Add first iteration of file browser

This commit is contained in:
Peter Steinberger 2025-06-21 16:10:39 +02:00
parent eab7a6f833
commit db8f4ffbeb
16 changed files with 1651 additions and 61 deletions

View file

@ -5,10 +5,12 @@
"name": "vibetunnel-web",
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
"@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
"mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2",
},
@ -382,6 +384,8 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
@ -1100,9 +1104,9 @@
"mime": ["mime@2.6.0", "", { "bin": "cli.js" }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@ -1544,12 +1548,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
@ -1576,7 +1576,7 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
@ -1594,16 +1594,12 @@
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/string-width": ["string-width@3.1.0", "", { "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } }, "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w=="],
"cliui/wrap-ansi": ["wrap-ansi@5.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "string-width": "^3.0.0", "strip-ansi": "^5.0.0" } }, "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q=="],
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@ -1616,6 +1612,8 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@ -1624,8 +1622,6 @@
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jake/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"jest-circus/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@ -1644,14 +1640,10 @@
"jest-snapshot/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
"jest-util/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"jest-validate/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"jest-validate/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lint-staged/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"log-update/ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
@ -1660,8 +1652,6 @@
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@ -1692,8 +1682,6 @@
"string-length/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string-width/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@ -1710,11 +1698,7 @@
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
"vite/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
@ -1730,10 +1714,6 @@
"yargs/yargs-parser": ["yargs-parser@13.1.2", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
@ -1760,9 +1740,9 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"babel-plugin-istanbul/test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"babel-plugin-istanbul/test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"babel-plugin-istanbul/test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@ -1774,10 +1754,6 @@
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@7.0.3", "", {}, "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="],
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@ -1792,12 +1768,12 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"jake/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"jest-circus/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"jest-circus/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@ -1846,24 +1822,20 @@
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@ -1888,8 +1860,6 @@
"@puppeteer/browsers/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"babel-plugin-istanbul/test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"chokidar-cli/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
@ -1916,8 +1886,6 @@
"jest-cli/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

87
web/package-lock.json generated
View file

@ -10,10 +10,12 @@
"license": "MIT",
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
"@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
"mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2"
},
@ -2779,6 +2781,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
@ -3615,6 +3623,27 @@
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -6356,6 +6385,29 @@
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@ -8846,21 +8898,21 @@
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@ -11586,6 +11638,27 @@
"node": ">= 0.6"
}
},
"node_modules/type-is/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/type-is/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",

View file

@ -34,10 +34,12 @@
},
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
"@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
"mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2"
},

View file

@ -8,6 +8,7 @@ import './components/session-create-form.js';
import './components/session-list.js';
import './components/session-view.js';
import './components/session-card.js';
import './components/file-browser-enhanced.js';
import type { Session } from './components/session-list.js';
import type { SessionCard } from './components/session-card.js';
@ -27,6 +28,7 @@ export class VibeTunnelApp extends LitElement {
@state() private selectedSessionId: string | null = null;
@state() private hideExited = this.loadHideExitedState();
@state() private showCreateModal = false;
@state() private showFileBrowser = false;
private hotReloadWs: WebSocket | null = null;
private errorTimeoutId: number | null = null;
@ -38,6 +40,7 @@ export class VibeTunnelApp extends LitElement {
this.loadSessions();
this.startAutoRefresh();
this.setupRouting();
this.setupKeyboardShortcuts();
}
disconnectedCallback() {
@ -47,6 +50,20 @@ export class VibeTunnelApp extends LitElement {
}
// Clean up routing listeners
window.removeEventListener('popstate', this.handlePopState);
// Clean up keyboard shortcuts
window.removeEventListener('keydown', this.handleKeyDown);
}
private handleKeyDown = (e: KeyboardEvent) => {
// Handle Cmd+O / Ctrl+O to open file browser
if ((e.metaKey || e.ctrlKey) && e.key === 'o' && this.currentView === 'list') {
e.preventDefault();
this.showFileBrowser = true;
}
};
private setupKeyboardShortcuts() {
window.addEventListener('keydown', this.handleKeyDown);
}
private showError(message: string) {
@ -452,6 +469,7 @@ export class VibeTunnelApp extends LitElement {
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
@open-file-browser=${() => (this.showFileBrowser = true)}
></app-header>
<session-list
.sessions=${this.sessions}
@ -469,6 +487,13 @@ export class VibeTunnelApp extends LitElement {
></session-list>
</div>
`}
<!-- File Browser Modal -->
<file-browser-enhanced
.visible=${this.showFileBrowser}
.mode=${'browse'}
@browser-cancel=${() => (this.showFileBrowser = false)}
></file-browser-enhanced>
`;
}
}

View file

@ -46,6 +46,10 @@ export class AppHeader extends LitElement {
this.dispatchEvent(new CustomEvent('clean-exited-sessions'));
}
private handleOpenFileBrowser() {
this.dispatchEvent(new CustomEvent('open-file-browser'));
}
render() {
const runningSessions = this.sessions.filter((session) => session.status === 'running');
const exitedSessions = this.sessions.filter((session) => session.status === 'exited');
@ -116,6 +120,13 @@ export class AppHeader extends LitElement {
</div>
<div class="flex gap-2">
<button
class="btn-secondary font-mono text-xs px-3 py-2"
@click=${this.handleOpenFileBrowser}
title="Browse Files"
>
Browse
</button>
<button
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
@click=${this.handleCreateSession}
@ -180,6 +191,13 @@ export class AppHeader extends LitElement {
</button>
`
: ''}
<button
class="btn-secondary font-mono text-xs px-4 py-2"
@click=${this.handleOpenFileBrowser}
title="Browse Files (⌘O)"
>
Browse
</button>
<button
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
@click=${this.handleCreateSession}

View file

@ -0,0 +1,766 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
interface FileInfo {
name: string;
path: string;
type: 'file' | 'directory';
size: number;
modified: string;
permissions?: string;
isGitTracked?: boolean;
gitStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'unchanged';
}
interface DirectoryListing {
path: string;
fullPath: string;
gitStatus: GitStatus | null;
files: FileInfo[];
}
interface GitStatus {
isGitRepo: boolean;
branch?: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
}
interface FilePreview {
type: 'image' | 'text' | 'binary';
content?: string;
language?: string;
url?: string;
mimeType?: string;
size: number;
humanSize?: string;
}
interface FileDiff {
path: string;
diff: string;
hasDiff: boolean;
}
@customElement('file-browser-enhanced')
export class FileBrowserEnhanced extends LitElement {
static styles = css`
:host {
display: block;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal-container {
width: 90vw;
height: 90vh;
max-width: 1400px;
max-height: 900px;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.split-view {
display: flex;
height: 100%;
gap: 1px;
background: #2d2d30;
}
.file-list {
flex: 1;
min-width: 300px;
background: #1e1e1e;
overflow-y: auto;
border-right: 1px solid #3e3e42;
}
.preview-pane {
flex: 2;
background: #1e1e1e;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-header {
padding: 8px 16px;
background: #252526;
border-bottom: 1px solid #3e3e42;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-content {
flex: 1;
overflow: auto;
position: relative;
}
.file-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.15s;
border-left: 3px solid transparent;
font-size: 13px;
}
.file-item:hover {
background: #2a2a2a;
}
.file-item.selected {
background: #094771;
border-left-color: #007acc;
}
.file-icon {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-status {
margin-left: auto;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
}
.git-status.modified {
background: #4b3c00;
color: #ffd700;
}
.git-status.added {
background: #0e3a0e;
color: #73c991;
}
.git-status.deleted {
background: #5a1d1d;
color: #f48771;
}
.git-status.untracked {
background: #373737;
color: #909090;
}
.monaco-container {
width: 100%;
height: 100%;
}
.image-preview {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #1e1e1e;
height: 100%;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border: 1px solid #3e3e42;
border-radius: 4px;
}
.binary-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #cccccc;
text-align: center;
padding: 20px;
}
.toolbar {
display: flex;
gap: 8px;
padding: 12px 16px;
background: #252526;
border-bottom: 1px solid #3e3e42;
align-items: center;
}
.filter-button {
padding: 4px 8px;
background: #3e3e42;
border: 1px solid #5a5a5a;
color: #cccccc;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.filter-button:hover {
background: #4e4e52;
}
.filter-button.active {
background: #007acc;
border-color: #007acc;
}
.path-breadcrumb {
padding: 8px 16px;
background: #252526;
border-bottom: 1px solid #3e3e42;
font-size: 12px;
color: #cccccc;
display: flex;
align-items: center;
justify-content: space-between;
}
.close-button {
background: none;
border: none;
color: #cccccc;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.close-button:hover {
background: #3e3e42;
color: #ffffff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
padding: 20px;
text-align: center;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
}
.diff-preview {
padding: 16px;
overflow: auto;
height: 100%;
}
.diff-line {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre;
}
.diff-line.added {
background: #0e3a0e;
color: #73c991;
}
.diff-line.deleted {
background: #5a1d1d;
color: #f48771;
}
.diff-line.context {
color: #cccccc;
}
.diff-line.header {
color: #569cd6;
font-weight: 600;
}
`;
@property({ type: String }) currentPath = '.';
@property({ type: Boolean }) visible = false;
@property({ type: String }) mode: 'browse' | 'select' = 'browse';
@state() private files: FileInfo[] = [];
@state() private loading = false;
@state() private selectedFile: FileInfo | null = null;
@state() private preview: FilePreview | null = null;
@state() private diff: FileDiff | null = null;
@state() private gitFilter: 'all' | 'changed' = 'all';
@state() private showHidden = false;
@state() private gitStatus: GitStatus | null = null;
@state() private previewLoading = false;
@state() private showDiff = false;
private monacoEditor: any = null;
private monacoContainer: HTMLElement | null = null;
async connectedCallback() {
super.connectedCallback();
console.log(
`[FileBrowser] Connected, visible: ${this.visible}, currentPath: ${this.currentPath}`
);
if (this.visible) {
await this.loadDirectory(this.currentPath);
}
document.addEventListener('keydown', this.handleKeyDown);
}
async updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('visible')) {
console.log(`[FileBrowser] Visibility changed to: ${this.visible}`);
if (this.visible) {
await this.loadDirectory(this.currentPath);
}
}
if (this.preview?.type === 'text' && this.monacoContainer && !this.monacoEditor) {
this.initMonacoEditor();
}
}
private async loadDirectory(dirPath: string) {
this.loading = true;
try {
const params = new URLSearchParams({
path: dirPath,
showHidden: this.showHidden.toString(),
gitFilter: this.gitFilter,
});
console.log(`[FileBrowser] Loading directory: ${dirPath}`);
const response = await fetch(`/api/fs/browse?${params}`);
console.log(`[FileBrowser] Response status: ${response.status}`);
if (response.ok) {
const data: DirectoryListing = await response.json();
console.log(`[FileBrowser] Received ${data.files?.length || 0} files`);
this.currentPath = data.path;
this.files = data.files || [];
this.gitStatus = data.gitStatus;
} else {
const errorData = await response.text();
console.error(`[FileBrowser] Failed to load directory: ${response.status}`, errorData);
}
} catch (error) {
console.error('[FileBrowser] Error loading directory:', error);
} finally {
this.loading = false;
}
}
private async loadPreview(file: FileInfo) {
if (file.type === 'directory') return;
this.previewLoading = true;
this.selectedFile = file;
this.showDiff = false;
try {
const response = await fetch(`/api/fs/preview?path=${encodeURIComponent(file.path)}`);
if (response.ok) {
this.preview = await response.json();
if (this.preview?.type === 'text') {
// Update Monaco editor if it exists
this.updateMonacoContent();
}
}
} catch (error) {
console.error('Error loading preview:', error);
} finally {
this.previewLoading = false;
}
}
private async loadDiff(file: FileInfo) {
if (file.type === 'directory' || !file.gitStatus || file.gitStatus === 'unchanged') return;
this.previewLoading = true;
this.showDiff = true;
try {
const response = await fetch(`/api/fs/diff?path=${encodeURIComponent(file.path)}`);
if (response.ok) {
this.diff = await response.json();
}
} catch (error) {
console.error('Error loading diff:', error);
} finally {
this.previewLoading = false;
}
}
private initMonacoEditor() {
if (!window.monaco || !this.monacoContainer) return;
this.monacoEditor = window.monaco.editor.create(this.monacoContainer, {
value: this.preview?.content || '',
language: this.preview?.language || 'plaintext',
theme: 'vs-dark',
readOnly: true,
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
});
}
private updateMonacoContent() {
if (!this.monacoEditor || !this.preview) return;
this.monacoEditor.setValue(this.preview.content || '');
window.monaco.editor.setModelLanguage(
this.monacoEditor.getModel(),
this.preview.language || 'plaintext'
);
}
private handleFileClick(file: FileInfo) {
if (file.type === 'directory') {
this.loadDirectory(file.path);
} else {
this.loadPreview(file);
}
}
private handleParentClick() {
const parentPath = this.currentPath.split('/').slice(0, -1).join('/') || '.';
this.loadDirectory(parentPath);
}
private toggleGitFilter() {
this.gitFilter = this.gitFilter === 'all' ? 'changed' : 'all';
this.loadDirectory(this.currentPath);
}
private toggleHidden() {
this.showHidden = !this.showHidden;
this.loadDirectory(this.currentPath);
}
private toggleDiff() {
if (
this.selectedFile &&
this.selectedFile.gitStatus &&
this.selectedFile.gitStatus !== 'unchanged'
) {
if (this.showDiff) {
this.loadPreview(this.selectedFile);
} else {
this.loadDiff(this.selectedFile);
}
}
}
private handleSelect() {
if (this.mode === 'select' && this.currentPath) {
this.dispatchEvent(
new CustomEvent('directory-selected', {
detail: this.currentPath,
})
);
}
}
private handleCancel() {
this.dispatchEvent(new CustomEvent('browser-cancel'));
}
private handleOverlayClick(e: Event) {
if (e.target === e.currentTarget) {
this.handleCancel();
}
}
private renderFileIcon(file: FileInfo) {
if (file.type === 'directory') {
return '📁';
}
const ext = file.name.split('.').pop()?.toLowerCase();
const iconMap: Record<string, string> = {
js: '📜',
ts: '📘',
jsx: '⚛️',
tsx: '⚛️',
json: '📋',
md: '📝',
txt: '📄',
html: '🌐',
css: '🎨',
scss: '🎨',
png: '🖼️',
jpg: '🖼️',
jpeg: '🖼️',
gif: '🖼️',
svg: '🖼️',
pdf: '📑',
zip: '📦',
tar: '📦',
gz: '📦',
};
return iconMap[ext || ''] || '📄';
}
private renderGitStatus(status?: FileInfo['gitStatus']) {
if (!status || status === 'unchanged') return '';
const labels: Record<string, string> = {
modified: 'M',
added: 'A',
deleted: 'D',
untracked: '?',
};
return html` <span class="git-status ${status}">${labels[status]}</span> `;
}
private renderPreview() {
if (this.previewLoading) {
return html`<div class="loading">Loading preview...</div>`;
}
if (this.showDiff && this.diff) {
return this.renderDiff();
}
if (!this.preview) {
return html`
<div class="empty-state">
<div style="font-size: 48px; margin-bottom: 16px;">📄</div>
<div>Select a file to preview</div>
</div>
`;
}
switch (this.preview.type) {
case 'image':
return html`
<div class="image-preview">
<img src="${this.preview.url}" alt="${this.selectedFile?.name}" />
</div>
`;
case 'text':
return html`
<div
class="monaco-container"
@connected=${(e: Event) => {
this.monacoContainer = e.target as HTMLElement;
this.initMonacoEditor();
}}
></div>
`;
case 'binary':
return html`
<div class="binary-preview">
<div style="font-size: 48px; margin-bottom: 16px;">📦</div>
<div style="font-size: 18px; margin-bottom: 8px;">Binary File</div>
<div style="color: #888;">
${this.preview.humanSize || this.preview.size + ' bytes'}
</div>
<div style="color: #888; margin-top: 8px;">
${this.preview.mimeType || 'Unknown type'}
</div>
</div>
`;
}
}
private renderDiff() {
if (!this.diff || !this.diff.diff) {
return html`
<div class="empty-state">
<div>No changes in this file</div>
</div>
`;
}
const lines = this.diff.diff.split('\n');
return html`
<div class="diff-preview">
${lines.map((line) => {
let className = 'diff-line context';
if (line.startsWith('+')) className = 'diff-line added';
else if (line.startsWith('-')) className = 'diff-line deleted';
else if (line.startsWith('@@')) className = 'diff-line header';
return html`<div class="${className}">${line}</div>`;
})}
</div>
`;
}
render() {
if (!this.visible) {
return html``;
}
return html`
<div class="modal-overlay" @click=${this.handleOverlayClick}>
<div class="modal-container" @click=${(e: Event) => e.stopPropagation()}>
<div class="toolbar">
<button
class="filter-button ${this.gitFilter === 'changed' ? 'active' : ''}"
@click=${this.toggleGitFilter}
title="Show only Git changes"
>
Git Changes
</button>
<button
class="filter-button ${this.showHidden ? 'active' : ''}"
@click=${this.toggleHidden}
title="Show hidden files"
>
Hidden Files
</button>
${this.gitStatus
? html`
<div
style="margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: #888;"
>
<span>📍 ${this.gitStatus.branch}</span>
</div>
`
: ''}
</div>
<div class="path-breadcrumb">
<span>📂 ${this.currentPath}</span>
<button class="close-button" @click=${this.handleCancel} title="Close (Esc)"></button>
</div>
<div class="split-view" style="flex: 1;">
<div class="file-list">
${this.loading
? html`<div class="loading">Loading...</div>`
: html`
${this.currentPath !== '.' && this.currentPath !== '/'
? html`
<div class="file-item" @click=${this.handleParentClick}>
<span class="file-icon"></span>
<span class="file-name">..</span>
</div>
`
: ''}
${this.files.map(
(file) => html`
<div
class="file-item ${this.selectedFile?.path === file.path
? 'selected'
: ''}"
@click=${() => this.handleFileClick(file)}
>
<span class="file-icon">${this.renderFileIcon(file)}</span>
<span class="file-name">${file.name}</span>
${this.renderGitStatus(file.gitStatus)}
</div>
`
)}
`}
</div>
<div class="preview-pane">
${this.selectedFile
? html`
<div class="preview-header">
<div style="display: flex; align-items: center; gap: 8px;">
<span>${this.renderFileIcon(this.selectedFile)}</span>
<span>${this.selectedFile.name}</span>
${this.renderGitStatus(this.selectedFile.gitStatus)}
</div>
${this.selectedFile.gitStatus && this.selectedFile.gitStatus !== 'unchanged'
? html`
<button
class="filter-button ${this.showDiff ? 'active' : ''}"
@click=${this.toggleDiff}
>
${this.showDiff ? 'View File' : 'View Diff'}
</button>
`
: ''}
</div>
`
: ''}
<div class="preview-content">${this.renderPreview()}</div>
</div>
</div>
${this.mode === 'select'
? html`
<div class="p-4 border-t border-dark-border flex gap-4">
<button class="btn-ghost font-mono flex-1" @click=${this.handleCancel}>
Cancel
</button>
<button class="btn-primary font-mono flex-1" @click=${this.handleSelect}>
Select Directory
</button>
</div>
`
: ''}
</div>
</div>
`;
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this.handleKeyDown);
if (this.monacoEditor) {
this.monacoEditor.dispose();
this.monacoEditor = null;
}
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.visible) {
e.preventDefault();
this.handleCancel();
}
};
}

View file

@ -0,0 +1,99 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('file-browser-fab')
export class FileBrowserFAB extends LitElement {
static styles = css`
:host {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 100;
}
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: #007acc;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.fab:hover {
background: #005a9e;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
}
.fab:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.icon {
font-size: 24px;
}
.tooltip {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: #333;
color: white;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.fab:hover + .tooltip {
opacity: 1;
}
@media (max-width: 768px) {
:host {
bottom: 16px;
right: 16px;
}
.fab {
width: 48px;
height: 48px;
}
.icon {
font-size: 20px;
}
}
`;
@property({ type: Boolean }) visible = true;
private handleClick() {
this.dispatchEvent(new CustomEvent('open-file-browser'));
}
render() {
if (!this.visible) {
return html``;
}
return html`
<button class="fab" @click=${this.handleClick} title="Browse Files (⌘O)">
<span class="icon">📁</span>
</button>
<div class="tooltip">Browse Files (O)</div>
`;
}
}

View file

@ -2,6 +2,8 @@ import { LitElement, PropertyValues, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import './terminal.js';
import './file-browser-fab.js';
import './file-browser-enhanced.js';
import type { Terminal } from './terminal.js';
import { CastConverter } from '../utils/cast-converter.js';
import {
@ -35,6 +37,7 @@ export class SessionView extends LitElement {
@state() private terminalMaxCols = 0;
@state() private showWidthSelector = false;
@state() private customWidth = '';
@state() private showFileBrowser = false;
private preferencesManager = TerminalPreferencesManager.getInstance();
@state() private reconnectCount = 0;
@ -48,6 +51,12 @@ export class SessionView extends LitElement {
private lastResizeHeight = 0;
private keyboardHandler = (e: KeyboardEvent) => {
// Handle Cmd+O / Ctrl+O to open file browser
if ((e.metaKey || e.ctrlKey) && e.key === 'o') {
e.preventDefault();
this.showFileBrowser = true;
return;
}
if (!this.session) return;
// Allow important browser shortcuts to pass through
@ -847,6 +856,14 @@ export class SessionView extends LitElement {
return commonWidth ? commonWidth.label : this.terminalMaxCols.toString();
}
private handleOpenFileBrowser() {
this.showFileBrowser = true;
}
private handleCloseFileBrowser() {
this.showFileBrowser = false;
}
private async sendInputText(text: string) {
if (!this.session) return;
@ -1359,6 +1376,19 @@ export class SessionView extends LitElement {
</div>
`
: ''}
<!-- File Browser FAB -->
<file-browser-fab
.visible=${!this.showFileBrowser}
@open-file-browser=${this.handleOpenFileBrowser}
></file-browser-fab>
<!-- File Browser Modal -->
<file-browser-enhanced
.visible=${this.showFileBrowser}
.mode=${'browse'}
@browser-cancel=${this.handleCloseFileBrowser}
></file-browser-enhanced>
</div>
`;
}

8
web/src/client/types/monaco.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
declare global {
interface Window {
monaco: any;
require: any;
}
}
export {};

View file

@ -2,6 +2,7 @@
// Entry point for the server - imports the modular server which starts automatically
import { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.js';
import { VERSION } from './server/version.js';
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
@ -14,7 +15,10 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1);
});
if (process.argv[2] === 'fwd') {
if (process.argv[2] === 'version') {
console.log(`VibeTunnel Linux v${VERSION}`);
process.exit(0);
} else if (process.argv[2] === 'fwd') {
startVibeTunnelForward(process.argv.slice(3)).catch((error) => {
console.error('Fatal error:', error);
process.exit(1);

View file

@ -13,6 +13,7 @@ import { HQClient } from './services/hq-client.js';
import { createAuthMiddleware } from './middleware/auth.js';
import { createSessionRoutes } from './routes/sessions.js';
import { createRemoteRoutes } from './routes/remotes.js';
import { createFilesystemRoutes } from './routes/filesystem.js';
import { ControlDirWatcher } from './services/control-dir-watcher.js';
import { BufferAggregator } from './services/buffer-aggregator.js';
import { v4 as uuidv4 } from 'uuid';
@ -354,6 +355,9 @@ export function createApp(): AppInstance {
})
);
// Mount filesystem routes
app.use('/api', createFilesystemRoutes());
// WebSocket endpoint for buffer updates
wss.on('connection', (ws, _req) => {
if (bufferAggregator) {
@ -438,6 +442,7 @@ export function createApp(): AppInstance {
remoteRegistry,
isHQMode: config.isHQMode,
hqClient,
ptyManager,
});
controlDirWatcher.start();
});

View file

@ -0,0 +1,462 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import mime from 'mime-types';
import { createReadStream, statSync } from 'fs';
const execAsync = promisify(exec);
interface FileInfo {
name: string;
path: string;
type: 'file' | 'directory';
size: number;
modified: string;
permissions?: string;
isGitTracked?: boolean;
gitStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'unchanged';
}
interface GitStatus {
isGitRepo: boolean;
branch?: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
}
export function createFilesystemRoutes(): Router {
const router = Router();
// Helper to check if path is safe (no directory traversal)
function isPathSafe(requestedPath: string, basePath: string): boolean {
const resolved = path.resolve(basePath, requestedPath);
return resolved.startsWith(path.resolve(basePath));
}
// Helper to get Git status for a directory
async function getGitStatus(dirPath: string): Promise<GitStatus | null> {
try {
// Check if directory is a git repository
await execAsync('git rev-parse --git-dir', { cwd: dirPath });
// Get current branch
const { stdout: branch } = await execAsync('git branch --show-current', { cwd: dirPath });
// Get status
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: dirPath });
const status: GitStatus = {
isGitRepo: true,
branch: branch.trim(),
modified: [],
added: [],
deleted: [],
untracked: [],
};
// Parse git status output
statusOutput.split('\n').forEach((line) => {
if (!line) return;
const statusCode = line.substring(0, 2);
const filename = line.substring(3);
if (statusCode === ' M' || statusCode === 'M ' || statusCode === 'MM') {
status.modified.push(filename);
} else if (statusCode === 'A ' || statusCode === 'AM') {
status.added.push(filename);
} else if (statusCode === ' D' || statusCode === 'D ') {
status.deleted.push(filename);
} else if (statusCode === '??') {
status.untracked.push(filename);
}
});
return status;
} catch {
return null;
}
}
// Helper to get file Git status
function getFileGitStatus(filePath: string, gitStatus: GitStatus | null): FileInfo['gitStatus'] {
if (!gitStatus) return undefined;
const relativePath = path.relative(process.cwd(), filePath);
if (gitStatus.modified.includes(relativePath)) return 'modified';
if (gitStatus.added.includes(relativePath)) return 'added';
if (gitStatus.deleted.includes(relativePath)) return 'deleted';
if (gitStatus.untracked.includes(relativePath)) return 'untracked';
return 'unchanged';
}
// Browse directory endpoint
router.get('/fs/browse', async (req: Request, res: Response) => {
try {
const requestedPath = (req.query.path as string) || '.';
const showHidden = req.query.showHidden === 'true';
const gitFilter = req.query.gitFilter as string; // 'all' | 'changed' | 'none'
// Security check
if (!isPathSafe(requestedPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), requestedPath);
// Check if path exists and is a directory
const stats = await fs.stat(fullPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
}
// Get Git status if requested
const gitStatus = gitFilter !== 'none' ? await getGitStatus(fullPath) : null;
// Read directory contents
const entries = await fs.readdir(fullPath, { withFileTypes: true });
// Build file list
const files: FileInfo[] = await Promise.all(
entries
.filter((entry) => showHidden || !entry.name.startsWith('.'))
.map(async (entry) => {
const entryPath = path.join(fullPath, entry.name);
const stats = await fs.stat(entryPath);
const relativePath = path.relative(process.cwd(), entryPath);
const fileInfo: FileInfo = {
name: entry.name,
path: relativePath,
type: entry.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtime.toISOString(),
permissions: stats.mode.toString(8).slice(-3),
isGitTracked: gitStatus?.isGitRepo || false,
gitStatus: getFileGitStatus(entryPath, gitStatus),
};
return fileInfo;
})
);
// Filter by Git status if requested
let filteredFiles = files;
if (gitFilter === 'changed' && gitStatus) {
filteredFiles = files.filter((file) => file.gitStatus && file.gitStatus !== 'unchanged');
}
// Sort: directories first, then by name
filteredFiles.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
res.json({
path: requestedPath,
fullPath,
gitStatus,
files: filteredFiles,
});
} catch (error: any) {
console.error('Browse error:', error);
res.status(500).json({ error: error.message });
}
});
// Get file preview
router.get('/fs/preview', async (req: Request, res: Response) => {
try {
const requestedPath = req.query.path as string;
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Security check
if (!isPathSafe(requestedPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), requestedPath);
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
return res.status(400).json({ error: 'Cannot preview directories' });
}
// Determine file type
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
const isText =
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/javascript' ||
mimeType === 'application/typescript' ||
mimeType === 'application/xml';
const isImage = mimeType.startsWith('image/');
if (isImage) {
// For images, return URL to fetch the image
res.json({
type: 'image',
mimeType,
url: `/api/fs/raw?path=${encodeURIComponent(requestedPath)}`,
size: stats.size,
});
} else if (isText || stats.size < 1024 * 1024) {
// Text or small files (< 1MB)
const content = await fs.readFile(fullPath, 'utf-8');
const language = getLanguageFromPath(fullPath);
res.json({
type: 'text',
content,
language,
mimeType,
size: stats.size,
});
} else {
// Binary or large files
res.json({
type: 'binary',
mimeType,
size: stats.size,
humanSize: formatBytes(stats.size),
});
}
} catch (error: any) {
console.error('Preview error:', error);
res.status(500).json({ error: error.message });
}
});
// Serve raw file content
router.get('/fs/raw', (req: Request, res: Response) => {
try {
const requestedPath = req.query.path as string;
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Security check
if (!isPathSafe(requestedPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), requestedPath);
// Check if file exists
if (!statSync(fullPath).isFile()) {
return res.status(404).json({ error: 'File not found' });
}
// Set appropriate content type
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
res.setHeader('Content-Type', mimeType);
// Stream the file
const stream = createReadStream(fullPath);
stream.pipe(res);
} catch (error: any) {
console.error('Raw file error:', error);
res.status(500).json({ error: error.message });
}
});
// Get file content (text files only)
router.get('/fs/content', async (req: Request, res: Response) => {
try {
const requestedPath = req.query.path as string;
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Security check
if (!isPathSafe(requestedPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), requestedPath);
const content = await fs.readFile(fullPath, 'utf-8');
res.json({
path: requestedPath,
content,
language: getLanguageFromPath(fullPath),
});
} catch (error: any) {
console.error('Content error:', error);
res.status(500).json({ error: error.message });
}
});
// Get Git diff for a file
router.get('/fs/diff', async (req: Request, res: Response) => {
try {
const requestedPath = req.query.path as string;
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
// Security check
if (!isPathSafe(requestedPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), requestedPath);
const relativePath = path.relative(process.cwd(), fullPath);
// Get git diff
const { stdout: diff } = await execAsync(`git diff HEAD -- "${relativePath}"`, {
cwd: process.cwd(),
});
res.json({
path: requestedPath,
diff,
hasDiff: diff.length > 0,
});
} catch (error: any) {
console.error('Diff error:', error);
res.status(500).json({ error: error.message });
}
});
// Create directory
router.post('/fs/mkdir', async (req: Request, res: Response) => {
try {
const { path: dirPath, name } = req.body;
if (!dirPath || !name) {
return res.status(400).json({ error: 'Path and name are required' });
}
// Validate name (no slashes, no dots at start)
if (name.includes('/') || name.includes('\\') || name.startsWith('.')) {
return res.status(400).json({ error: 'Invalid directory name' });
}
// Security check
if (!isPathSafe(dirPath, process.cwd())) {
return res.status(403).json({ error: 'Access denied' });
}
const fullPath = path.resolve(process.cwd(), dirPath, name);
// Create directory
await fs.mkdir(fullPath, { recursive: true });
res.json({
success: true,
path: path.relative(process.cwd(), fullPath),
});
} catch (error: any) {
console.error('Mkdir error:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
// Helper function to determine language from file path
function getLanguageFromPath(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const languageMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.py': 'python',
'.java': 'java',
'.c': 'c',
'.cpp': 'cpp',
'.cs': 'csharp',
'.php': 'php',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.swift': 'swift',
'.kt': 'kotlin',
'.scala': 'scala',
'.r': 'r',
'.m': 'objective-c',
'.mm': 'objective-c',
'.h': 'c',
'.hpp': 'cpp',
'.sh': 'shell',
'.bash': 'shell',
'.zsh': 'shell',
'.fish': 'shell',
'.ps1': 'powershell',
'.html': 'html',
'.htm': 'html',
'.xml': 'xml',
'.css': 'css',
'.scss': 'scss',
'.sass': 'sass',
'.less': 'less',
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.ini': 'ini',
'.cfg': 'ini',
'.conf': 'ini',
'.sql': 'sql',
'.md': 'markdown',
'.markdown': 'markdown',
'.tex': 'latex',
'.dockerfile': 'dockerfile',
'.makefile': 'makefile',
'.cmake': 'cmake',
'.gradle': 'gradle',
'.vue': 'vue',
'.svelte': 'svelte',
'.elm': 'elm',
'.clj': 'clojure',
'.cljs': 'clojure',
'.ex': 'elixir',
'.exs': 'elixir',
'.erl': 'erlang',
'.hrl': 'erlang',
'.fs': 'fsharp',
'.fsx': 'fsharp',
'.fsi': 'fsharp',
'.ml': 'ocaml',
'.mli': 'ocaml',
'.pas': 'pascal',
'.pp': 'pascal',
'.pl': 'perl',
'.pm': 'perl',
'.t': 'perl',
'.lua': 'lua',
'.dart': 'dart',
'.nim': 'nim',
'.nims': 'nim',
'.zig': 'zig',
'.jl': 'julia',
};
return languageMap[ext] || 'plaintext';
}
// Helper function to format bytes
function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}

View file

@ -6,6 +6,7 @@ import { RemoteRegistry } from '../services/remote-registry.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as net from 'net';
interface SessionRoutesConfig {
ptyManager: PtyManager;
@ -121,12 +122,47 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Create new session (local or on remote)
router.post('/sessions', async (req, res) => {
const { command, workingDir, name, remoteId } = req.body;
const { command, workingDir, name, remoteId, spawn_terminal } = req.body;
if (!command || !Array.isArray(command) || command.length === 0) {
return res.status(400).json({ error: 'Command array is required' });
}
// If spawn_terminal is true, use the spawn-terminal logic
if (spawn_terminal) {
try {
// Generate session ID
const sessionId = generateSessionId();
const sessionName = name || `session_${Date.now()}`;
// Request Mac app to spawn terminal
const spawnResult = await requestTerminalSpawn({
sessionId,
sessionName,
command,
workingDir: resolvePath(workingDir, process.cwd()),
});
if (!spawnResult.success) {
throw new Error(spawnResult.error || 'Failed to spawn terminal');
}
// Wait a bit for the session to be created
await new Promise((resolve) => setTimeout(resolve, 500));
// Return the session ID - client will poll for the session to appear
res.json({ sessionId, message: 'Terminal spawn requested' });
return;
} catch (error) {
console.error('Error spawning terminal:', error);
res.status(500).json({
error: 'Failed to spawn terminal',
details: error instanceof Error ? error.message : 'Unknown error',
});
return;
}
}
try {
// If remoteId is specified and we're in HQ mode, forward to remote
if (remoteId && isHQMode && remoteRegistry) {
@ -773,3 +809,85 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
return router;
}
// Generate a unique session ID
function generateSessionId(): string {
// Generate UUID v4
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
// Set version (4) and variant bits
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
// Convert to hex string with dashes
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
// Request terminal spawn from Mac app
async function requestTerminalSpawn(params: {
sessionId: string;
sessionName: string;
command: string[];
workingDir: string;
}): Promise<{ success: boolean; error?: string }> {
const socketPath = '/tmp/vibetunnel-terminal.sock';
// Check if socket exists
if (!fs.existsSync(socketPath)) {
return {
success: false,
error: 'Terminal spawn service not available. Is the Mac app running?',
};
}
const spawnRequest = {
workingDir: params.workingDir,
sessionId: params.sessionId,
command: params.command.join(' '),
terminal: null, // Let Mac app use default terminal
};
return new Promise((resolve) => {
const client = net.createConnection(socketPath, () => {
console.log(`Connected to terminal spawn service for session ${params.sessionId}`);
client.write(JSON.stringify(spawnRequest));
});
client.on('data', (data) => {
try {
const response = JSON.parse(data.toString());
console.log(`Terminal spawn response:`, response);
resolve({ success: response.success, error: response.error });
} catch (error) {
console.error('Failed to parse terminal spawn response:', error);
resolve({ success: false, error: 'Invalid response from terminal spawn service' });
}
client.end();
});
client.on('error', (error) => {
console.error('Failed to connect to terminal spawn service:', error);
resolve({
success: false,
error: `Connection failed: ${error.message}`,
});
});
client.on('timeout', () => {
client.destroy();
resolve({ success: false, error: 'Terminal spawn request timed out' });
});
client.setTimeout(5000); // 5 second timeout
});
}

View file

@ -70,8 +70,7 @@ export function startVibeTunnelServer() {
// When running with tsx, the main module check is different
// NOTE: When bundled as 'vibetunnel' executable, index.ts handles the startup
const isMainModule =
process.argv[1]?.endsWith('server.ts') ||
process.argv[1]?.endsWith('server/index.ts');
process.argv[1]?.endsWith('server.ts') || process.argv[1]?.endsWith('server/index.ts');
if (isMainModule) {
startVibeTunnelServer();
}

View file

@ -4,12 +4,14 @@ import chalk from 'chalk';
import { RemoteRegistry } from './remote-registry.js';
import { HQClient } from './hq-client.js';
import { isShuttingDown } from './shutdown-state.js';
import { PtyManager } from '../pty/index.js';
interface ControlDirWatcherConfig {
controlDir: string;
remoteRegistry: RemoteRegistry | null;
isHQMode: boolean;
hqClient: HQClient | null;
ptyManager?: PtyManager;
}
export class ControlDirWatcher {
@ -57,6 +59,17 @@ export class ControlDirWatcher {
console.log(chalk.blue(`Detected new external session: ${sessionId}`));
// Check if PtyManager already knows about this session
if (this.config.ptyManager) {
const existingSession = this.config.ptyManager.getSession(sessionId);
if (!existingSession) {
// This is a new external session, PtyManager needs to track it
console.log(chalk.green(`Attaching to external session: ${sessionId}`));
// PtyManager will pick it up through its own session listing
// since it reads from the control directory
}
}
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient && !isShuttingDown()) {
try {

View file

@ -1,7 +1,7 @@
// Version information for VibeTunnel Server
// This file is updated during the build process
export const VERSION = '1.0.0';
export const VERSION = '1.0.0-beta.3';
// BUILD_DATE will be replaced by build script, fallback to current time in dev
export const BUILD_DATE = process.env.BUILD_DATE || new Date().toISOString();
export const BUILD_TIMESTAMP = process.env.BUILD_TIMESTAMP || Date.now();
@ -31,4 +31,4 @@ export function printVersionBanner() {
console.log(`Built: ${BUILD_DATE}`);
console.log(`Platform: ${PLATFORM}/${ARCH} Node ${NODE_VERSION}`);
console.log(`PID: ${process.pid}`);
}
}