From d2afc04094c56342e60f355b2f56096c8a188d01 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Thu, 25 Feb 2021 18:14:57 +0100 Subject: [PATCH] Properly kill commands and add mouse/arrow keys support (#7) --- .eslintrc.js | 1 + Makefile | 4 + README.md | 20 +- demo/server.js | 7 +- example.json | 2 +- get-cursor-position.js | 1 - package-lock.json | 264 +++++---------- package.json | 15 +- run-pty.js | 741 ++++++++++++++++++++++++++++++++--------- test-clear.js | 65 ++++ test-line-movements.js | 17 + test-mouse.js | 57 ++++ test/run-pty.test.js | 133 ++++---- 13 files changed, 913 insertions(+), 414 deletions(-) create mode 100644 test-clear.js create mode 100644 test-line-movements.js create mode 100644 test-mouse.js diff --git a/.eslintrc.js b/.eslintrc.js index 4b66144..43d4cdd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { "arrow-body-style": "error", curly: "error", "dot-notation": "error", + "no-control-regex": "off", "no-fallthrough": "off", "no-shadow": "error", "object-shorthand": "error", diff --git a/Makefile b/Makefile index 1d17810..a9fc738 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,7 @@ watch: echo "Watching..." cat + +.PHONY: signals +signals: + node signals.js diff --git a/README.md b/README.md index 5a080d3..53f1494 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,12 @@ $ npm start ➡️ ``` -[1] 🟢 npm run frontend -[2] 🟢 npm run backend +[1] 🟢 npm run frontend +[2] 🟢 npm run backend -[1-2] focus command +[1-2] focus command (or click) [ctrl+c] kill all +[↑/↓] move selection ``` ➡️ 1 ️️➡️ @@ -53,11 +54,9 @@ $ npm start [9:51:27 AM]: Producing bundles... [9:51:27 AM]: Packaging... [9:51:27 AM]: ✨ Built in 67ms. - +▊ [ctrl+c] kill (pid 63096) [ctrl+z] dashboard - -▊ ``` ➡️ ctrl+c ➡️ @@ -74,6 +73,7 @@ $ npm start [9:51:27 AM]: Producing bundles... [9:51:27 AM]: Packaging... [9:51:27 AM]: ✨ Built in 67ms. +^C ⚪ npm run frontend exit 0 @@ -86,11 +86,13 @@ exit 0 ➡️ ctrl+z ➡️ ``` -[1] ⚪ exit 0 npm run frontend -[2] 🟢 npm run backend +[1] ⚪ exit 0 npm run frontend +[2] 🟢 npm run backend -[1-2] focus command +[1-2] focus command (or click) [ctrl+c] kill all +[↑/↓] move selection +[enter] restart exited ``` ➡️ ctrl+c ➡️ diff --git a/demo/server.js b/demo/server.js index 195d901..b4a5ad0 100644 --- a/demo/server.js +++ b/demo/server.js @@ -1,4 +1,9 @@ "use strict"; console.log("Listening on port 1337"); -process.stdin.resume(); + +const interval = Number(process.argv[2]) || 1000; + +setInterval(() => { + console.log(new Date()); +}, interval); diff --git a/example.json b/example.json index 342fb3d..62a0838 100644 --- a/example.json +++ b/example.json @@ -18,7 +18,7 @@ "cwd": "demo" }, { - "title": "node \u001B[91mLorem ipsum dolor sit amet \u001B[92mconsectetur adipiscing elit ac \u001B[93mfaucibus, senectus neque \u001B[94metiam tempus tortor suscipit \u001B[95mquis auctor, id ad fusce \u001B[96meleifend lobortis integer elementum praesent. \u001B[91mSodales quam elementum dui conubia purus \u001B[92maliquam facilisi bibendum senectus, \u001B[93mnetus consequat nec felis posuere \u001B[94merat himenaeos. Vitae conubia nisi \u001B[95minterdum vestibulum neque est quisque, \u001B[96mfacilisis elementum ultricies commodo feugiat \u001B[91mnatoque mi, eu potenti posuere \u001B[92meros condimentum ridiculus", + "title": "node \u001B[91mLorem \u001B[27mipsum \u001B[mdolor sit amet \u001B[92mconsectetur adipiscing \u001B[0melit ac \u001B[93mfaucibus, senectus neque \u001B[94metiam tempus tortor suscipit \u001B[95mquis auctor, id ad fusce \u001B[96meleifend lobortis integer elementum praesent. \u001B[91mSodales quam elementum dui conubia purus \u001B[92maliquam facilisi bibendum senectus, \u001B[93mnetus consequat nec felis posuere \u001B[94merat himenaeos. Vitae conubia nisi \u001B[95minterdum vestibulum neque est quisque, \u001B[96mfacilisis elementum ultricies commodo feugiat \u001B[91mnatoque mi, eu potenti posuere \u001B[92meros condimentum ridiculus", "command": ["node"], "defaultStatus": [ "status Lorem ipsum dolor sit amet consectetur adipiscing elit ac faucibus, senectus neque etiam tempus tortor suscipit quis auctor, id ad fusce eleifend lobortis integer elementum praesent. Sodales quam elementum dui conubia purus aliquam facilisi bibendum senectus, netus consequat nec felis posuere erat himenaeos. Vitae conubia nisi interdum vestibulum neque est quisque, facilisis elementum ultricies commodo feugiat natoque mi, eu potenti posuere eros condimentum ridiculus", diff --git a/get-cursor-position.js b/get-cursor-position.js index 1b6d06f..a224d12 100644 --- a/get-cursor-position.js +++ b/get-cursor-position.js @@ -4,7 +4,6 @@ process.stdin.setRawMode(true); process.stdin.on("data", (data) => { const string = data.toString("utf8"); - // eslint-disable-next-line no-control-regex if (/^\x1B\[\d+;\d+R$/.test(string)) { console.log("Got cursor position reply:", JSON.stringify(string)); } else if (string === "\x03") { diff --git a/package-lock.json b/package-lock.json index 4515251..987b99c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -800,9 +800,9 @@ } }, "@types/json-schema": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", - "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", + "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, "@types/node": { @@ -845,188 +845,85 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz", - "integrity": "sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.2.tgz", + "integrity": "sha512-uiQQeu9tWl3f1+oK0yoAv9lt/KXO24iafxgQTkIYO/kitruILGx3uH+QtIAHqxFV+yIsdnJH+alel9KuE3J15Q==", "dev": true, "requires": { - "@typescript-eslint/experimental-utils": "4.14.0", - "@typescript-eslint/scope-manager": "4.14.0", + "@typescript-eslint/experimental-utils": "4.15.2", + "@typescript-eslint/scope-manager": "4.15.2", "debug": "^4.1.1", "functional-red-black-tree": "^1.0.1", "lodash": "^4.17.15", "regexpp": "^3.0.0", "semver": "^7.3.2", "tsutils": "^3.17.1" - }, - "dependencies": { - "@typescript-eslint/experimental-utils": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz", - "integrity": "sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.14.0", - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/typescript-estree": "4.14.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^2.0.0" - } - }, - "@typescript-eslint/scope-manager": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz", - "integrity": "sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/visitor-keys": "4.14.0" - } - }, - "@typescript-eslint/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.14.0.tgz", - "integrity": "sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz", - "integrity": "sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/visitor-keys": "4.14.0", - "debug": "^4.1.1", - "globby": "^11.0.1", - "is-glob": "^4.0.1", - "lodash": "^4.17.15", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz", - "integrity": "sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "eslint-visitor-keys": "^2.0.0" - } - } } }, "@typescript-eslint/experimental-utils": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.11.1.tgz", - "integrity": "sha512-mAlWowT4A6h0TC9F+J5pdbEhjNiEMO+kqPKQ4sc3fVieKL71dEqfkKgtcFVSX3cjSBwYwhImaQ/mXQF0oaI38g==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.2.tgz", + "integrity": "sha512-Fxoshw8+R5X3/Vmqwsjc8nRO/7iTysRtDqx6rlfLZ7HbT8TZhPeQqbPjTyk2RheH3L8afumecTQnUc9EeXxohQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.11.1", - "@typescript-eslint/types": "4.11.1", - "@typescript-eslint/typescript-estree": "4.11.1", + "@typescript-eslint/scope-manager": "4.15.2", + "@typescript-eslint/types": "4.15.2", + "@typescript-eslint/typescript-estree": "4.15.2", "eslint-scope": "^5.0.0", "eslint-utils": "^2.0.0" } }, "@typescript-eslint/parser": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.14.0.tgz", - "integrity": "sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.15.2.tgz", + "integrity": "sha512-SHeF8xbsC6z2FKXsaTb1tBCf0QZsjJ94H6Bo51Y1aVEZ4XAefaw5ZAilMoDPlGghe+qtq7XdTiDlGfVTOmvA+Q==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.14.0", - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/typescript-estree": "4.14.0", + "@typescript-eslint/scope-manager": "4.15.2", + "@typescript-eslint/types": "4.15.2", + "@typescript-eslint/typescript-estree": "4.15.2", "debug": "^4.1.1" - }, - "dependencies": { - "@typescript-eslint/scope-manager": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz", - "integrity": "sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/visitor-keys": "4.14.0" - } - }, - "@typescript-eslint/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.14.0.tgz", - "integrity": "sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz", - "integrity": "sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "@typescript-eslint/visitor-keys": "4.14.0", - "debug": "^4.1.1", - "globby": "^11.0.1", - "is-glob": "^4.0.1", - "lodash": "^4.17.15", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz", - "integrity": "sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.14.0", - "eslint-visitor-keys": "^2.0.0" - } - } } }, "@typescript-eslint/scope-manager": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.11.1.tgz", - "integrity": "sha512-Al2P394dx+kXCl61fhrrZ1FTI7qsRDIUiVSuN6rTwss6lUn8uVO2+nnF4AvO0ug8vMsy3ShkbxLu/uWZdTtJMQ==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.15.2.tgz", + "integrity": "sha512-Zm0tf/MSKuX6aeJmuXexgdVyxT9/oJJhaCkijv0DvJVT3ui4zY6XYd6iwIo/8GEZGy43cd7w1rFMiCLHbRzAPQ==", "dev": true, "requires": { - "@typescript-eslint/types": "4.11.1", - "@typescript-eslint/visitor-keys": "4.11.1" + "@typescript-eslint/types": "4.15.2", + "@typescript-eslint/visitor-keys": "4.15.2" } }, "@typescript-eslint/types": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.11.1.tgz", - "integrity": "sha512-5kvd38wZpqGY4yP/6W3qhYX6Hz0NwUbijVsX2rxczpY6OXaMxh0+5E5uLJKVFwaBM7PJe1wnMym85NfKYIh6CA==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.2.tgz", + "integrity": "sha512-r7lW7HFkAarfUylJ2tKndyO9njwSyoy6cpfDKWPX6/ctZA+QyaYscAHXVAfJqtnY6aaTwDYrOhp+ginlbc7HfQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.11.1.tgz", - "integrity": "sha512-tC7MKZIMRTYxQhrVAFoJq/DlRwv1bnqA4/S2r3+HuHibqvbrPcyf858lNzU7bFmy4mLeIHFYr34ar/1KumwyRw==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.2.tgz", + "integrity": "sha512-cGR8C2g5SPtHTQvAymEODeqx90pJHadWsgTtx6GbnTWKqsg7yp6Eaya9nFzUd4KrKhxdYTTFBiYeTPQaz/l8bw==", "dev": true, "requires": { - "@typescript-eslint/types": "4.11.1", - "@typescript-eslint/visitor-keys": "4.11.1", + "@typescript-eslint/types": "4.15.2", + "@typescript-eslint/visitor-keys": "4.15.2", "debug": "^4.1.1", "globby": "^11.0.1", "is-glob": "^4.0.1", - "lodash": "^4.17.15", "semver": "^7.3.2", "tsutils": "^3.17.1" } }, "@typescript-eslint/visitor-keys": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.11.1.tgz", - "integrity": "sha512-IrlBhD9bm4bdYcS8xpWarazkKXlE7iYb1HzRuyBP114mIaj5DJPo11Us1HgH60dTt41TCZXMaTCAW+OILIYPOg==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.2.tgz", + "integrity": "sha512-TME1VgSb7wTwgENN5KVj4Nqg25hP8DisXxNBojM4Nn31rYaNDIocNm5cmjOFfh42n7NVERxWrDFoETO/76ePyg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.11.1", + "@typescript-eslint/types": "4.15.2", "eslint-visitor-keys": "^2.0.0" } }, @@ -1877,12 +1774,12 @@ } }, "eslint": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.18.0.tgz", - "integrity": "sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ==", + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.20.0.tgz", + "integrity": "sha512-qGi0CTcOGP2OtCQBgWZlQjcTuP0XkIpYFj25XtRTQSHC+umNnp7UMshr2G8SLsRFYDdAPFeHOsiteadmMH02Yw==", "dev": true, "requires": { - "@babel/code-frame": "^7.0.0", + "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.3.0", "ajv": "^6.10.0", "chalk": "^4.0.0", @@ -1894,7 +1791,7 @@ "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", - "esquery": "^1.2.0", + "esquery": "^1.4.0", "esutils": "^2.0.2", "file-entry-cache": "^6.0.0", "functional-red-black-tree": "^1.0.1", @@ -1930,9 +1827,9 @@ } }, "eslint-plugin-jest": { - "version": "24.1.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.3.tgz", - "integrity": "sha512-dNGGjzuEzCE3d5EPZQ/QGtmlMotqnYWD/QpCZ1UuZlrMAdhG5rldh0N0haCvhGnUkSeuORS5VNROwF9Hrgn3Lg==", + "version": "24.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.5.tgz", + "integrity": "sha512-FIP3lwC8EzEG+rOs1y96cOJmMVpdFNreoDJv29B5vIupVssRi8zrSY3QadogT0K3h1Y8TMxJ6ZSAzYUmFCp2hg==", "dev": true, "requires": { "@typescript-eslint/experimental-utils": "^4.0.1" @@ -1997,9 +1894,9 @@ "dev": true }, "esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -2289,9 +2186,9 @@ "dev": true }, "fast-glob": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", - "integrity": "sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", + "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", @@ -2315,9 +2212,9 @@ "dev": true }, "fastq": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.10.0.tgz", - "integrity": "sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", + "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", "dev": true, "requires": { "reusify": "^1.0.4" @@ -2508,9 +2405,9 @@ } }, "globby": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz", - "integrity": "sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", + "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", "dev": true, "requires": { "array-union": "^2.1.0", @@ -3769,9 +3666,9 @@ "dev": true }, "nan": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", - "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==" + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" }, "nanomatch": { "version": "1.2.13", @@ -3832,9 +3729,9 @@ } }, "node-pty": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-0.9.0.tgz", - "integrity": "sha512-MBnCQl83FTYOu7B4xWw10AW77AAh7ThCE1VXEv+JeWj8mSpGo+0bwgsV+b23ljBFwEM9OmsOv3kM27iUPPm84g==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-0.10.0.tgz", + "integrity": "sha512-Q65ookKbjhqWUYKmtZ6iPn0nnqNdzpm3YJOBmzwWJde/TrenBxK9FgqGGtSW0Wjz4YsR1grQF4a7RS5nBwuW9A==", "requires": { "nan": "^2.14.0" } @@ -4178,6 +4075,12 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, + "queue-microtask": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz", + "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==", + "dev": true + }, "react-is": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", @@ -4412,10 +4315,13 @@ "dev": true }, "run-parallel": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", - "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", - "dev": true + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } }, "safe-buffer": { "version": "5.1.2", @@ -5040,9 +4946,9 @@ }, "dependencies": { "ajv": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz", - "integrity": "sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.1.0.tgz", + "integrity": "sha512-svS9uILze/cXbH0z2myCK2Brqprx/+JJYK5pHicT/GQiBfzzhUVAIT6MwqJg8y4xV/zoGsUeuPuwtoiKSGE15g==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -5172,9 +5078,9 @@ "dev": true }, "tsutils": { - "version": "3.17.1", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", - "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.20.0.tgz", + "integrity": "sha512-RYbuQuvkhuqVeXweWT3tJLKOEJ/UUw9GjNEZGWdrLLlM+611o1gwLHBpxoFJKKl25fLprp2eVthtKs5JOrNeXg==", "dev": true, "requires": { "tslib": "^1.8.1" @@ -5226,9 +5132,9 @@ } }, "typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.2.tgz", + "integrity": "sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ==", "dev": true }, "union-value": { diff --git a/package.json b/package.json index 08b96b2..3994e2c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "description": "Run several commands concurrently. Show output for one command at a time. Kill all at once.", "repository": "lydell/run-pty", "type": "commonjs", - "exports": {}, "bin": "./run-pty.js", "files": [ "run-pty.js" @@ -25,21 +24,21 @@ "xterm" ], "dependencies": { - "node-pty": "^0.9.0" + "node-pty": "^0.10.0" }, "devDependencies": { "@types/jest": "26.0.20", - "@typescript-eslint/eslint-plugin": "4.14.0", - "@typescript-eslint/parser": "4.14.0", - "eslint": "7.18.0", - "eslint-plugin-jest": "24.1.3", + "@typescript-eslint/eslint-plugin": "4.15.2", + "@typescript-eslint/parser": "4.15.2", + "eslint": "7.20.0", + "eslint-plugin-jest": "24.1.5", "jest": "26.6.3", "jest-environment-node-single-context": "26.2.0", "prettier": "2.2.1", - "typescript": "4.1.3" + "typescript": "4.2.2" }, "scripts": { - "start": "node run-pty.js % cat % false % echo hello world % ping localhost % node get-cursor-position.js % node test-keys.js % node signals.js % node slow-kill.js % node slow-kill.js 2000 \"Shutting down…\" % make watch", + "start": "node run-pty.js % cat % false % echo hello world % ping localhost % node get-cursor-position.js % node test-keys.js % node signals.js % node slow-kill.js % node slow-kill.js 2000 \"Shutting down…\" % make watch % make signals", "example": "node run-pty.js example.json", "test": "prettier --check . && eslint . --report-unused-disable-directives && tsc && jest", "prepublishOnly": "npm test" diff --git a/run-pty.js b/run-pty.js index 02cc436..8b4c5f1 100755 --- a/run-pty.js +++ b/run-pty.js @@ -9,7 +9,7 @@ const pty = require("node-pty"); /** * @typedef { | { tag: "Running", terminal: import("node-pty").IPty } - | { tag: "Killing", terminal: import("node-pty").IPty, slow: boolean } + | { tag: "Killing", terminal: import("node-pty").IPty, slow: boolean, lastKillPress: number | undefined } | { tag: "Exit", exitCode: number } } Status * @@ -19,10 +19,13 @@ const pty = require("node-pty"); } Current */ -// node-pty does not support kill signals on Windows. -// This is the same check that node-pty uses. const IS_WINDOWS = process.platform === "win32"; +const SLOW_KILL = 100; // ms + +// This is apparently what Windows uses for double clicks. +const DOUBLE_PRESS = 500; // ms + const MAX_HISTORY_DEFAULT = 1000000; const MAX_HISTORY = (() => { @@ -38,12 +41,22 @@ const KEYS = { kill: "ctrl+c", restart: "enter", dashboard: "ctrl+z", + navigate: "↑/↓", + enter: "enter", + unselect: "escape", }; const KEY_CODES = { kill: "\x03", restart: "\r", dashboard: "\x1a", + up: "\x1B[A", + upVim: "k", + down: "\x1B[B", + downVim: "j", + enter: "\r", + enterVim: "o", + esc: "\x1B", }; const ALPHABET = "abcdefghijklmnopqrstuvwxyz"; @@ -52,12 +65,50 @@ const ALL_LABELS = LABEL_GROUPS.join(""); const HIDE_CURSOR = "\x1B[?25l"; const SHOW_CURSOR = "\x1B[?25h"; +const CURSOR_UP = "\x1B[A"; +const CURSOR_DOWN = "\x1B[B"; +const ENABLE_ALTERNATE_SCREEN = "\x1B[?1049h"; const DISABLE_ALTERNATE_SCREEN = "\x1B[?1049l"; +const ALTERNATE_SCREEN_REGEX = /(\x1B\[\?1049[hl])/; const DISABLE_BRACKETED_PASTE_MODE = "\x1B[?2004l"; +const DISABLE_APPLICATION_CURSOR_KEYS = "\x1B[?1l"; // https://www.vt100.net/docs/vt510-rm/DECCKM.html +const ENABLE_MOUSE = "\x1B[?1000;1006h"; +const DISABLE_MOUSE = "\x1B[?1000;1006l"; const RESET_COLOR = "\x1B[m"; -const CLEAR = IS_WINDOWS ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H"; +const CLEAR = "\x1B[2J\x1B[3J\x1B[H"; const CLEAR_RIGHT = "\x1B[K"; +const CLEAR_REGEX = (() => { + const goToTopLeft = /(?:[01](?:;[01])?)?[fH]/; + const clearDown = /0?J/; + const clearScreen = /2J/; + const clearScrollback = /3J/; + + /** + * @template T + * @param {Array} items + * @returns {Array>} + */ + const permutations = (items) => + items.length <= 1 + ? [items] + : items.flatMap((first, index) => + permutations([ + ...items.slice(0, index), + ...items.slice(index + 1), + ]).map((rest) => [first, ...rest]) + ); + + const variants = [ + ...permutations([clearScreen, clearScrollback, goToTopLeft]), + [clearScrollback, goToTopLeft, clearDown], + [goToTopLeft, clearDown, clearScrollback], + [goToTopLeft, clearScrollback, clearDown], + ].map((parts) => parts.map((part) => `\\x1B\\[${part.source}`).join("")); + + return RegExp(`(?:${variants.join("|")})$`); +})(); + const runningIndicator = NO_COLOR ? "›" : IS_WINDOWS @@ -75,7 +126,8 @@ const killingIndicator = NO_COLOR * @returns {string} */ const exitIndicator = (exitCode) => - exitCode === 0 + // 130 commonly means exit by ctrl+c. + exitCode === 0 || exitCode === 130 ? NO_COLOR ? "●" : IS_WINDOWS @@ -109,7 +161,20 @@ const dim = (string) => (NO_COLOR ? string : `\x1B[2m${string}${RESET_COLOR}`); /** * @param {string} string - * @param {{ pad?: boolean }} pad + * @returns {string} + */ +const invert = (string) => { + const inverted = string + // Split on RESET_COLOR and stop invert (27). + .split(/(\x1B\[(?:0?|27)m)/) + .map((part, index) => (index % 2 === 0 ? `\x1B[7m${part}` : part)) + .join(""); + return NO_COLOR ? string : `${inverted}${RESET_COLOR}`; +}; + +/** + * @param {string} string + * @param {{ pad?: boolean, highlight?: boolean }} pad */ const shortcut = (string, { pad = true } = {}) => dim("[") + @@ -153,11 +218,6 @@ Run several commands concurrently. Show output for one command at a time. Kill all at once. - ${shortcut(summarizeLabels(ALL_LABELS.split("")))} focus command - ${shortcut(KEYS.dashboard)} dashboard - ${shortcut(KEYS.kill)} kill focused/all - ${shortcut(KEYS.restart)} restart killed/exited command - Separate the commands with a character of choice: ${runPty} ${pc} npm start ${pc} make watch ${pc} some_command arg1 arg2 arg3 @@ -171,6 +231,12 @@ Alternatively, specify the commands in a JSON (or NDJSON) file: ${runPty} run-pty.json +Keyboard shortcuts: + + ${shortcut(KEYS.dashboard)} Dashboard + ${shortcut(KEYS.kill)} Kill all or focused command + Other keyboard shortcuts are shown as needed. + Environment variables: ${bold("RUN_PTY_MAX_HISTORY")} @@ -189,7 +255,7 @@ Environment variables: */ const killAllLabel = (commands) => commands.some((command) => command.status.tag === "Killing") - ? "force kill all" + ? `kill all ${dim("(double-press to force) ")}` : commands.every((command) => command.status.tag === "Exit") ? "exit" : "kill all"; @@ -197,59 +263,119 @@ const killAllLabel = (commands) => /** * @param {Array} commands * @param {number} width - * @param {boolean} attemptedKillAll + * @param {Selection} selection + * @returns {Array<{ line: string, length: number }>} */ -const drawDashboard = (commands, width, attemptedKillAll) => { +const drawDashboardCommandLines = (commands, width, selection) => { const lines = commands.map((command) => { const [icon, status] = statusText(command.status, command.statusFromRules); return { label: shortcut(command.label || " ", { pad: false }), icon, status, - title: command.title, + title: command.titleWithGraphicRenditions, }; }); + const separator = " "; + const widestStatus = Math.max( 0, ...lines.map(({ status }) => (status === undefined ? 0 : status.length)) ); - const finalLines = lines - .map(({ label, icon, status, title }) => { - const separator = " "; - const start = truncate(`${label}${separator}${icon}`, width); - const startLength = - removeGraphicRenditions(label).length + separator.length + ICON_WIDTH; - const end = - status === undefined - ? title - : `${status.padEnd(widestStatus, " ")}${separator}${title}`; - return `${start}${RESET_COLOR}${cursorHorizontalAbsolute( + return lines.map(({ label, icon, status, title }, index) => { + const start = truncate(`${label}${separator}${icon}`, width); + const startLength = + removeGraphicRenditions(label).length + separator.length + ICON_WIDTH; + const end = + status === undefined + ? title + : `${status.padEnd(widestStatus, " ")}${separator}${title}`; + const truncatedEnd = truncate(end, width - startLength - separator.length); + const length = + startLength + + separator.length + + removeGraphicRenditions(truncatedEnd).length; + const finalEnd = + selection.tag !== "Invisible" && index === selection.index + ? NO_COLOR + ? `${separator.slice(0, -1)}→${truncatedEnd}` + : `${separator}${invert(truncatedEnd)}` + : `${separator}${truncatedEnd}`; + return { + line: `${start}${RESET_COLOR}${cursorHorizontalAbsolute( startLength + 1 - )}${CLEAR_RIGHT}${separator}${truncate( - end, - width - startLength - separator.length - )}${RESET_COLOR}`; - }) - .join("\n"); - - const label = summarizeLabels(commands.map((command) => command.label)); + )}${CLEAR_RIGHT}${finalEnd}${RESET_COLOR}`, + length, + }; + }); +}; - if ( +/** + * @param {Array} commands + * @param {number} width + * @param {boolean} attemptedKillAll + * @param {Selection} selection + * @returns {string} + */ +const drawDashboard = (commands, width, attemptedKillAll, selection) => { + const done = attemptedKillAll && - commands.every((command) => command.status.tag === "Exit") - ) { + commands.every((command) => command.status.tag === "Exit"); + + const finalLines = drawDashboardCommandLines( + commands, + width, + done ? { tag: "Invisible", index: 0 } : selection + ) + .map(({ line }) => line) + .join("\n"); + + if (done) { return `${finalLines}\n`; } - // Newlines at the end are wanted here. + const label = summarizeLabels(commands.map((command) => command.label)); + + const click = IS_WINDOWS ? "" : ` ${dim("(or click)")}`; + + const pid = + selection.tag === "Keyboard" + ? getPid(commands[selection.index]) + : undefined; + + const enter = + pid === undefined + ? commands.some((command) => command.status.tag === "Exit") + ? `${shortcut(KEYS.enter)} restart exited` + : "" + : `${shortcut(KEYS.enter)} focus selected${pid}\n${shortcut( + KEYS.unselect + )} unselect`; + return ` ${finalLines} -${shortcut(label)} focus command +${shortcut(label)} focus command${click} ${shortcut(KEYS.kill)} ${killAllLabel(commands)} -`.trimStart(); +${shortcut(KEYS.navigate)} move selection +${enter} +`.trim(); +}; + +/** + * @param {Command} command + * @returns {string} + */ +const getPid = (command) => { + switch (command.status.tag) { + case "Running": + case "Killing": + return ` ${dim(`(pid ${command.status.terminal.pid})`)}`; + case "Exit": + return ""; + } }; /** @@ -261,8 +387,7 @@ ${shortcut(KEYS.kill)} ${killAllLabel(commands)} * @returns {string} */ const cwdText = (command) => - path.resolve(command.cwd) === process.cwd() || - command.cwd === removeGraphicRenditions(command.title) + path.resolve(command.cwd) === process.cwd() || command.cwd === command.title ? "" : `${folder}${EMOJI_WIDTH_FIX} ${dim(command.cwd)}\n`; @@ -280,29 +405,20 @@ const historyStart = (command) => * @returns {string} */ const runningText = (pid) => - // Newlines at the start/end are wanted here. ` ${shortcut(KEYS.kill)} kill ${dim(`(pid ${pid})`)} ${shortcut(KEYS.dashboard)} dashboard - -`; +`.trimEnd(); /** - * @param {CommandText} command * @param {number} pid * @returns {string} */ -const killingText = (command, pid) => - // Newlines at the start/end are wanted here. +const killingText = (pid) => ` -${killingIndicator}${EMOJI_WIDTH_FIX} ${ - command.formattedCommandWithTitle - }${RESET_COLOR} -${cwdText(command)}killing… - -${shortcut(KEYS.kill)} force kill ${dim(`(pid ${pid})`)} +${shortcut(KEYS.kill)} kill ${dim(`(double-press to force) (pid ${pid})`)} ${shortcut(KEYS.dashboard)} dashboard -`; +`.trimEnd(); /** * @param {Array} commands @@ -311,7 +427,6 @@ ${shortcut(KEYS.dashboard)} dashboard * @returns {string} */ const exitText = (commands, command, exitCode) => - // Newlines at the start/end are wanted here. ` ${exitIndicator(exitCode)}${EMOJI_WIDTH_FIX} ${ command.formattedCommandWithTitle @@ -321,7 +436,7 @@ ${cwdText(command)}exit ${exitCode} ${shortcut(KEYS.restart)} restart ${shortcut(KEYS.kill)} ${killAllLabel(commands)} ${shortcut(KEYS.dashboard)} dashboard -`; +`.trimEnd(); /** * @param {Status} status @@ -341,9 +456,33 @@ const statusText = (status, statusFromRules = runningIndicator) => { } }; -// eslint-disable-next-line no-control-regex +// If a command moves the cursor to another line it’s not considered a “simple +// log”. Then it’s not safe to print the keyboard shortcuts. +// +// - A, B: Cursor up/down. +// - C, D: Cursor left/right. Should be safe! Parcel does this. +// - E, F: Cursor up/down, and to the start of the line. +// - G: Cursor absolute position within line. Should be safe! Again, Parcel. +// - H, f: Cursor absolute position, both x and y. Exception: Moving to the +// top-left corner and clearing the screen (ctrl+l). +// - I, Z: Cursor forward/backward by tab stops. Should be safe. +// - J: Clear the screen in different ways. Should be safe. It might clear away +// the keyboard shortcuts too, but they’ll reappear once a new line is written. +// - K: Erase in line. Should be safe. +// - L: Insert lines. +// - M: Delete lines. +// - S: Scroll up. +// - T: Scroll down. +// - s: Save cursor position. +// - u: Restore cursor position. +const NOT_SIMPLE_LOG_ESCAPE = /\x1B\[(?:\d*[ABEFLMST]|[su]|(?!(?:[01](?:;[01])?)?[fH]\x1B\[[02]?J)(?:\d+(?:;\d+)?)?[fH])/; const GRAPHIC_RENDITIONS = /(\x1B\[(?:\d+(?:;\d+)*)?m)/g; +// Windows likes putting a RESET_COLOR at the start of lines if the previous +// line was colored. I’ve also seen tools print color codes after the newline. +// Ignore that and consider the line empty anyway. +const LAST_LINE_REGEX = /(?:^|\n)(?:\x1B\[(?:\d+(?:;\d+)*)?m)?([^\n]*)$/; + /** * @param {string} string * @returns {string} @@ -375,6 +514,26 @@ const truncate = (string, maxLength) => { return result; }; +/** + * @param {string} string + * @returns {string} + */ +const moveBack = (string) => + string === "" ? "" : `${CURSOR_UP.repeat(string.split("\n").length - 1)}\r`; + +/** + * Assumes that `moveBack` has been run first. Always clears the first line. + * + * @param {string} string + * @returns {string} + */ +const erase = (string) => { + const numLines = string.split("\n").length; + return `\r${CLEAR_RIGHT}${`${CURSOR_DOWN}${CLEAR_RIGHT}`.repeat( + numLines - 1 + )}${CURSOR_UP.repeat(numLines - 1)}`; +}; + /** * @param {Array} command * @returns {string} @@ -708,6 +867,20 @@ const parseStatus = (json) => { return [value1, value2]; }; +/** + * @param {Command} command + * @returns {string} + */ +const joinHistory = (command) => + command.history + + (command.historyAlternateScreen === "" + ? command.isOnAlternateScreen + ? ENABLE_ALTERNATE_SCREEN + : "" + : ENABLE_ALTERNATE_SCREEN + + command.historyAlternateScreen + + (command.isOnAlternateScreen ? "" : DISABLE_ALTERNATE_SCREEN)); + class Command { /** * @param {{ @@ -734,15 +907,20 @@ class Command { this.file = file; this.args = args; this.cwd = cwd; - this.title = title; + this.title = removeGraphicRenditions(title); + this.titleWithGraphicRenditions = title; this.formattedCommandWithTitle = title === formattedCommand ? formattedCommand + : NO_COLOR + ? `${removeGraphicRenditions(title)}: ${formattedCommand}` : `${bold(`${title}${RESET_COLOR}:`)} ${formattedCommand}`; this.onData = onData; this.onExit = onExit; - /** @type {string} */ this.history = ""; + this.historyAlternateScreen = ""; + this.isSimpleLog = true; + this.isOnAlternateScreen = false; /** @type {Status} */ this.status = { tag: "Exit", exitCode: 0 }; /** @type {string | undefined} */ @@ -765,6 +943,9 @@ class Command { } this.history = historyStart(this); + this.historyAlternateScreen = ""; + this.isSimpleLog = true; + this.isOnAlternateScreen = false; this.statusFromRules = extractStatus(this.defaultStatus); const [file, args] = IS_WINDOWS @@ -789,14 +970,33 @@ class Command { }); if (IS_WINDOWS) { - // Needed when using `conptyInheritCursor`. Otherwise the spawned - // terminals hang and will not run their command. - terminal.write("\x1B[1;1R"); + // See `onData` below for why we do this. + // It’s important to get the line number right. Otherwise the pty emits + // cursor movements trying to adjust for it or something, resulting in + // lost lines of output (cursor is moved up and lines are overwritten). + terminal.write(`\x1B[${this.history.split("\n").length};1R`); } + let first = true; + const disposeOnData = terminal.onData((data) => { - const statusFromRulesChanged = this.pushHistory(data); - this.onData(data, statusFromRulesChanged); + // When using `conptyInheritCursor` (Windows only), a 6n escape is the + // first thing we get here. If we print that code to the console, we will + // get a `\x1B[2;1R` (cursor position) reply on stdin. The pty is waiting + // for such a message. By default we pass on all stdin so the pty gets it. + // So if we have a single (focused by default) command it all works + // automatically. But if we have multiple commands, the pty still waits + // for the message before executing the command. And we won’t print the 6n + // escape until we focus it. This means commands effectively don’t start + // executing until focused. For this reason we ignore this escape and send + // the reply above instead. This has the side bonus of the 6n escape never + // reaching the command’s stdin. + const shouldIgnore = IS_WINDOWS && first && data === "\x1B[6n"; + first = false; + if (!shouldIgnore) { + const statusFromRulesChanged = this.pushHistory(data); + this.onData(data, statusFromRulesChanged); + } }); const disposeOnExit = terminal.onExit(({ exitCode }) => { @@ -813,13 +1013,13 @@ class Command { * @returns {undefined} */ kill() { - // https://www.gnu.org/software/libc/manual/html_node/Termination-Signals.html switch (this.status.tag) { case "Running": this.status = { tag: "Killing", terminal: this.status.terminal, slow: false, + lastKillPress: undefined, }; setTimeout(() => { if (this.status.tag === "Killing") { @@ -827,24 +1027,27 @@ class Command { // Ugly way to redraw: this.onData("", false); } - }, 100); - if (IS_WINDOWS) { - this.status.terminal.kill(); - } else { - // SIGHUP causes a silent exit for `npm run`. - this.status.terminal.kill("SIGHUP"); - // SIGTERM is needed for some programs (but is noisy for `npm run`). - this.status.terminal.kill("SIGTERM"); - } + }, SLOW_KILL); + this.status.terminal.write(KEY_CODES.kill); return undefined; - case "Killing": - if (IS_WINDOWS) { - this.status.terminal.kill(); + case "Killing": { + const now = Date.now(); + if ( + this.status.lastKillPress !== undefined && + now - this.status.lastKillPress <= DOUBLE_PRESS + ) { + if (IS_WINDOWS) { + this.status.terminal.kill(); + } else { + this.status.terminal.kill("SIGKILL"); + } } else { - this.status.terminal.kill("SIGKILL"); + this.status.terminal.write(KEY_CODES.kill); } + this.status.lastKillPress = now; return undefined; + } case "Exit": throw new Error(`Cannot kill already exited pty for: ${this.title}`); @@ -856,21 +1059,60 @@ class Command { * @returns {boolean} */ pushHistory(data) { - const statusFromRulesChanged = this.updateStatusFromRules(data); - this.history += data; - if (this.history.length > MAX_HISTORY) { - this.history = this.history.slice(-MAX_HISTORY); + const previousStatusFromRules = this.statusFromRules; + + for (const part of data.split(ALTERNATE_SCREEN_REGEX)) { + switch (part) { + case ENABLE_ALTERNATE_SCREEN: + this.isOnAlternateScreen = true; + break; + case DISABLE_ALTERNATE_SCREEN: + this.isOnAlternateScreen = false; + break; + default: + this.updateStatusFromRules(part); + if (this.isOnAlternateScreen) { + this.historyAlternateScreen += part; + if (CLEAR_REGEX.test(this.historyAlternateScreen)) { + this.historyAlternateScreen = ""; + } else { + if (this.historyAlternateScreen.length > MAX_HISTORY) { + this.historyAlternateScreen = this.historyAlternateScreen.slice( + -MAX_HISTORY + ); + } + } + } else { + this.history += part; + if (CLEAR_REGEX.test(this.history)) { + this.history = ""; + this.isSimpleLog = true; + } else { + if (this.history.length > MAX_HISTORY) { + this.history = this.history.slice(-MAX_HISTORY); + } + if (this.isSimpleLog && NOT_SIMPLE_LOG_ESCAPE.test(part)) { + this.isSimpleLog = false; + } + } + } + } } + + const statusFromRulesChanged = + this.statusFromRules !== previousStatusFromRules; + return statusFromRulesChanged; } /** * @param {string} data - * @returns {boolean} + * @returns {void} */ updateStatusFromRules(data) { - const previousStatusFromRules = this.statusFromRules; - const lastLine = getLastLine(this.history); + const lastLine = getLastLine( + this.isOnAlternateScreen ? this.historyAlternateScreen : this.history + ); const lines = (lastLine + data).split(/(?:\r?\n|\r)/); for (const line of lines) { @@ -880,8 +1122,6 @@ class Command { } } } - - return this.statusFromRules !== previousStatusFromRules; } } @@ -913,6 +1153,13 @@ const getLastLine = (string) => { } return string.slice(index + 1); }; +/** + * @typedef { + | { tag: "Invisible", index: number } + | { tag: "Mousedown", index: number } + | { tag: "Keyboard", index: number } + } Selection + */ /** * @param {Array} commandDescriptions @@ -922,49 +1169,90 @@ const runCommands = (commandDescriptions) => { /** @type {Current} */ let current = { tag: "Dashboard" }; let attemptedKillAll = false; + /** @type {Selection} */ + let selection = { tag: "Invisible", index: 0 }; + let lastExtraText = ""; + let lastLine = ""; /** * @param {Command} command + * @param {string} data * @returns {undefined} */ - const printHistoryAndExtraText = (command) => { - process.stdout.write( - SHOW_CURSOR + - DISABLE_ALTERNATE_SCREEN + - RESET_COLOR + - CLEAR + - command.history - ); + const printExtraText = (command, data) => { + const match = LAST_LINE_REGEX.exec(command.history); + const previousLastLine = lastLine; + lastLine = match === null ? "" : match[1]; + + // Notes: + // - For a simple log (no cursor movements or anything) we can _always_ show + // extra text. Otherwise, it’s better not to print anything extra in. We + // don’t want to put something in the middle of the command’s output. + // - Visually the last line of the history does not need reprinting, but we + // do that anyway to get the cursor at the end of it. + + /** + * @param {string} extraText + * @returns {string} + */ + const helper = (extraText) => + RESET_COLOR + extraText + moveBack(extraText) + lastLine; switch (command.status.tag) { - case "Running": - if ( - command.history.endsWith("\n") || - command.history.endsWith(`\n${RESET_COLOR}`) - ) { - process.stdout.write( - RESET_COLOR + runningText(command.status.terminal.pid) - ); - } + case "Running": { + const extraText = command.isSimpleLog + ? helper(runningText(command.status.terminal.pid)) + : ""; + process.stdout.write( + extraText === "" && lastExtraText === "" + ? data + : erase(lastExtraText) + previousLastLine + data + extraText + ); + lastExtraText = extraText; return undefined; + } - case "Killing": - if (command.status.slow) { - process.stdout.write( - HIDE_CURSOR + - RESET_COLOR + - killingText(command, command.status.terminal.pid) - ); - } + case "Killing": { + const extraText = command.isSimpleLog + ? command.status.slow + ? helper(killingText(command.status.terminal.pid)) + : helper(runningText(command.status.terminal.pid)) + : ""; + process.stdout.write( + extraText === "" && lastExtraText === "" + ? data + : erase(lastExtraText) + previousLastLine + data + extraText + ); + lastExtraText = extraText; return undefined; + } - case "Exit": - process.stdout.write( + case "Exit": { + const maybeNewline = + // If the last line is empty, no newline is needed. + lastLine === "" ? "" : "\n"; + + // This has the side effect of moving the cursor, so only do it if needed. + const disableAlternateScreen = command.isOnAlternateScreen + ? DISABLE_ALTERNATE_SCREEN + : ""; + + const extraText = HIDE_CURSOR + - RESET_COLOR + - exitText(commands, command, command.status.exitCode) + RESET_COLOR + + disableAlternateScreen + + maybeNewline + + exitText(commands, command, command.status.exitCode); + + process.stdout.write( + lastExtraText === "" + ? data + extraText + : erase(lastExtraText) + previousLastLine + data + extraText ); + + lastExtraText = ""; return undefined; + } } }; @@ -976,9 +1264,16 @@ const runCommands = (commandDescriptions) => { process.stdout.write( HIDE_CURSOR + DISABLE_ALTERNATE_SCREEN + + DISABLE_APPLICATION_CURSOR_KEYS + + ENABLE_MOUSE + RESET_COLOR + CLEAR + - drawDashboard(commands, process.stdout.columns, attemptedKillAll) + drawDashboard( + commands, + process.stdout.columns, + attemptedKillAll, + selection + ) ); }; @@ -986,10 +1281,40 @@ const runCommands = (commandDescriptions) => { * @param {number} index * @returns {void} */ - const switchToCommand = (index) => { + const switchToCommand = (index, { hideSelection = false } = {}) => { const command = commands[index]; current = { tag: "Command", index }; - printHistoryAndExtraText(command); + if (hideSelection) { + selection = { tag: "Invisible", index }; + } + + process.stdout.write( + SHOW_CURSOR + + DISABLE_ALTERNATE_SCREEN + + DISABLE_APPLICATION_CURSOR_KEYS + + DISABLE_MOUSE + + RESET_COLOR + + CLEAR + ); + + lastExtraText = ""; + lastLine = ""; + + if (command.isOnAlternateScreen) { + process.stdout.write(joinHistory(command)); + } else { + printExtraText(command, joinHistory(command)); + } + }; + + /** + * @param {Selection} newSelection + * @returns {void} + */ + const setSelection = (newSelection) => { + selection = newSelection; + // Redraw dashboard. + switchToDashboard(); }; /** @@ -1012,6 +1337,20 @@ const runCommands = (commandDescriptions) => { } }; + /** + * @returns {void} + */ + const restartExited = () => { + const exited = commands.filter((command) => command.status.tag === "Exit"); + if (exited.length > 0) { + for (const command of exited) { + command.start(); + } + // Redraw dashboard. + switchToDashboard(); + } + }; + /** @type {Array} */ const commands = commandDescriptions.map( (commandDescription, index) => @@ -1025,14 +1364,13 @@ const runCommands = (commandDescriptions) => { const command = commands[index]; switch (command.status.tag) { case "Running": - process.stdout.write(data); - return undefined; - case "Killing": - // Redraw with killingText at the bottom. - printHistoryAndExtraText(command); + if (command.isOnAlternateScreen) { + process.stdout.write(data); + } else { + printExtraText(command, data); + } return undefined; - case "Exit": throw new Error( `Received unexpected output from already exited pty for: ${command.title}\n${data}` @@ -1063,8 +1401,7 @@ const runCommands = (commandDescriptions) => { case "Command": if (current.index === index) { const command = commands[index]; - // Redraw current command. - printHistoryAndExtraText(command); + printExtraText(command, ""); } return undefined; @@ -1100,10 +1437,12 @@ const runCommands = (commandDescriptions) => { data.toString("utf8"), current, commands, + selection, switchToDashboard, switchToCommand, + setSelection, killAll, - printHistoryAndExtraText + restartExited ); }); @@ -1131,11 +1470,11 @@ const runCommands = (commandDescriptions) => { process.on("exit", () => { process.stdout.write( - SHOW_CURSOR + DISABLE_BRACKETED_PASTE_MODE + RESET_COLOR + SHOW_CURSOR + DISABLE_BRACKETED_PASTE_MODE + DISABLE_MOUSE + RESET_COLOR ); }); - if (commands.length === 1) { + if (commandDescriptions.length === 1) { switchToCommand(0); } else { switchToDashboard(); @@ -1146,40 +1485,30 @@ const runCommands = (commandDescriptions) => { * @param {string} data * @param {Current} current * @param {Array} commands + * @param {Selection} selection * @param {() => void} switchToDashboard - * @param {(index: number) => void} switchToCommand + * @param {(index: number, options?: { hideSelection?: boolean }) => void} switchToCommand + * @param {(newSelection: Selection) => void} setSelection * @param {() => void} killAll - * @param {(command: Command) => void} printHistoryAndExtraText + * @param {() => void} restartExited * @returns {undefined} */ const onStdin = ( data, current, commands, + selection, switchToDashboard, switchToCommand, + setSelection, killAll, - printHistoryAndExtraText + restartExited ) => { switch (current.tag) { case "Command": { const command = commands[current.index]; switch (command.status.tag) { case "Running": - switch (data) { - case KEY_CODES.kill: - command.kill(); - return undefined; - - case KEY_CODES.dashboard: - switchToDashboard(); - return undefined; - - default: - command.status.terminal.write(data); - return undefined; - } - case "Killing": switch (data) { case KEY_CODES.kill: @@ -1191,6 +1520,11 @@ const onStdin = ( return undefined; default: + command.status = { + tag: "Running", + terminal: command.status.terminal, + }; + command.status.terminal.write(data); return undefined; } @@ -1206,7 +1540,7 @@ const onStdin = ( case KEY_CODES.restart: command.start(); - printHistoryAndExtraText(command); + switchToCommand(current.index); return undefined; default: @@ -1221,19 +1555,124 @@ const onStdin = ( killAll(); return undefined; + case KEY_CODES.enter: + case KEY_CODES.enterVim: + if (selection.tag === "Invisible") { + restartExited(); + } else { + switchToCommand(selection.index); + } + return undefined; + + case KEY_CODES.up: + case KEY_CODES.upVim: + setSelection({ + tag: "Keyboard", + index: + selection.tag === "Invisible" + ? selection.index + : selection.index === 0 + ? commands.length - 1 + : selection.index - 1, + }); + return undefined; + + case KEY_CODES.down: + case KEY_CODES.downVim: + setSelection({ + tag: "Keyboard", + index: + selection.tag === "Invisible" + ? selection.index + : selection.index === commands.length - 1 + ? 0 + : selection.index + 1, + }); + return undefined; + + case KEY_CODES.esc: + setSelection({ tag: "Invisible", index: selection.index }); + return undefined; + default: { const commandIndex = commands.findIndex( (command) => command.label === data ); if (commandIndex !== -1) { - switchToCommand(commandIndex); + switchToCommand(commandIndex, { hideSelection: true }); + return undefined; + } + + const mousePosition = parseMouse(data); + if (mousePosition === undefined) { + return undefined; + } + + const index = getCommandIndexFromMousePosition( + commands, + mousePosition + ); + + switch (mousePosition.type) { + case "mousedown": + if (index !== undefined) { + setSelection({ tag: "Mousedown", index }); + } + return undefined; + + case "mouseup": { + if (index !== undefined && index === selection.index) { + switchToCommand(index, { hideSelection: true }); + } else if (selection.tag !== "Invisible") { + setSelection({ tag: "Invisible", index: selection.index }); + } + return undefined; + } } - return undefined; } } } }; +const MOUSE_REGEX = /\x1B\[<0;(\d+);(\d+)([Mm])/; + +/** + * @param {string} string + * @returns {{ type: "mousedown" | "mouseup", x: number, y: number } | undefined} + */ +const parseMouse = (string) => { + const match = MOUSE_REGEX.exec(string); + if (match === null) { + return undefined; + } + const [, x, y, type] = match; + return { + type: type === "M" ? "mousedown" : "mouseup", + x: Number(x) - 1, + y: Number(y) - 1, + }; +}; + +/** + * @param {Array} commands + * @param {{ x: number, y: number }} mousePosition + */ +const getCommandIndexFromMousePosition = (commands, { x, y }) => { + const lines = drawDashboardCommandLines(commands, process.stdout.columns, { + tag: "Invisible", + index: 0, + }); + + if (y >= 0 && y < lines.length) { + const line = lines[y]; + if (x >= 0 && x < line.length) { + return y; + } + } + + return undefined; +}; + /** * @returns {undefined} */ diff --git a/test-clear.js b/test-clear.js new file mode 100644 index 0000000..cdf56f8 --- /dev/null +++ b/test-clear.js @@ -0,0 +1,65 @@ +"use strict"; + +const CURSOR_DOWN = "\x1B[B"; + +/** + * @template T + * @param {Array} items + * @returns {Array>} + */ +const permutations = (items) => + items.length <= 1 + ? [items] + : items.flatMap((first, index) => + permutations([ + ...items.slice(0, index), + ...items.slice(index + 1), + ]).map((rest) => [first, ...rest]) + ); + +const variants = [ + ...permutations(["\x1B[2J", "\x1B[3J", "\x1B[H"]).map((items) => + items.join("") + ), + ...permutations(["\x1B[2J", "\x1B[3J", "\x1B[0;0f"]).map((items) => + items.join("") + ), + ...permutations(["\x1B[2J", "\x1B[3J", "\x1B[1;1H"]).map((items) => + items.join("") + ), + () => { + process.stdout.write("\x1B[3J"); + console.clear(); + }, + () => { + console.clear(); + process.stdout.write("\x1B[3J"); + }, +]; + +const index = Number(process.argv[2]) || 0; + +process.stdout.write(CURSOR_DOWN); +console.log("Not a simple log"); + +setTimeout(() => { + const f = variants[index]; + + if (f === undefined) { + console.error("Out of bounds! Got:", index, "Max:", variants.length - 1); + process.exit(1); + } + + let string; + + if (typeof f === "string") { + process.stdout.write(f); + string = JSON.stringify(f); + } else { + f(); + string = f.toString(); + } + + console.log("Done!", string); + process.stdin.resume(); +}, 1000); diff --git a/test-line-movements.js b/test-line-movements.js new file mode 100644 index 0000000..237dbae --- /dev/null +++ b/test-line-movements.js @@ -0,0 +1,17 @@ +"use strict"; + +const CURSOR_FORWARD_3 = "\x1B[3C"; +const CURSOR_BACK = "\x1B[D"; +const CURSOR_AT_9 = "\x1B[9G"; +const CLEAR_LINE = "\x1B[2K"; + +process.stdout.write("First"); +process.stdout.write(CLEAR_LINE); +process.stdout.write("Trixxy xxsiness"); +process.stdout.write(CURSOR_AT_9); +process.stdout.write("ck"); +process.stdout.write(CURSOR_FORWARD_3); +process.stdout.write(CURSOR_BACK); +process.stdout.write("bu"); + +process.stdin.resume(); diff --git a/test-mouse.js b/test-mouse.js new file mode 100644 index 0000000..192db7d --- /dev/null +++ b/test-mouse.js @@ -0,0 +1,57 @@ +"use strict"; + +process.stdin.setRawMode(true); + +const ENABLE_MOUSE = "\x1B[?1000;1006h"; +const DISABLE_MOUSE = "\x1B[?1000;1006l"; + +console.log(process.pid); +process.stdout.write(ENABLE_MOUSE); +process.on("exit", () => { + process.stdout.write(DISABLE_MOUSE); +}); + +process.stdin.on("data", (buffer) => { + const data = buffer.toString(); + console.log(JSON.stringify(data), parse(data)); + if (data === "\x03") { + process.exit(); + } +}); + +const REGEX = /\x1B\[M([ #])(.+)/; +const REGEX_2 = /\x1B\[<0;(\d+);(\d+)([Mm])/; + +/** + * @param {string} data + * @returns {string} + */ +function parse(data) { + const match = REGEX.exec(data); + if (match === null) { + return parse2(data); + } + + const [, upDownRaw, chars] = match; + const upDown = upDownRaw === "#" ? "up" : "down"; + const nums = chars + .split("") + .map((c) => c.charCodeAt(0) - 32) + .join(", "); + return `${upDown}: ${nums}`; +} + +/** + * @param {string} data + * @returns {string} + */ +function parse2(data) { + const match = REGEX_2.exec(data); + if (match === null) { + return "(parse error)"; + } + + const [, x, y, upDownRaw] = match; + const upDown = upDownRaw === "m" ? "up" : "down"; + return `${upDown}: ${x},${y}`; +} diff --git a/test/run-pty.test.js b/test/run-pty.test.js index 17defff..a970b54 100644 --- a/test/run-pty.test.js +++ b/test/run-pty.test.js @@ -22,12 +22,10 @@ const { * @returns {string} */ function replaceAnsi(string) { - /* eslint-disable no-control-regex */ return string .replace(/\x1B\[0?m/g, "⧘") .replace(/\x1B\[\d+m/g, "⧙") .replace(/\x1B\[\d*[GK]/g, ""); - /* eslint-enable no-control-regex */ } /** @@ -55,11 +53,6 @@ describe("help", () => { Show output for one command at a time. Kill all at once. - ⧙[⧘⧙1-9/a-z/A-Z⧘⧙]⧘ focus command - ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill focused/all - ⧙[⧘⧙enter⧘⧙]⧘ restart killed/exited command - Separate the commands with a character of choice: ⧙run-pty⧘ ⧙%⧘ npm start ⧙%⧘ make watch ⧙%⧘ some_command arg1 arg2 arg3 @@ -73,6 +66,12 @@ describe("help", () => { ⧙run-pty⧘ run-pty.json + Keyboard shortcuts: + + ⧙[⧘⧙ctrl+z⧘⧙]⧘ Dashboard + ⧙[⧘⧙ctrl+c⧘⧙]⧘ Kill all or focused command + Other keyboard shortcuts are shown as needed. + Environment variables: ⧙RUN_PTY_MAX_HISTORY⧘ @@ -102,31 +101,39 @@ describe("dashboard", () => { function testDashboard(items, width) { return replaceAnsi( drawDashboard( - items.map((item, index) => ({ - label: ALL_LABELS[index] || "", - title: + items.map((item, index) => { + const title = item.title === undefined ? commandToPresentationName(item.command) - : item.title, - formattedCommandWithTitle: commandToPresentationName(item.command), - status: item.status, - // Unused in this case: - file: "file", - args: [], - cwd: ".", - history: "", - statusFromRules: item.statusFromRules, - defaultStatus: undefined, - statusRules: [], - onData: () => notCalled("onData"), - onExit: () => notCalled("onExit"), - pushHistory: () => notCalled("pushHistory"), - start: () => notCalled("start"), - kill: () => notCalled("kill"), - updateStatusFromRules: () => notCalled("updateStatusFromRules"), - })), + : item.title; + return { + label: ALL_LABELS[index] || "", + title, + titleWithGraphicRenditions: title, + formattedCommandWithTitle: commandToPresentationName(item.command), + status: item.status, + // Unused in this case: + file: "file", + args: [], + cwd: ".", + history: "", + historyAlternateScreen: "", + isSimpleLog: true, + isOnAlternateScreen: false, + statusFromRules: item.statusFromRules, + defaultStatus: undefined, + statusRules: [], + onData: () => notCalled("onData"), + onExit: () => notCalled("onExit"), + pushHistory: () => notCalled("pushHistory"), + start: () => notCalled("start"), + kill: () => notCalled("kill"), + updateStatusFromRules: () => notCalled("updateStatusFromRules"), + }; + }), width, - false + false, + { tag: "Invisible", index: 0 } ) ); } @@ -147,6 +154,8 @@ describe("dashboard", () => { onData: () => notCalled("onData") || { dispose: () => undefined }, onExit: () => notCalled("onExit") || { dispose: () => undefined }, on: () => notCalled("on"), + pause: () => notCalled("pause"), + resume: () => notCalled("resume"), resize: () => notCalled("resize"), write: () => notCalled("write"), kill: () => notCalled("kill"), @@ -155,9 +164,9 @@ describe("dashboard", () => { test("empty", () => { expect(testDashboard([], 0)).toMatchInlineSnapshot(` - ⧙[⧘⧙⧘⧙]⧘ focus command - ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit␊ - + ⧙[⧘⧙⧘⧙]⧘ focus command ⧙(or click)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit + ⧙[⧘⧙↑/↓⧘⧙]⧘ move selection `); }); @@ -175,9 +184,10 @@ describe("dashboard", () => { ).toMatchInlineSnapshot(` ⧙[⧘⧙1⧘⧙]⧘ ⚪⧘ ⧙exit 0⧘ npm start⧘ - ⧙[⧘⧙1⧘⧙]⧘ focus command - ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit␊ - + ⧙[⧘⧙1⧘⧙]⧘ focus command ⧙(or click)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit + ⧙[⧘⧙↑/↓⧘⧙]⧘ move selection + ⧙[⧘⧙enter⧘⧙]⧘ restart exited `); }); @@ -198,6 +208,11 @@ describe("dashboard", () => { status: { tag: "Exit", exitCode: 0 }, statusFromRules: "!", // Should be ignored. }, + { + command: ["npm", "run", "server"], + status: { tag: "Exit", exitCode: 130 }, + statusFromRules: "!", // Should be ignored. + }, { command: ["ping", "nope"], status: { tag: "Exit", exitCode: 68 }, @@ -209,6 +224,7 @@ describe("dashboard", () => { tag: "Killing", terminal: fakeTerminal({ pid: 12345 }), slow: false, + lastKillPress: undefined, }, statusFromRules: "!", // Should be ignored. }, @@ -233,15 +249,17 @@ describe("dashboard", () => { 80 ) ).toMatchInlineSnapshot(` - ⧙[⧘⧙1⧘⧙]⧘ ⚪⧘ ⧙exit 0⧘ echo ./Some_script2.js -v '$end' '' \\'quoted\\''th|ng'\\' 'hell…⧘ - ⧙[⧘⧙2⧘⧙]⧘ 🔴⧘ ⧙exit 68⧘ ping nope⧘ - ⧙[⧘⧙3⧘⧙]⧘ ⭕⧘ ping localhost⧘ - ⧙[⧘⧙4⧘⧙]⧘ 🟢⧘ yes⧘ - ⧙[⧘⧙5⧘⧙]⧘ 🚨⧘ very long title for some reason that needs to be cut off at some point⧘ - - ⧙[⧘⧙1-5⧘⧙]⧘ focus command - ⧙[⧘⧙ctrl+c⧘⧙]⧘ force kill all␊ - + ⧙[⧘⧙1⧘⧙]⧘ ⚪⧘ ⧙exit 0⧘ echo ./Some_script2.js -v '$end' '' \\'quoted\\''th|ng'\\' 'hel…⧘ + ⧙[⧘⧙2⧘⧙]⧘ ⚪⧘ ⧙exit 130⧘ npm run server⧘ + ⧙[⧘⧙3⧘⧙]⧘ 🔴⧘ ⧙exit 68⧘ ping nope⧘ + ⧙[⧘⧙4⧘⧙]⧘ ⭕⧘ ping localhost⧘ + ⧙[⧘⧙5⧘⧙]⧘ 🟢⧘ yes⧘ + ⧙[⧘⧙6⧘⧙]⧘ 🚨⧘ very long title for some reason that needs to be cut off at some point⧘ + + ⧙[⧘⧙1-6⧘⧙]⧘ focus command ⧙(or click)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill all ⧙(double-press to force) ⧘ + ⧙[⧘⧙↑/↓⧘⧙]⧘ move selection + ⧙[⧘⧙enter⧘⧙]⧘ restart exited `); }); @@ -321,9 +339,9 @@ describe("dashboard", () => { ⧙[⧘⧙Z⧘⧙]⧘ 🟢⧘ echo 60⧘ ⧙[⧘⧙ ⧘⧙]⧘ 🟢⧘ echo 61⧘ - ⧙[⧘⧙1-9/a-z/A-Z⧘⧙]⧘ focus command - ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill all␊ - + ⧙[⧘⧙1-9/a-z/A-Z⧘⧙]⧘ focus command ⧙(or click)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill all + ⧙[⧘⧙↑/↓⧘⧙]⧘ move selection `); }); }); @@ -371,47 +389,36 @@ describe("focused command", () => { ␊ ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill ⧙(pid 12345)⧘ ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - - `); }); test("killing without cwd", () => { expect( render( - (command) => killingText(command, 12345), + () => killingText(12345), "frontend: npm start", "frontend", "./x/.." ) ).toMatchInlineSnapshot(` ␊ - ⭕ frontend: npm start⧘ - killing… - - ⧙[⧘⧙ctrl+c⧘⧙]⧘ force kill ⧙(pid 12345)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill ⧙(double-press to force) (pid 12345)⧘ ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - `); }); test("killing with cwd", () => { expect( render( - (command) => killingText(command, 12345), + () => killingText(12345), "frontend: npm start", "frontend", "web/frontend" ) ).toMatchInlineSnapshot(` ␊ - ⭕ frontend: npm start⧘ - 📂 ⧙web/frontend⧘ - killing… - - ⧙[⧘⧙ctrl+c⧘⧙]⧘ force kill ⧙(pid 12345)⧘ + ⧙[⧘⧙ctrl+c⧘⧙]⧘ kill ⧙(double-press to force) (pid 12345)⧘ ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - `); }); @@ -432,7 +439,6 @@ describe("focused command", () => { ⧙[⧘⧙enter⧘⧙]⧘ restart ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - `); }); @@ -452,7 +458,6 @@ describe("focused command", () => { ⧙[⧘⧙enter⧘⧙]⧘ restart ⧙[⧘⧙ctrl+c⧘⧙]⧘ exit ⧙[⧘⧙ctrl+z⧘⧙]⧘ dashboard - `); }); });