mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Replace XTerm.js with headless terminal implementation
- Remove XTerm.js dependencies (@xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links, asciinema-player) - Switch terminal component to use @xterm/headless for better performance and compatibility - Simplify build process by removing unused renderer and mobile-terminal components - Update package.json scripts to use asset bundling approach - Fix TypeScript imports and remove deprecated addons - Streamline terminal implementation for improved reliability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1cef390ed6
commit
d9d134ff2b
11 changed files with 301 additions and 1702 deletions
357
web/package-lock.json
generated
357
web/package-lock.json
generated
|
|
@ -9,11 +9,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
|
||||||
"@xterm/headless": "^5.5.0",
|
"@xterm/headless": "^5.5.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
|
||||||
"asciinema-player": "^3.7.0",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lit": "^3.1.0",
|
"lit": "^3.1.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
|
|
@ -30,6 +26,7 @@
|
||||||
"@typescript-eslint/parser": "^8.34.1",
|
"@typescript-eslint/parser": "^8.34.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
|
"chokidar-cli": "^3.0.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"esbuild": "^0.25.5",
|
"esbuild": "^0.25.5",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
|
|
@ -546,6 +543,7 @@
|
||||||
"version": "7.27.6",
|
"version": "7.27.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -2654,36 +2652,12 @@
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-fit": {
|
|
||||||
"version": "0.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
|
||||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-web-links": {
|
|
||||||
"version": "0.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
|
||||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/headless": {
|
"node_modules/@xterm/headless": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
||||||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
|
||||||
"version": "5.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
|
@ -2834,16 +2808,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/asciinema-player": {
|
|
||||||
"version": "3.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.10.0.tgz",
|
|
||||||
"integrity": "sha512-shoOK6F606nDKZxDVM7JuGSCAyWLePoGRFNlV+FqiP5Sqvyn0BlE7wlbjZyd2X4P1iRhv/HKfVNtnQIxmgphRA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.21.0",
|
|
||||||
"solid-js": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ast-types": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.13.4",
|
"version": "0.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||||
|
|
@ -3407,6 +3371,240 @@
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chokidar-cli": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.5.2",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"yargs": "^13.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"chokidar": "index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/ansi-regex": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/ansi-styles": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/cliui": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^3.1.0",
|
||||||
|
"strip-ansi": "^5.2.0",
|
||||||
|
"wrap-ansi": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/color-convert": {
|
||||||
|
"version": "1.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
|
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/color-name": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/emoji-regex": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/find-up": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/locate-path": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^3.0.0",
|
||||||
|
"path-exists": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/p-locate": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/path-exists": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/string-width": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^7.0.1",
|
||||||
|
"is-fullwidth-code-point": "^2.0.0",
|
||||||
|
"strip-ansi": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/strip-ansi": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/wrap-ansi": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^3.2.0",
|
||||||
|
"string-width": "^3.0.0",
|
||||||
|
"strip-ansi": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/yargs": {
|
||||||
|
"version": "13.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
|
||||||
|
"integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^5.0.0",
|
||||||
|
"find-up": "^3.0.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^3.0.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^13.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar-cli/node_modules/yargs-parser": {
|
||||||
|
"version": "13.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
|
||||||
|
"integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar/node_modules/glob-parent": {
|
"node_modules/chokidar/node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
|
@ -3865,12 +4063,6 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||||
|
|
@ -3907,6 +4099,16 @@
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
|
||||||
|
|
@ -6901,6 +7103,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.debounce": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
|
|
@ -6915,6 +7124,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.throttle": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-update": {
|
"node_modules/log-update": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||||
|
|
@ -8351,6 +8567,13 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
|
|
@ -8598,27 +8821,6 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/seroval": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/seroval-plugins": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"seroval": "^1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||||
|
|
@ -8634,6 +8836,13 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -8881,17 +9090,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/solid-js": {
|
|
||||||
"version": "1.9.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz",
|
|
||||||
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"csstype": "^3.1.0",
|
|
||||||
"seroval": "~1.3.0",
|
|
||||||
"seroval-plugins": "~1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|
@ -9863,6 +10061,13 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,19 @@
|
||||||
"description": "Web frontend for terminal multiplexer",
|
"description": "Web frontend for terminal multiplexer",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run bundle:watch\" \"npm run watch:server\"",
|
"dev": "npm run bundle:assets && npm run bundle:css && concurrently --kill-others-on-fail \"npm run watch:css\" \"npm run watch:assets\" \"npm run bundle:client -- --watch\" \"npm run bundle:test -- --watch\" \"npm run watch:server\"",
|
||||||
"watch:server": "tsx watch src/server.ts",
|
"watch:server": "tsx watch src/server.ts",
|
||||||
"watch:client": "tsc -p tsconfig.client.json --watch --preserveWatchOutput",
|
|
||||||
"watch:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --watch",
|
"watch:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --watch",
|
||||||
"build": "npm run build:css && npm run build:client && npm run build:server",
|
"watch:assets": "chokidar 'src/client/assets/**/*' -c 'npm run bundle:assets'",
|
||||||
"build:server": "tsc",
|
"clean": "rm -rf public/* && rm -rf dist/",
|
||||||
"build:client": "tsc -p tsconfig.client.json",
|
"build": "npm run bundle",
|
||||||
"build:css": "npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
"typecheck": "tsc --noEmit",
|
||||||
"bundle": "npm run bundle:client && npm run bundle:renderer && npm run bundle:test",
|
"bundle": "npm run clean && npm run bundle:assets && npm run bundle:css && npm run bundle:client && npm run bundle:test",
|
||||||
"bundle:client": "esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap --external:@xterm/xterm/css/xterm.css",
|
"bundle:assets": "mkdir -p public && cp -r src/client/assets/* public/",
|
||||||
"bundle:renderer": "esbuild src/client/renderer-entry.ts --bundle --outfile=public/bundle/renderer.js --format=esm --sourcemap",
|
"bundle:css": "mkdir -p public/bundle && npx tailwindcss -i ./src/input.css -o ./public/bundle/output.css --minify",
|
||||||
"bundle:test": "esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
|
"bundle:client": "mkdir -p public/bundle && esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap",
|
||||||
"bundle:watch": "concurrently \"npm run bundle:client -- --watch\" \"npm run bundle:renderer -- --watch\" \"npm run bundle:test -- --watch\"",
|
"bundle:test": "mkdir -p public/bundle && esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "jest",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:mobile-terminal": "jest src/client/components/__tests__/mobile-terminal.test.ts",
|
|
||||||
"test:components": "jest src/client/components",
|
|
||||||
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
"lint": "eslint 'src/**/*.{ts,tsx}'",
|
||||||
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
||||||
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
||||||
|
|
@ -29,11 +24,7 @@
|
||||||
"pre-commit": "./scripts/pre-commit-check.sh"
|
"pre-commit": "./scripts/pre-commit-check.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
|
||||||
"@xterm/headless": "^5.5.0",
|
"@xterm/headless": "^5.5.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
|
||||||
"asciinema-player": "^3.7.0",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lit": "^3.1.0",
|
"lit": "^3.1.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
|
|
@ -50,6 +41,7 @@
|
||||||
"@typescript-eslint/parser": "^8.34.1",
|
"@typescript-eslint/parser": "^8.34.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
|
"chokidar-cli": "^3.0.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"esbuild": "^0.25.5",
|
"esbuild": "^0.25.5",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import './components/session-view.js';
|
||||||
import './components/session-card.js';
|
import './components/session-card.js';
|
||||||
|
|
||||||
import type { Session } from './components/session-list.js';
|
import type { Session } from './components/session-list.js';
|
||||||
|
import type { SessionCard } from './components/session-card.js';
|
||||||
|
|
||||||
@customElement('vibetunnel-app')
|
@customElement('vibetunnel-app')
|
||||||
export class VibeTunnelApp extends LitElement {
|
export class VibeTunnelApp extends LitElement {
|
||||||
|
|
@ -180,9 +181,9 @@ export class VibeTunnelApp extends LitElement {
|
||||||
|
|
||||||
private async handleKillAll() {
|
private async handleKillAll() {
|
||||||
// Find all session cards and trigger their kill buttons
|
// Find all session cards and trigger their kill buttons
|
||||||
const sessionCards = this.querySelectorAll('session-card');
|
const sessionCards = this.querySelectorAll<SessionCard>('session-card');
|
||||||
|
|
||||||
sessionCards.forEach((card: Element) => {
|
sessionCards.forEach((card: SessionCard) => {
|
||||||
// Check if this session is running
|
// Check if this session is running
|
||||||
if (card.session && card.session.status === 'running') {
|
if (card.session && card.session.status === 'running') {
|
||||||
// Find all buttons within this card and look for the kill button
|
// Find all buttons within this card and look for the kill button
|
||||||
|
|
|
||||||
|
|
@ -1,815 +0,0 @@
|
||||||
import { LitElement, html, nothing } from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
import { Terminal } from '@xterm/xterm';
|
|
||||||
import { ScaleFitAddon } from '../scale-fit-addon.js';
|
|
||||||
|
|
||||||
// Simplified - only fit-both mode
|
|
||||||
|
|
||||||
@customElement('responsive-terminal')
|
|
||||||
export class ResponsiveTerminal extends LitElement {
|
|
||||||
// Disable shadow DOM for Tailwind compatibility
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property({ type: String }) sessionId = '';
|
|
||||||
@property({ type: Number }) cols = 80;
|
|
||||||
@property({ type: Number }) rows = 24;
|
|
||||||
@property({ type: Number }) fontSize = 14;
|
|
||||||
// Removed fitMode - always fit-both
|
|
||||||
@property({ type: Boolean }) showControls = false;
|
|
||||||
@property({ type: Boolean }) enableInput = false;
|
|
||||||
@property({ type: String }) containerClass = '';
|
|
||||||
|
|
||||||
@state() private terminal: Terminal | null = null;
|
|
||||||
@state() private scaleFitAddon: ScaleFitAddon | null = null;
|
|
||||||
@state() private isMobile = false;
|
|
||||||
@state() private touches = new Map<number, Touch>();
|
|
||||||
@state() private currentTerminalSize = { cols: 80, rows: 24 };
|
|
||||||
@state() private actualLineHeight = 16;
|
|
||||||
@state() private touchCount = 0;
|
|
||||||
@state() private terminalStatus = 'Initializing...';
|
|
||||||
|
|
||||||
// Inertial scrolling state
|
|
||||||
private velocity = 0;
|
|
||||||
private lastTouchTime = 0;
|
|
||||||
private momentumAnimationId: number | null = null;
|
|
||||||
|
|
||||||
// Long press detection for text selection
|
|
||||||
private longPressTimer: number | null = null;
|
|
||||||
private longPressThreshold = 500; // ms
|
|
||||||
private isInSelectionMode = false;
|
|
||||||
|
|
||||||
private container: HTMLElement | null = null;
|
|
||||||
private wrapper: HTMLElement | null = null;
|
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
|
||||||
private boundCopyHandler: ((e: ClipboardEvent) => void) | null = null;
|
|
||||||
private resizeTimeout: number | null = null;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.detectMobile();
|
|
||||||
this.currentTerminalSize = { cols: this.cols, rows: this.rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this.cleanup();
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
private detectMobile() {
|
|
||||||
this.isMobile = window.innerWidth <= 768 || 'ontouchstart' in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanup() {
|
|
||||||
if (this.resizeObserver) {
|
|
||||||
this.resizeObserver.disconnect();
|
|
||||||
this.resizeObserver = null;
|
|
||||||
}
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.dispose();
|
|
||||||
this.terminal = null;
|
|
||||||
}
|
|
||||||
// Remove copy event listener
|
|
||||||
if (this.boundCopyHandler) {
|
|
||||||
document.removeEventListener('copy', this.boundCopyHandler);
|
|
||||||
this.boundCopyHandler = null;
|
|
||||||
}
|
|
||||||
// Stop momentum animation
|
|
||||||
if (this.momentumAnimationId) {
|
|
||||||
cancelAnimationFrame(this.momentumAnimationId);
|
|
||||||
this.momentumAnimationId = null;
|
|
||||||
}
|
|
||||||
// Clear long press timer
|
|
||||||
if (this.longPressTimer) {
|
|
||||||
clearTimeout(this.longPressTimer);
|
|
||||||
this.longPressTimer = null;
|
|
||||||
}
|
|
||||||
this.scaleFitAddon = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
updated(changedProperties: Map<string, unknown>) {
|
|
||||||
if (changedProperties.has('cols') || changedProperties.has('rows')) {
|
|
||||||
this.currentTerminalSize = { cols: this.cols, rows: this.rows };
|
|
||||||
if (this.terminal) {
|
|
||||||
this.reinitializeTerminal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
firstUpdated() {
|
|
||||||
this.initializeTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeTerminal() {
|
|
||||||
try {
|
|
||||||
this.terminalStatus = 'Initializing...';
|
|
||||||
this.requestUpdate();
|
|
||||||
|
|
||||||
this.container = this.querySelector('#terminal-container') as HTMLElement;
|
|
||||||
this.wrapper = this.querySelector('#terminal-wrapper') as HTMLElement;
|
|
||||||
|
|
||||||
if (!this.container || !this.wrapper) {
|
|
||||||
throw new Error('Terminal container or wrapper not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.setupTerminal();
|
|
||||||
this.setupTouchHandling();
|
|
||||||
this.setupResize();
|
|
||||||
this.generateMockData();
|
|
||||||
|
|
||||||
this.terminalStatus = 'Ready';
|
|
||||||
this.requestUpdate();
|
|
||||||
} catch (error) {
|
|
||||||
this.terminalStatus = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reinitializeTerminal() {
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.clear();
|
|
||||||
this.fitTerminal();
|
|
||||||
this.generateMockData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupTerminal() {
|
|
||||||
// EXACT terminal config from working file
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
cursorBlink: false,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'Consolas, "Liberation Mono", monospace',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
scrollback: 10000,
|
|
||||||
// Disable built-in link handling completely
|
|
||||||
linkHandler: null,
|
|
||||||
windowOptions: {},
|
|
||||||
theme: {
|
|
||||||
background: '#1e1e1e',
|
|
||||||
foreground: '#d4d4d4',
|
|
||||||
cursor: '#00ff00',
|
|
||||||
black: '#000000',
|
|
||||||
red: '#f14c4c',
|
|
||||||
green: '#23d18b',
|
|
||||||
yellow: '#f5f543',
|
|
||||||
blue: '#3b8eea',
|
|
||||||
magenta: '#d670d6',
|
|
||||||
cyan: '#29b8db',
|
|
||||||
white: '#e5e5e5',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open terminal first
|
|
||||||
if (this.wrapper) {
|
|
||||||
this.terminal.open(this.wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always disable default terminal handlers first
|
|
||||||
this.disableDefaultTerminalHandlers();
|
|
||||||
|
|
||||||
// Fit terminal to container
|
|
||||||
this.fitTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
private disableDefaultTerminalHandlers() {
|
|
||||||
if (!this.terminal || !this.wrapper) return;
|
|
||||||
|
|
||||||
// Back to aggressive approach - disable all XTerm touch handling so we can control everything
|
|
||||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
|
||||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
|
||||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
|
||||||
const textareaEl = this.wrapper.querySelector('.xterm-helper-textarea');
|
|
||||||
|
|
||||||
if (terminalEl) {
|
|
||||||
// Disable all default behaviors on terminal elements
|
|
||||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
|
||||||
if (el) {
|
|
||||||
el.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
|
|
||||||
el.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
|
||||||
el.addEventListener('touchend', (e) => e.preventDefault(), { passive: false });
|
|
||||||
el.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
|
||||||
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable the hidden textarea that XTerm uses for input
|
|
||||||
if (textareaEl) {
|
|
||||||
textareaEl.disabled = true;
|
|
||||||
textareaEl.readOnly = true;
|
|
||||||
textareaEl.style.pointerEvents = 'none';
|
|
||||||
textareaEl.addEventListener('focus', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
textareaEl.blur();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupTouchHandling() {
|
|
||||||
if (!this.wrapper) return;
|
|
||||||
|
|
||||||
// ONLY attach touch handlers to the terminal wrapper (the XTerm content area)
|
|
||||||
// This way buttons and other elements outside aren't affected
|
|
||||||
this.wrapper.addEventListener('touchstart', this.handleTouchStart.bind(this), {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
this.wrapper.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
|
||||||
this.wrapper.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
|
|
||||||
this.wrapper.addEventListener('touchcancel', this.handleTouchEnd.bind(this), {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent context menu ONLY on terminal wrapper
|
|
||||||
this.wrapper.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
||||||
|
|
||||||
// Mouse wheel ONLY on terminal wrapper
|
|
||||||
this.wrapper.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy support for desktop
|
|
||||||
this.boundCopyHandler = this.handleCopy.bind(this);
|
|
||||||
document.addEventListener('copy', this.boundCopyHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupResize() {
|
|
||||||
if (!this.container) return;
|
|
||||||
|
|
||||||
// Use debounced ResizeObserver to avoid infinite resize loops
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
|
||||||
// Debounce to avoid resize loops
|
|
||||||
clearTimeout(this.resizeTimeout);
|
|
||||||
this.resizeTimeout = setTimeout(() => {
|
|
||||||
this.fitTerminal();
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
this.resizeObserver.observe(this.container);
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
this.detectMobile();
|
|
||||||
this.fitTerminal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private fitTerminal() {
|
|
||||||
if (!this.terminal || !this.container) return;
|
|
||||||
|
|
||||||
// Use the actual rendered dimensions of the component itself
|
|
||||||
const hostElement = this as HTMLElement;
|
|
||||||
const containerWidth = hostElement.clientWidth;
|
|
||||||
const containerHeight = hostElement.clientHeight;
|
|
||||||
|
|
||||||
// EXACT copy of working fitTerminal() method from mobile-terminal-test-fixed.html
|
|
||||||
|
|
||||||
// Resize to target dimensions first
|
|
||||||
this.terminal.resize(this.currentTerminalSize.cols, this.currentTerminalSize.rows);
|
|
||||||
|
|
||||||
// Calculate font size to fit the target columns exactly in container width
|
|
||||||
const charWidthRatio = 0.6; // More conservative estimate
|
|
||||||
const calculatedFontSize = containerWidth / (this.currentTerminalSize.cols * charWidthRatio);
|
|
||||||
const fontSize = Math.max(1, calculatedFontSize); // Allow very small fonts
|
|
||||||
|
|
||||||
// Apply font size
|
|
||||||
this.terminal.options.fontSize = fontSize;
|
|
||||||
if (this.terminal.element) {
|
|
||||||
this.terminal.element.style.fontSize = `${fontSize}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate line height and rows
|
|
||||||
const lineHeight = fontSize * 1.2;
|
|
||||||
this.actualLineHeight = lineHeight;
|
|
||||||
const rows = Math.max(1, Math.floor(containerHeight / lineHeight));
|
|
||||||
|
|
||||||
this.terminal.resize(this.currentTerminalSize.cols, rows);
|
|
||||||
|
|
||||||
// Force a refresh to apply the new sizing
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
|
||||||
|
|
||||||
// After rendering, check if we need to adjust row count
|
|
||||||
setTimeout(() => {
|
|
||||||
const xtermRows = this.terminal.element?.querySelector('.xterm-rows');
|
|
||||||
const firstRow = xtermRows?.children[0];
|
|
||||||
if (firstRow && xtermRows) {
|
|
||||||
// Use the REAL CSS line-height, not offsetHeight
|
|
||||||
const realLineHeight =
|
|
||||||
parseFloat(getComputedStyle(firstRow).lineHeight) || firstRow.offsetHeight;
|
|
||||||
const rowsThatFit = Math.floor(containerHeight / realLineHeight);
|
|
||||||
|
|
||||||
if (rowsThatFit !== this.terminal.rows) {
|
|
||||||
this.actualLineHeight = realLineHeight;
|
|
||||||
this.terminal.resize(this.currentTerminalSize.cols, rowsThatFit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removed - not needed in simplified version
|
|
||||||
|
|
||||||
private handleTouchStart(e: TouchEvent) {
|
|
||||||
// If in selection mode, let browser handle it
|
|
||||||
if (this.isInSelectionMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Stop any ongoing momentum scrolling
|
|
||||||
if (this.momentumAnimationId) {
|
|
||||||
cancelAnimationFrame(this.momentumAnimationId);
|
|
||||||
this.momentumAnimationId = null;
|
|
||||||
}
|
|
||||||
this.velocity = 0;
|
|
||||||
this.lastTouchTime = Date.now();
|
|
||||||
|
|
||||||
// Clear any existing long press timer
|
|
||||||
if (this.longPressTimer) {
|
|
||||||
clearTimeout(this.longPressTimer);
|
|
||||||
this.longPressTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const touch = e.changedTouches[i];
|
|
||||||
this.touches.set(touch.identifier, {
|
|
||||||
x: touch.clientX,
|
|
||||||
y: touch.clientY,
|
|
||||||
startX: touch.clientX,
|
|
||||||
startY: touch.clientY,
|
|
||||||
startTime: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start long press detection for single finger
|
|
||||||
if (this.touches.size === 1 && this.isMobile) {
|
|
||||||
this.longPressTimer = setTimeout(() => {
|
|
||||||
this.startTextSelection();
|
|
||||||
}, this.longPressThreshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.touchCount = this.touches.size;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchMove(e: TouchEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// If we moved, cancel long press detection (unless already in selection mode)
|
|
||||||
if (this.longPressTimer && !this.isInSelectionMode) {
|
|
||||||
const touch = Array.from(this.touches.values())[0];
|
|
||||||
if (touch) {
|
|
||||||
const deltaX = Math.abs(e.changedTouches[0].clientX - touch.startX);
|
|
||||||
const deltaY = Math.abs(e.changedTouches[0].clientY - touch.startY);
|
|
||||||
|
|
||||||
// Cancel long press if significant movement (more than 5px)
|
|
||||||
if (deltaX > 5 || deltaY > 5) {
|
|
||||||
clearTimeout(this.longPressTimer);
|
|
||||||
this.longPressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const touch = e.changedTouches[i];
|
|
||||||
const stored = this.touches.get(touch.identifier);
|
|
||||||
if (stored) {
|
|
||||||
stored.x = touch.clientX;
|
|
||||||
stored.y = touch.clientY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only handle scrolling if we're NOT in selection mode
|
|
||||||
if (this.touches.size === 1 && !this.isInSelectionMode) {
|
|
||||||
this.handleSingleTouch();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchEnd(e: TouchEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Clear long press timer if still running
|
|
||||||
if (this.longPressTimer) {
|
|
||||||
clearTimeout(this.longPressTimer);
|
|
||||||
this.longPressTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
|
||||||
const touch = e.changedTouches[i];
|
|
||||||
this.touches.delete(touch.identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start inertial scrolling if we had velocity on mobile and NOT in selection mode
|
|
||||||
if (
|
|
||||||
this.touches.size === 0 &&
|
|
||||||
this.isMobile &&
|
|
||||||
Math.abs(this.velocity) > 0.5 &&
|
|
||||||
!this.isInSelectionMode
|
|
||||||
) {
|
|
||||||
this.startMomentumScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.touchCount = this.touches.size;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSingleTouch() {
|
|
||||||
const touch = Array.from(this.touches.values())[0];
|
|
||||||
const deltaY = touch.y - touch.startY;
|
|
||||||
const currentTime = Date.now();
|
|
||||||
|
|
||||||
// Only handle vertical scroll
|
|
||||||
if (Math.abs(deltaY) > 2) {
|
|
||||||
// Calculate velocity for inertial scrolling
|
|
||||||
const timeDelta = currentTime - this.lastTouchTime;
|
|
||||||
if (timeDelta > 0) {
|
|
||||||
this.velocity = deltaY / timeDelta; // pixels per millisecond
|
|
||||||
}
|
|
||||||
this.lastTouchTime = currentTime;
|
|
||||||
|
|
||||||
this.handleScroll(deltaY);
|
|
||||||
touch.startY = touch.y; // Update immediately for smooth tracking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleScroll(deltaY: number) {
|
|
||||||
if (!this.terminal) return;
|
|
||||||
|
|
||||||
// Simple, direct scroll calculation like the working version
|
|
||||||
const scrollLines = Math.round(deltaY / this.actualLineHeight);
|
|
||||||
|
|
||||||
if (scrollLines !== 0) {
|
|
||||||
this.terminal.scrollLines(-scrollLines); // Negative for natural scroll direction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startMomentumScroll() {
|
|
||||||
// Stop any existing momentum animation
|
|
||||||
if (this.momentumAnimationId) {
|
|
||||||
cancelAnimationFrame(this.momentumAnimationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start momentum animation
|
|
||||||
const animate = () => {
|
|
||||||
if (!this.terminal || Math.abs(this.velocity) < 0.01) {
|
|
||||||
this.momentumAnimationId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply momentum scroll
|
|
||||||
const scrollDelta = this.velocity * 16; // 16ms frame time
|
|
||||||
this.handleScroll(scrollDelta);
|
|
||||||
|
|
||||||
// Apply friction to slow down
|
|
||||||
this.velocity *= 0.95; // Friction coefficient
|
|
||||||
|
|
||||||
// Continue animation
|
|
||||||
this.momentumAnimationId = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.momentumAnimationId = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startTextSelection() {
|
|
||||||
if (!this.terminal || !this.wrapper) return;
|
|
||||||
|
|
||||||
// Provide haptic feedback
|
|
||||||
if (navigator.vibrate) {
|
|
||||||
navigator.vibrate(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = Array.from(this.touches.values())[0];
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
// Enable browser selection for both desktop and mobile
|
|
||||||
this.isInSelectionMode = true;
|
|
||||||
this.wrapper.classList.add('selection-mode');
|
|
||||||
|
|
||||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
|
||||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
|
||||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
|
||||||
|
|
||||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
|
||||||
if (el) {
|
|
||||||
const element = el as HTMLElement;
|
|
||||||
element.style.pointerEvents = 'auto';
|
|
||||||
element.style.webkitUserSelect = 'text';
|
|
||||||
element.style.userSelect = 'text';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Text selection mode enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
private exitTextSelection() {
|
|
||||||
this.isInSelectionMode = false;
|
|
||||||
|
|
||||||
// Clean up browser selection
|
|
||||||
if (this.wrapper) {
|
|
||||||
this.wrapper.classList.remove('selection-mode');
|
|
||||||
|
|
||||||
const terminalEl = this.wrapper.querySelector('.xterm');
|
|
||||||
const screenEl = this.wrapper.querySelector('.xterm-screen');
|
|
||||||
const rowsEl = this.wrapper.querySelector('.xterm-rows');
|
|
||||||
|
|
||||||
[terminalEl, screenEl, rowsEl].forEach((el) => {
|
|
||||||
if (el) {
|
|
||||||
const element = el as HTMLElement;
|
|
||||||
element.style.pointerEvents = '';
|
|
||||||
element.style.webkitUserSelect = '';
|
|
||||||
element.style.userSelect = '';
|
|
||||||
element.style.webkitTouchCallout = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const selection = window.getSelection();
|
|
||||||
selection?.removeAllRanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Text selection mode deactivated');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleWheel(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const wheelEvent = e as WheelEvent;
|
|
||||||
|
|
||||||
if (this.terminal) {
|
|
||||||
// EXACT same logic as working version
|
|
||||||
let scrollLines = 0;
|
|
||||||
|
|
||||||
if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
|
||||||
// deltaY is already in lines
|
|
||||||
scrollLines = Math.round(wheelEvent.deltaY);
|
|
||||||
} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
|
||||||
// deltaY is in pixels, convert to lines
|
|
||||||
scrollLines = Math.round(wheelEvent.deltaY / this.actualLineHeight);
|
|
||||||
} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
|
||||||
// deltaY is in pages, convert to lines
|
|
||||||
scrollLines = Math.round(wheelEvent.deltaY * this.terminal.rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollLines !== 0) {
|
|
||||||
this.terminal.scrollLines(scrollLines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCopy(e: ClipboardEvent) {
|
|
||||||
if (!this.terminal) return;
|
|
||||||
|
|
||||||
// Get selected text from XTerm regardless of focus
|
|
||||||
const selection = this.terminal.getSelection();
|
|
||||||
|
|
||||||
if (selection && selection.trim()) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.clipboardData?.setData('text/plain', selection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSizeChange(cols: number, rows: number) {
|
|
||||||
// Old method that regenerates content - keeping for backward compatibility
|
|
||||||
this.cols = cols;
|
|
||||||
this.rows = rows;
|
|
||||||
this.currentTerminalSize = { cols, rows };
|
|
||||||
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.clear();
|
|
||||||
this.fitTerminal();
|
|
||||||
this.generateMockData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New method for viewport size changes without content regeneration
|
|
||||||
public setViewportSize(cols: number, rows: number) {
|
|
||||||
this.cols = cols;
|
|
||||||
this.rows = rows;
|
|
||||||
this.currentTerminalSize = { cols, rows };
|
|
||||||
|
|
||||||
if (this.terminal) {
|
|
||||||
// Just resize the viewport - XTerm will reflow the existing content
|
|
||||||
this.fitTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requestUpdate(); // Update the UI to show new size in status
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removed fitMode handling - only fit-both mode now
|
|
||||||
|
|
||||||
private generateMockData() {
|
|
||||||
if (!this.terminal) return;
|
|
||||||
|
|
||||||
// Always generate content for 120x40, regardless of current viewport size
|
|
||||||
// This way we can see XTerm reflow the same content for different viewport sizes
|
|
||||||
const contentCols = 120;
|
|
||||||
const contentRows = 40;
|
|
||||||
const numPages = 10;
|
|
||||||
|
|
||||||
let lineNumber = 1;
|
|
||||||
|
|
||||||
for (let page = 1; page <= numPages; page++) {
|
|
||||||
// Page header with special characters and highlighting
|
|
||||||
const headerLine = '\x1b[43m◄\x1b[0m' + '='.repeat(contentCols - 2) + '\x1b[43m►\x1b[0m';
|
|
||||||
this.terminal.writeln(headerLine);
|
|
||||||
lineNumber++;
|
|
||||||
|
|
||||||
// Fill the page with numbered lines (rows - 2 for header/footer only)
|
|
||||||
const contentLines = contentRows - 2;
|
|
||||||
for (let line = 1; line <= contentLines; line++) {
|
|
||||||
this.terminal.writeln(
|
|
||||||
`Line ${lineNumber.toString().padStart(4, '0')}: Content originally sized for ${contentCols}x${contentRows} terminal - watch it reflow!`
|
|
||||||
);
|
|
||||||
lineNumber++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page footer with special characters and highlighting
|
|
||||||
const footerLine = '\x1b[43m◄\x1b[0m' + '='.repeat(contentCols - 2) + '\x1b[43m►\x1b[0m';
|
|
||||||
this.terminal.writeln(footerLine);
|
|
||||||
lineNumber++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.writeln('\x1b[1;31m>>> END OF ALL CONTENT - THIS IS THE BOTTOM <<<\x1b[0m');
|
|
||||||
this.terminal.writeln(
|
|
||||||
'\x1b[1;33mIf you can see this, you reached the end. Scroll up to see all pages.\x1b[0m'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure we can scroll to the bottom
|
|
||||||
this.terminal.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API methods
|
|
||||||
public write(data: string) {
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.write(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public clear() {
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTerminal(): Terminal | null {
|
|
||||||
return this.terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
// EXACT aggressive CSS constraints from working file (keep as CSS for XTerm)
|
|
||||||
const aggressiveXTermStyles = html`
|
|
||||||
<style>
|
|
||||||
/* Hide XTerm scrollbar */
|
|
||||||
.xterm-viewport {
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure XTerm fills container */
|
|
||||||
.xterm {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable text selection completely */
|
|
||||||
* {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When in selection mode, allow text selection */
|
|
||||||
.selection-mode * {
|
|
||||||
-webkit-user-select: text !important;
|
|
||||||
-moz-user-select: text !important;
|
|
||||||
-ms-user-select: text !important;
|
|
||||||
user-select: text !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure XTerm selection styling works */
|
|
||||||
.selection-mode .xterm-selection {
|
|
||||||
position: absolute !important;
|
|
||||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In selection mode, allow pointer events on XTerm elements */
|
|
||||||
.selection-mode .xterm-screen,
|
|
||||||
.selection-mode .xterm-rows,
|
|
||||||
.selection-mode .xterm-row,
|
|
||||||
.selection-mode .xterm span {
|
|
||||||
pointer-events: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure all XTerm elements don't interfere with touch by default */
|
|
||||||
.xterm-screen,
|
|
||||||
.xterm-rows,
|
|
||||||
.xterm-row,
|
|
||||||
.xterm span {
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Aggressively force XTerm to stay within container bounds */
|
|
||||||
.xterm {
|
|
||||||
position: relative !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-screen {
|
|
||||||
position: relative !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-viewport {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-rows {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
${aggressiveXTermStyles}
|
|
||||||
<div id="terminal-container" class="relative w-full h-full bg-gray-900">
|
|
||||||
${this.showControls ? this.renderControls() : nothing} ${this.renderStatus()}
|
|
||||||
<div id="terminal-wrapper" class="w-full h-full touch-none"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderControls() {
|
|
||||||
const sizeOptions = [
|
|
||||||
{ cols: 60, rows: 15, label: '60x15' },
|
|
||||||
{ cols: 80, rows: 20, label: '80x20' },
|
|
||||||
{ cols: 120, rows: 40, label: '120x40' },
|
|
||||||
{ cols: 160, rows: 50, label: '160x50' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use position: fixed like working version so controls don't affect layout
|
|
||||||
return html`
|
|
||||||
<div class="fixed top-2 left-2 z-50 flex gap-1 flex-wrap max-w-xs">
|
|
||||||
${sizeOptions.map(
|
|
||||||
(size) => html`
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs font-mono bg-black/80 text-white border border-gray-600 rounded hover:bg-gray-800 cursor-pointer
|
|
||||||
${this.cols === size.cols && this.rows === size.rows
|
|
||||||
? 'bg-blue-600 border-blue-400'
|
|
||||||
: ''}"
|
|
||||||
@click=${() => this.handleSizeChange(size.cols, size.rows)}
|
|
||||||
>
|
|
||||||
${size.label}
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderStatus() {
|
|
||||||
// Position relative to the component, not the viewport
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="absolute top-2 left-2 bg-black/80 text-white p-2 rounded text-xs z-40 pointer-events-none font-mono"
|
|
||||||
>
|
|
||||||
<div>Size: ${this.currentTerminalSize.cols}x${this.currentTerminalSize.rows}</div>
|
|
||||||
<div>Font: ${this.terminal?.options.fontSize?.toFixed(1) || 14}px</div>
|
|
||||||
<div>Touch: ${this.touchCount}</div>
|
|
||||||
<div>Status: ${this.terminalStatus}</div>
|
|
||||||
<div>Device: ${this.isMobile ? 'Mobile' : 'Desktop'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'responsive-terminal': ResponsiveTerminal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/xterm';
|
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
|
||||||
import { UrlHighlighter } from '../utils/url-highlighter.js';
|
import { UrlHighlighter } from '../utils/url-highlighter.js';
|
||||||
|
|
||||||
@customElement('vibe-terminal')
|
@customElement('vibe-terminal')
|
||||||
|
|
@ -177,10 +177,9 @@ export class Terminal extends LitElement {
|
||||||
// Create regular terminal but don't call .open() to make it headless
|
// Create regular terminal but don't call .open() to make it headless
|
||||||
this.terminal = new XtermTerminal({
|
this.terminal = new XtermTerminal({
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
fontSize: this.fontSize,
|
|
||||||
fontFamily: 'Fira Code, ui-monospace, SFMono-Regular, monospace',
|
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
|
allowProposedApi: true,
|
||||||
theme: {
|
theme: {
|
||||||
background: '#1e1e1e',
|
background: '#1e1e1e',
|
||||||
foreground: '#d4d4d4',
|
foreground: '#d4d4d4',
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
|
||||||
|
|
||||||
export class CustomWebLinksAddon implements ITerminalAddon {
|
|
||||||
private _terminal?: Terminal;
|
|
||||||
private _linkMatcher?: number;
|
|
||||||
|
|
||||||
constructor(private _handler?: (event: MouseEvent, uri: string) => void) {}
|
|
||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
|
||||||
this._terminal = terminal;
|
|
||||||
|
|
||||||
// URL regex pattern - matches http/https URLs
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/gi;
|
|
||||||
|
|
||||||
this._linkMatcher = this._terminal.registerLinkMatcher(
|
|
||||||
urlRegex,
|
|
||||||
(event: MouseEvent, uri: string) => {
|
|
||||||
console.log('Custom WebLinks click:', uri);
|
|
||||||
if (this._handler) {
|
|
||||||
this._handler(event, uri);
|
|
||||||
} else {
|
|
||||||
window.open(uri, '_blank');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Custom styling options
|
|
||||||
hover: (
|
|
||||||
event: MouseEvent,
|
|
||||||
uri: string,
|
|
||||||
_location: { start: { x: number; y: number }; end: { x: number; y: number } }
|
|
||||||
) => {
|
|
||||||
console.log('Custom WebLinks hover:', uri);
|
|
||||||
// Style the link on hover
|
|
||||||
const linkElement = event.target as HTMLElement;
|
|
||||||
if (linkElement) {
|
|
||||||
linkElement.style.backgroundColor = 'rgba(59, 142, 234, 0.2)';
|
|
||||||
linkElement.style.color = '#ffffff';
|
|
||||||
linkElement.style.textDecoration = 'underline';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leave: (event: MouseEvent, uri: string) => {
|
|
||||||
console.log('Custom WebLinks leave:', uri);
|
|
||||||
// Remove hover styling
|
|
||||||
const linkElement = event.target as HTMLElement;
|
|
||||||
if (linkElement) {
|
|
||||||
linkElement.style.backgroundColor = '';
|
|
||||||
linkElement.style.color = '#3b8eea';
|
|
||||||
linkElement.style.textDecoration = 'underline';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
priority: 1,
|
|
||||||
willLinkActivate: (event: MouseEvent, uri: string) => {
|
|
||||||
console.log('Custom WebLinks will activate:', uri);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('Custom WebLinks addon activated with matcher ID:', this._linkMatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispose(): void {
|
|
||||||
if (this._linkMatcher !== undefined && this._terminal) {
|
|
||||||
this._terminal.deregisterLinkMatcher(this._linkMatcher);
|
|
||||||
this._linkMatcher = undefined;
|
|
||||||
}
|
|
||||||
this._terminal = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// Entry point for renderer bundle - exports XTerm-based renderer
|
|
||||||
export { Renderer } from './renderer';
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
// Terminal renderer for asciinema cast format using XTerm.js
|
|
||||||
// Professional-grade terminal emulation with full VT compatibility
|
|
||||||
|
|
||||||
import { Terminal } from '@xterm/xterm';
|
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
||||||
import { ScaleFitAddon } from './scale-fit-addon.js';
|
|
||||||
|
|
||||||
// interface CastHeader {
|
|
||||||
// version: number;
|
|
||||||
// width: number;
|
|
||||||
// height: number;
|
|
||||||
// timestamp?: number;
|
|
||||||
// env?: Record<string, string>;
|
|
||||||
// }
|
|
||||||
|
|
||||||
interface CastEvent {
|
|
||||||
timestamp: number;
|
|
||||||
type: 'o' | 'i' | 'r'; // output, input, or resize
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Renderer {
|
|
||||||
private static activeCount: number = 0;
|
|
||||||
|
|
||||||
private container: HTMLElement;
|
|
||||||
private terminal: Terminal;
|
|
||||||
private scaleFitAddon: ScaleFitAddon;
|
|
||||||
private webLinksAddon: WebLinksAddon;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
container: HTMLElement,
|
|
||||||
width: number = 80,
|
|
||||||
height: number = 20,
|
|
||||||
scrollback: number = 1000000,
|
|
||||||
fontSize: number = 14
|
|
||||||
) {
|
|
||||||
Renderer.activeCount++;
|
|
||||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
|
||||||
this.container = container;
|
|
||||||
|
|
||||||
// Create terminal with options similar to the custom renderer
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
cols: width,
|
|
||||||
rows: height,
|
|
||||||
fontFamily: 'Monaco, "Lucida Console", monospace',
|
|
||||||
fontSize: fontSize,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
theme: {
|
|
||||||
background: '#1e1e1e',
|
|
||||||
foreground: '#d4d4d4',
|
|
||||||
cursor: '#00ff00',
|
|
||||||
cursorAccent: '#1e1e1e',
|
|
||||||
selectionBackground: '#264f78',
|
|
||||||
// VS Code Dark theme colors
|
|
||||||
black: '#000000',
|
|
||||||
red: '#f14c4c',
|
|
||||||
green: '#23d18b',
|
|
||||||
yellow: '#f5f543',
|
|
||||||
blue: '#3b8eea',
|
|
||||||
magenta: '#d670d6',
|
|
||||||
cyan: '#29b8db',
|
|
||||||
white: '#e5e5e5',
|
|
||||||
// Bright colors
|
|
||||||
brightBlack: '#666666',
|
|
||||||
brightRed: '#f14c4c',
|
|
||||||
brightGreen: '#23d18b',
|
|
||||||
brightYellow: '#f5f543',
|
|
||||||
brightBlue: '#3b8eea',
|
|
||||||
brightMagenta: '#d670d6',
|
|
||||||
brightCyan: '#29b8db',
|
|
||||||
brightWhite: '#ffffff',
|
|
||||||
},
|
|
||||||
allowProposedApi: true,
|
|
||||||
scrollback: scrollback, // Configurable scrollback buffer
|
|
||||||
convertEol: true,
|
|
||||||
altClickMovesCursor: false,
|
|
||||||
rightClickSelectsWord: false,
|
|
||||||
disableStdin: true, // We handle input separately
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
cursorWidth: 1,
|
|
||||||
cursorInactiveStyle: 'block',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add addons
|
|
||||||
this.scaleFitAddon = new ScaleFitAddon();
|
|
||||||
this.webLinksAddon = new WebLinksAddon();
|
|
||||||
|
|
||||||
this.terminal.loadAddon(this.scaleFitAddon);
|
|
||||||
this.terminal.loadAddon(this.webLinksAddon);
|
|
||||||
|
|
||||||
this.setupDOM();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupDOM(): void {
|
|
||||||
// Clear container and add CSS
|
|
||||||
this.container.innerHTML = '';
|
|
||||||
|
|
||||||
// Full terminals get padding
|
|
||||||
this.container.style.padding = '10px';
|
|
||||||
this.container.style.backgroundColor = '#1e1e1e';
|
|
||||||
this.container.style.overflow = 'hidden';
|
|
||||||
this.container.style.maxWidth = '100%';
|
|
||||||
this.container.style.boxSizing = 'border-box';
|
|
||||||
|
|
||||||
// Create terminal wrapper
|
|
||||||
const terminalWrapper = document.createElement('div');
|
|
||||||
terminalWrapper.style.width = '100%';
|
|
||||||
terminalWrapper.style.height = '100%';
|
|
||||||
terminalWrapper.style.maxWidth = '100%';
|
|
||||||
terminalWrapper.style.overflow = 'hidden';
|
|
||||||
this.container.appendChild(terminalWrapper);
|
|
||||||
|
|
||||||
// Open terminal in the wrapper
|
|
||||||
this.terminal.open(terminalWrapper);
|
|
||||||
|
|
||||||
// Disable XTerm's input handling completely while preserving cursor
|
|
||||||
this.terminal.onData(() => {
|
|
||||||
// Do nothing - we handle input via our own system
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure cursor is visible without focus by forcing it to stay active
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.terminal.element) {
|
|
||||||
const helperTextarea = this.terminal.element.querySelector(
|
|
||||||
'.xterm-helper-textarea'
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
if (helperTextarea) {
|
|
||||||
// Prevent the helper textarea from stealing focus but allow cursor rendering
|
|
||||||
helperTextarea.addEventListener('focus', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
helperTextarea.blur();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add CSS to override XTerm's fixed width/height on .xterm-screen within this container
|
|
||||||
// Apply to both previews and full terminals
|
|
||||||
const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
this.container.id = containerId;
|
|
||||||
|
|
||||||
// Always use ScaleFitAddon for better scaling
|
|
||||||
this.scaleFitAddon.fit();
|
|
||||||
|
|
||||||
// Handle container resize
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.scaleFitAddon.fit();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(this.container);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API methods - maintain compatibility with custom renderer
|
|
||||||
|
|
||||||
async loadCastFile(url: string): Promise<void> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const text = await response.text();
|
|
||||||
this.parseCastFile(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
parseCastFile(content: string): void {
|
|
||||||
const lines = content.trim().split('\n');
|
|
||||||
const outputEvents: string[] = [];
|
|
||||||
|
|
||||||
// Clear terminal
|
|
||||||
this.terminal.clear();
|
|
||||||
|
|
||||||
// First pass: collect all output events and process headers/resizes immediately
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
|
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
|
||||||
// Header
|
|
||||||
// header = parsed;
|
|
||||||
this.resize(parsed.width, parsed.height);
|
|
||||||
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
||||||
// Event: [timestamp, type, data]
|
|
||||||
const event: CastEvent = {
|
|
||||||
timestamp: parsed[0],
|
|
||||||
type: parsed[1],
|
|
||||||
data: parsed[2],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.type === 'o') {
|
|
||||||
outputEvents.push(event.data);
|
|
||||||
} else if (event.type === 'r') {
|
|
||||||
this.processResize(event.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
console.warn('Failed to parse cast line:', line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write all output at once, then scroll when rendering is complete
|
|
||||||
if (outputEvents.length > 0) {
|
|
||||||
const allOutput = outputEvents.join('');
|
|
||||||
this.terminal.write(allOutput, () => {
|
|
||||||
// This callback fires when XTerm has finished rendering the content
|
|
||||||
this.scrollToBottom();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No output to render, scroll immediately
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processOutput(data: string): void {
|
|
||||||
// XTerm handles all ANSI escape sequences automatically
|
|
||||||
this.terminal.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
processResize(data: string): void {
|
|
||||||
// Parse resize data in format "WIDTHxHEIGHT" (e.g., "80x24")
|
|
||||||
const match = data.match(/^(\d+)x(\d+)$/);
|
|
||||||
if (match) {
|
|
||||||
const width = parseInt(match[1], 10);
|
|
||||||
const height = parseInt(match[2], 10);
|
|
||||||
this.resize(width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processEvent(event: CastEvent): void {
|
|
||||||
if (event.type === 'o') {
|
|
||||||
this.processOutput(event.data);
|
|
||||||
} else if (event.type === 'r') {
|
|
||||||
this.processResize(event.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resize(width: number, height: number): void {
|
|
||||||
// Resize terminal to session dimensions
|
|
||||||
this.terminal.resize(width, height);
|
|
||||||
// Always use ScaleFitAddon for consistent scaling behavior
|
|
||||||
this.scaleFitAddon.fit();
|
|
||||||
|
|
||||||
// Emit custom event with terminal dimensions
|
|
||||||
const event = new CustomEvent('terminal-resize', {
|
|
||||||
detail: { cols: width, rows: height },
|
|
||||||
bubbles: true,
|
|
||||||
});
|
|
||||||
this.container.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.terminal.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream support - connect to SSE endpoint
|
|
||||||
connectToStream(sessionId: string): EventSource {
|
|
||||||
console.log('connectToStream called for session:', sessionId);
|
|
||||||
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to any SSE URL
|
|
||||||
connectToUrl(url: string): EventSource {
|
|
||||||
console.log('Creating new EventSource connection to:', url);
|
|
||||||
const eventSource = new EventSource(url);
|
|
||||||
|
|
||||||
// Don't clear terminal for live streams - just append new content
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.version && data.width && data.height) {
|
|
||||||
// Header
|
|
||||||
console.log('Received header:', data);
|
|
||||||
this.resize(data.width, data.height);
|
|
||||||
} else if (Array.isArray(data) && data.length >= 3) {
|
|
||||||
// Check if this is an exit event
|
|
||||||
if (data[0] === 'exit') {
|
|
||||||
const exitCode = data[1];
|
|
||||||
const sessionId = data[2];
|
|
||||||
console.log(`Session ${sessionId} exited with code ${exitCode}`);
|
|
||||||
|
|
||||||
// Close the SSE connection immediately
|
|
||||||
if (this.eventSource) {
|
|
||||||
console.log('Closing SSE connection due to session exit');
|
|
||||||
this.eventSource.close();
|
|
||||||
this.eventSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch custom event that session-view can listen to
|
|
||||||
const exitEvent = new CustomEvent('session-exit', {
|
|
||||||
detail: { sessionId, exitCode },
|
|
||||||
});
|
|
||||||
this.container.dispatchEvent(exitEvent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular cast event
|
|
||||||
const castEvent: CastEvent = {
|
|
||||||
timestamp: data[0],
|
|
||||||
type: data[1],
|
|
||||||
data: data[2],
|
|
||||||
};
|
|
||||||
// Process event without verbose logging
|
|
||||||
this.processEvent(castEvent);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
console.warn('Failed to parse stream event:', event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
|
||||||
console.error('Stream error:', error);
|
|
||||||
// Close the connection to prevent automatic reconnection attempts
|
|
||||||
if (eventSource.readyState === EventSource.CLOSED) {
|
|
||||||
console.log('Stream closed, cleaning up...');
|
|
||||||
if (this.eventSource === eventSource) {
|
|
||||||
this.eventSource = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return eventSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
private eventSource: EventSource | null = null;
|
|
||||||
|
|
||||||
// Load content from URL - pass isStream to determine how to handle it
|
|
||||||
async loadFromUrl(url: string, isStream: boolean): Promise<void> {
|
|
||||||
// Clean up existing connection
|
|
||||||
if (this.eventSource) {
|
|
||||||
console.log('Explicitly closing existing EventSource connection');
|
|
||||||
this.eventSource.close();
|
|
||||||
this.eventSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStream) {
|
|
||||||
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
|
||||||
this.eventSource = this.connectToUrl(url);
|
|
||||||
} else {
|
|
||||||
// It's a snapshot URL, clear first then load as cast file
|
|
||||||
this.terminal.clear();
|
|
||||||
await this.loadCastFile(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional methods for terminal control
|
|
||||||
|
|
||||||
focus(): void {
|
|
||||||
this.terminal.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
blur(): void {
|
|
||||||
this.terminal.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
getTerminal(): Terminal {
|
|
||||||
return this.terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.eventSource) {
|
|
||||||
console.log('Explicitly closing EventSource connection in dispose()');
|
|
||||||
this.eventSource.close();
|
|
||||||
this.eventSource = null;
|
|
||||||
}
|
|
||||||
this.terminal.dispose();
|
|
||||||
Renderer.activeCount--;
|
|
||||||
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to fit terminal to container (useful for responsive layouts)
|
|
||||||
fit(): void {
|
|
||||||
this.scaleFitAddon.fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll terminal to bottom
|
|
||||||
scrollToBottom(): void {
|
|
||||||
this.terminal.scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get terminal dimensions
|
|
||||||
getDimensions(): { cols: number; rows: number } {
|
|
||||||
return {
|
|
||||||
cols: this.terminal.cols,
|
|
||||||
rows: this.terminal.rows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write raw data to terminal (useful for testing)
|
|
||||||
write(data: string): void {
|
|
||||||
this.terminal.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable/disable input (though we keep it disabled by default)
|
|
||||||
setInputEnabled(enabled: boolean): void {
|
|
||||||
// XTerm doesn't have a direct way to disable input, so we override onData
|
|
||||||
if (enabled) {
|
|
||||||
// Remove any existing handler first
|
|
||||||
this.terminal.onData(() => {
|
|
||||||
// Input is handled by the session component
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.terminal.onData(() => {
|
|
||||||
// Do nothing - input disabled
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable all pointer events for previews so clicks pass through to parent
|
|
||||||
setPointerEventsEnabled(enabled: boolean): void {
|
|
||||||
const terminalElement = this.container.querySelector('.xterm') as HTMLElement;
|
|
||||||
if (terminalElement) {
|
|
||||||
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom FitAddon that scales font size to fit terminal columns to container width,
|
|
||||||
* then calculates optimal rows for the container height.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Terminal, ITerminalAddon } from '@xterm/xterm';
|
|
||||||
|
|
||||||
interface ITerminalDimensions {
|
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINIMUM_ROWS = 1;
|
|
||||||
const MIN_FONT_SIZE = 4;
|
|
||||||
const MAX_FONT_SIZE = 16;
|
|
||||||
|
|
||||||
export class ScaleFitAddon implements ITerminalAddon {
|
|
||||||
private _terminal: Terminal | undefined;
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
|
||||||
this._terminal = terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispose(): void {}
|
|
||||||
|
|
||||||
public fit(): void {
|
|
||||||
// For full terminals, resize both font and dimensions
|
|
||||||
const dims = this.proposeDimensions();
|
|
||||||
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only resize rows, keep cols the same (font scaling handles width)
|
|
||||||
if (this._terminal.rows !== dims.rows) {
|
|
||||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force responsive sizing by overriding XTerm's fixed dimensions
|
|
||||||
this.forceResponsiveSizing();
|
|
||||||
}
|
|
||||||
|
|
||||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
|
||||||
if (!this._terminal?.element?.parentElement) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the renderer container (parent of parent - the one with 10px padding)
|
|
||||||
const terminalWrapper = this._terminal.element.parentElement;
|
|
||||||
const rendererContainer = terminalWrapper.parentElement;
|
|
||||||
|
|
||||||
if (!rendererContainer) return undefined;
|
|
||||||
|
|
||||||
// Get container dimensions and exact padding
|
|
||||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
|
||||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
|
||||||
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
|
|
||||||
const containerPadding = {
|
|
||||||
top: parseInt(containerStyle.getPropertyValue('padding-top')),
|
|
||||||
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
|
|
||||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
|
||||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate exact available space using known padding
|
|
||||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
|
||||||
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
|
|
||||||
|
|
||||||
// Current terminal dimensions
|
|
||||||
const currentCols = this._terminal.cols;
|
|
||||||
|
|
||||||
// Get exact character dimensions from XTerm's measurement system first
|
|
||||||
const charDimensions = this.getXTermCharacterDimensions();
|
|
||||||
|
|
||||||
if (charDimensions) {
|
|
||||||
// Use actual measured dimensions for linear scaling calculation
|
|
||||||
const { charWidth, lineHeight } = charDimensions;
|
|
||||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
|
||||||
|
|
||||||
// Calculate current total rendered width for all columns
|
|
||||||
const currentRenderedWidth = currentCols * charWidth;
|
|
||||||
|
|
||||||
// Calculate scale factor needed to fit exactly in available width
|
|
||||||
const scaleFactor = availableWidth / currentRenderedWidth;
|
|
||||||
|
|
||||||
// Apply linear scaling to font size
|
|
||||||
const newFontSize = currentFontSize * scaleFactor;
|
|
||||||
const clampedFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newFontSize));
|
|
||||||
|
|
||||||
// Calculate actual font scaling that was applied (accounting for clamping)
|
|
||||||
const actualFontScaling = clampedFontSize / currentFontSize;
|
|
||||||
|
|
||||||
// Apply the actual font scaling to line height
|
|
||||||
const newLineHeight = lineHeight * actualFontScaling;
|
|
||||||
|
|
||||||
// Calculate how many rows fit with the scaled line height
|
|
||||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / newLineHeight));
|
|
||||||
|
|
||||||
// Apply the new font size
|
|
||||||
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
|
|
||||||
|
|
||||||
// Log all calculations for debugging
|
|
||||||
console.log(
|
|
||||||
`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}→${optimalRows}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cols: currentCols, // ALWAYS keep exact column count
|
|
||||||
rows: optimalRows, // Maximize rows that fit
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Fallback: estimate font size and dimensions if measurements aren't available
|
|
||||||
const charWidthRatio = 0.63;
|
|
||||||
const calculatedFontSize =
|
|
||||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
|
||||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
|
||||||
|
|
||||||
// Apply the calculated font size
|
|
||||||
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
|
|
||||||
|
|
||||||
const lineHeight = optimalFontSize * (this._terminal.options.lineHeight || 1.2);
|
|
||||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
|
||||||
|
|
||||||
return {
|
|
||||||
cols: currentCols,
|
|
||||||
rows: optimalRows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyFontSize(fontSize: number): void {
|
|
||||||
if (!this._terminal?.element) return;
|
|
||||||
|
|
||||||
// Prevent infinite recursion by checking if font size changed significantly
|
|
||||||
const currentFontSize = this._terminal.options.fontSize || 14;
|
|
||||||
if (Math.abs(fontSize - currentFontSize) < 0.1) return;
|
|
||||||
|
|
||||||
const terminalElement = this._terminal.element;
|
|
||||||
|
|
||||||
// Update terminal's font size
|
|
||||||
this._terminal.options.fontSize = fontSize;
|
|
||||||
|
|
||||||
// Apply CSS font size to the element
|
|
||||||
terminalElement.style.fontSize = `${fontSize}px`;
|
|
||||||
|
|
||||||
// Force a refresh to apply the new font size and ensure responsive sizing
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this._terminal) {
|
|
||||||
this._terminal.refresh(0, this._terminal.rows - 1);
|
|
||||||
// Force responsive sizing after refresh since XTerm might reset dimensions
|
|
||||||
this.forceResponsiveSizing();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the calculated font size that would fit the current columns in the container
|
|
||||||
*/
|
|
||||||
private scaleFontOnly(): void {
|
|
||||||
if (!this._terminal?.element?.parentElement) return;
|
|
||||||
|
|
||||||
// Get container dimensions for font scaling
|
|
||||||
const terminalWrapper = this._terminal.element.parentElement;
|
|
||||||
const rendererContainer = terminalWrapper.parentElement;
|
|
||||||
if (!rendererContainer) return;
|
|
||||||
|
|
||||||
const containerStyle = window.getComputedStyle(rendererContainer);
|
|
||||||
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
|
||||||
const containerPadding = {
|
|
||||||
left: parseInt(containerStyle.getPropertyValue('padding-left')),
|
|
||||||
right: parseInt(containerStyle.getPropertyValue('padding-right')),
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
|
||||||
const currentCols = this._terminal.cols;
|
|
||||||
|
|
||||||
// Calculate font size to fit columns in available width
|
|
||||||
const charWidthRatio = 0.63;
|
|
||||||
// Calculate font size and round down for precision
|
|
||||||
const calculatedFontSize =
|
|
||||||
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
|
|
||||||
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
|
||||||
|
|
||||||
// Apply the font size without changing terminal dimensions
|
|
||||||
this.applyFontSize(optimalFontSize);
|
|
||||||
|
|
||||||
// Also force responsive sizing for previews
|
|
||||||
this.forceResponsiveSizing();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getOptimalFontSize(): number {
|
|
||||||
if (!this._terminal?.element?.parentElement) {
|
|
||||||
return this._terminal?.options.fontSize || 14;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentElement = this._terminal.element.parentElement;
|
|
||||||
const parentStyle = window.getComputedStyle(parentElement);
|
|
||||||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
|
||||||
|
|
||||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
|
||||||
const paddingHor =
|
|
||||||
parseInt(elementStyle.getPropertyValue('padding-left')) +
|
|
||||||
parseInt(elementStyle.getPropertyValue('padding-right'));
|
|
||||||
|
|
||||||
const availableWidth = parentWidth - paddingHor;
|
|
||||||
const charWidthRatio = 0.63;
|
|
||||||
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
|
|
||||||
|
|
||||||
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get exact character dimensions from XTerm's built-in measurement system
|
|
||||||
*/
|
|
||||||
private getXTermCharacterDimensions(): { charWidth: number; lineHeight: number } | null {
|
|
||||||
if (!this._terminal?.element) return null;
|
|
||||||
|
|
||||||
// XTerm has a built-in character measurement system with multiple font styles
|
|
||||||
const measureContainer = this._terminal.element.querySelector(
|
|
||||||
'.xterm-width-cache-measure-container'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find the first measurement element (normal weight, usually 'm' characters)
|
|
||||||
// This is what XTerm uses for baseline character width calculations
|
|
||||||
const firstMeasureElement = measureContainer?.querySelector('.xterm-char-measure-element');
|
|
||||||
|
|
||||||
if (firstMeasureElement) {
|
|
||||||
const measureRect = firstMeasureElement.getBoundingClientRect();
|
|
||||||
const measureText = firstMeasureElement.textContent || '';
|
|
||||||
|
|
||||||
if (measureText.length > 0 && measureRect.width > 0) {
|
|
||||||
// Calculate actual character width from the primary measurement element
|
|
||||||
const actualCharWidth = measureRect.width / measureText.length;
|
|
||||||
|
|
||||||
// Get line height from the first row in .xterm-rows
|
|
||||||
const xtermRows = this._terminal.element.querySelector('.xterm-rows');
|
|
||||||
const firstRow = xtermRows?.querySelector('div');
|
|
||||||
let lineHeight = 21.5; // fallback
|
|
||||||
|
|
||||||
if (firstRow) {
|
|
||||||
const rowStyle = window.getComputedStyle(firstRow);
|
|
||||||
const rowLineHeight = parseFloat(rowStyle.lineHeight);
|
|
||||||
if (!isNaN(rowLineHeight) && rowLineHeight > 0) {
|
|
||||||
lineHeight = rowLineHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
charWidth: actualCharWidth,
|
|
||||||
lineHeight: lineHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try to measure from the xterm-screen dimensions and terminal cols/rows
|
|
||||||
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
|
|
||||||
if (xtermScreen) {
|
|
||||||
const screenRect = xtermScreen.getBoundingClientRect();
|
|
||||||
const charWidth = screenRect.width / this._terminal.cols;
|
|
||||||
const lineHeight = screenRect.height / this._terminal.rows;
|
|
||||||
|
|
||||||
if (charWidth > 0 && lineHeight > 0) {
|
|
||||||
return { charWidth, lineHeight };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force XTerm elements to use responsive sizing instead of fixed dimensions
|
|
||||||
*/
|
|
||||||
private forceResponsiveSizing(): void {
|
|
||||||
if (!this._terminal?.element) return;
|
|
||||||
|
|
||||||
// Find the xterm-screen element within the terminal
|
|
||||||
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
|
|
||||||
const xtermViewport = this._terminal.element.querySelector('.xterm-viewport') as HTMLElement;
|
|
||||||
|
|
||||||
if (xtermScreen) {
|
|
||||||
// Remove any fixed width/height styles and force responsive sizing
|
|
||||||
xtermScreen.style.width = '100%';
|
|
||||||
xtermScreen.style.height = '100%';
|
|
||||||
xtermScreen.style.maxWidth = '100%';
|
|
||||||
xtermScreen.style.maxHeight = '100%';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xtermViewport) {
|
|
||||||
xtermViewport.style.width = '100%';
|
|
||||||
xtermViewport.style.maxWidth = '100%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,2 @@
|
||||||
// Entry point for test pages - includes both terminal implementations
|
export * from './components/terminal.js';
|
||||||
import './components/mobile-terminal.js';
|
export * from './utils/cast-converter.js';
|
||||||
import './components/terminal.js';
|
|
||||||
|
|
||||||
// Re-export CastConverter for use in test pages
|
|
||||||
export { CastConverter } from './utils/cast-converter.js';
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import express from 'express';
|
import express, { Response } from 'express';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
@ -347,7 +347,7 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
||||||
const activeStreams = new Map<
|
const activeStreams = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
clients: Set<WebSocket>;
|
clients: Set<Response>;
|
||||||
tailProcess: ChildProcess;
|
tailProcess: ChildProcess;
|
||||||
lastPosition: number;
|
lastPosition: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue