diff --git a/bun.lock b/bun.lock index 8d863179a5..9d0777a421 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -57,6 +56,7 @@ "express": "^5.1.0", "fix-path": "5.0.0", "ghostty-web": "^0.3.0-next.13.g3dd4aef", + "ignore": "^7.0.5", "jsdom": "^27.2.0", "json-schema-to-typescript": "^15.0.4", "jsonc-parser": "^3.3.1", @@ -1424,7 +1424,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -3820,14 +3820,26 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@jest/console/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/console/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@jest/core/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "@jest/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/core/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "@jest/environment/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/fake-timers/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/pattern/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/reporters/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/reporters/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/reporters/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -3842,6 +3854,8 @@ "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "@jest/types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3932,31 +3946,15 @@ "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "@types/asn1/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/body-parser/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/cacheable-request/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/connect/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/express-serve-static-core/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/keyv/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/plist/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/cors/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/responselike/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/fs-extra/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/send/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/jsdom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/serve-static/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/write-file-atomic/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/sshpk/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/wait-on/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/yauzl/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/ws/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -3988,6 +3986,8 @@ "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "bun-types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4030,8 +4030,6 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "electron/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4094,6 +4092,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -4114,8 +4114,6 @@ "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-circus/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "jest-circus/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4128,7 +4126,7 @@ "jest-each/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-environment-node/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "jest-haste-map/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4140,24 +4138,28 @@ "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-mock/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-process-manager/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-process-manager/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "jest-resolve/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-runner/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "jest-runner/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + "jest-runtime/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-runtime/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-snapshot/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-util/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -4166,12 +4168,16 @@ "jest-watch-typeahead/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "jest-watcher/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-watcher/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "jest-watcher/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-watcher/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + "jest-worker/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], diff --git a/package.json b/package.json index 312ddb4442..8b5e1bded5 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "express": "^5.1.0", "fix-path": "5.0.0", "ghostty-web": "^0.3.0-next.13.g3dd4aef", + "ignore": "^7.0.5", "jsdom": "^27.2.0", "json-schema-to-typescript": "^15.0.4", "jsonc-parser": "^3.3.1", diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 8b234e69d4..1ef175c9d4 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -62,12 +62,14 @@ import { import { createTerminalSession, openTerminalPopout } from "@/browser/utils/terminal"; import { CostsTabLabel, + ExplorerTabLabel, ReviewTabLabel, StatsTabLabel, TerminalTabLabel, getTabContentClassName, type ReviewStats, } from "./RightSidebar/tabs"; +import { ExplorerTab } from "./RightSidebar/ExplorerTab"; import { DndContext, DragOverlay, @@ -302,6 +304,8 @@ const RightSidebarTabsetNode: React.FC = (props) => label = ; } else if (tab === "review") { label = ; + } else if (tab === "explorer") { + label = ; } else if (tab === "stats") { label = ; } else if (isTerminal) { @@ -333,10 +337,12 @@ const RightSidebarTabsetNode: React.FC = (props) => const costsPanelId = `${tabsetBaseId}-panel-costs`; const reviewPanelId = `${tabsetBaseId}-panel-review`; + const explorerPanelId = `${tabsetBaseId}-panel-explorer`; const statsPanelId = `${tabsetBaseId}-panel-stats`; const costsTabId = `${tabsetBaseId}-tab-costs`; const reviewTabId = `${tabsetBaseId}-tab-review`; + const explorerTabId = `${tabsetBaseId}-tab-explorer`; const statsTabId = `${tabsetBaseId}-tab-stats`; // Generate sortable IDs for tabs in this tabset @@ -458,6 +464,17 @@ const RightSidebarTabsetNode: React.FC = (props) => )} + {props.node.activeTab === "explorer" && ( +
+ +
+ )} + {props.node.activeTab === "review" && (
; // relativePath -> children + expanded: Set; + loading: Set; + error: string | null; +} + +const DEBOUNCE_MS = 2000; +const INDENT_PX = 12; + +export const ExplorerTab: React.FC = (props) => { + const { api } = useAPI(); + + const [state, setState] = React.useState({ + entries: new Map(), + expanded: new Set(), + loading: new Set(), // starts empty, set when fetch begins + error: null, + }); + + // Track if we've done initial load + const initialLoadRef = React.useRef(false); + + // Fetch a directory's contents and return the entries (for recursive expand) + const fetchDirectory = React.useCallback( + async (relativePath: string, suppressErrors = false): Promise => { + if (!api) return null; + + const key = relativePath; // empty string = root directory + + setState((prev) => ({ + ...prev, + loading: new Set(prev.loading).add(key), + error: null, + })); + + try { + const result = await api.general.listWorkspaceDirectory({ + workspaceId: props.workspaceId, + relativePath: relativePath || undefined, + }); + + if (!result.success) { + setState((prev) => { + // On failure, remove from expanded set (dir may have been deleted) + const newExpanded = new Set(prev.expanded); + newExpanded.delete(key); + // Remove stale entries + const newEntries = new Map(prev.entries); + newEntries.delete(key); + return { + ...prev, + entries: newEntries, + expanded: newExpanded, + loading: new Set([...prev.loading].filter((k) => k !== key)), + // Only set error for root or if not suppressing + error: suppressErrors ? prev.error : result.error, + }; + }); + return null; + } + + setState((prev) => { + const newEntries = new Map(prev.entries); + newEntries.set(key, result.data); + return { + ...prev, + entries: newEntries, + loading: new Set([...prev.loading].filter((k) => k !== key)), + }; + }); + + return result.data; + } catch (err) { + setState((prev) => { + // On error, remove from expanded set + const newExpanded = new Set(prev.expanded); + newExpanded.delete(key); + const newEntries = new Map(prev.entries); + newEntries.delete(key); + return { + ...prev, + entries: newEntries, + expanded: newExpanded, + loading: new Set([...prev.loading].filter((k) => k !== key)), + error: suppressErrors ? prev.error : err instanceof Error ? err.message : String(err), + }; + }); + return null; + } + }, + [api, props.workspaceId] + ); + + // Initial load - retry when api becomes available + React.useEffect(() => { + if (!api) return; + if (!initialLoadRef.current) { + initialLoadRef.current = true; + void fetchDirectory(""); + } + }, [api, fetchDirectory]); + + // Subscribe to file-modifying tool events and debounce refresh + React.useEffect(() => { + let timeoutId: ReturnType | null = null; + + const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + // Refresh root and all expanded directories + // Suppress errors for non-root paths (dir may have been deleted) + void fetchDirectory(""); + for (const p of state.expanded) { + void fetchDirectory(p, true); + } + }, DEBOUNCE_MS); + }, props.workspaceId); + + return () => { + unsubscribe(); + if (timeoutId) clearTimeout(timeoutId); + }; + }, [props.workspaceId, state.expanded, fetchDirectory]); + + // Toggle expand/collapse + const toggleExpand = (node: FileTreeNode) => { + if (!node.isDirectory) return; + + const key = node.path; + + setState((prev) => { + const newExpanded = new Set(prev.expanded); + + if (newExpanded.has(key)) { + newExpanded.delete(key); + return { ...prev, expanded: newExpanded }; + } + + newExpanded.add(key); + + // Always fetch when expanding to ensure fresh data + void fetchDirectory(key); + + return { ...prev, expanded: newExpanded }; + }); + }; + + // Refresh all expanded paths + const handleRefresh = () => { + const pathsToRefresh = ["", ...state.expanded]; + void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p))); + }; + + // Collapse all + const handleCollapseAll = () => { + setState((prev) => ({ + ...prev, + expanded: new Set(), + })); + }; + + const hasExpandedDirs = state.expanded.size > 0; + + // Render a tree node recursively + const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => { + const key = node.path; + const isExpanded = state.expanded.has(key); + const isLoading = state.loading.has(key); + const children = state.entries.get(key) ?? []; + const isIgnored = node.ignored === true; + + return ( +
+ + + {node.isDirectory && isExpanded && ( +
{children.map((child) => renderNode(child, depth + 1))}
+ )} +
+ ); + }; + + const rootEntries = state.entries.get("") ?? []; + const isRootLoading = state.loading.has(""); + + // Shorten workspace path for display (replace home dir with ~) + const shortenPath = (fullPath: string): string => { + // Match home directory patterns across platforms: + // Linux: /home/username/... + // macOS: /Users/username/... + // Windows: C:\Users\username\... (may come as forward slashes too) + const homePatterns = [ + /^\/home\/[^/]+/, // Linux + /^\/Users\/[^/]+/, // macOS + /^[A-Za-z]:[\\/]Users[\\/][^\\/]+/, // Windows + ]; + + for (const pattern of homePatterns) { + const match = fullPath.match(pattern); + if (match) { + return "~" + fullPath.slice(match[0].length); + } + } + return fullPath; + }; + + const displayPath = shortenPath(props.workspacePath); + + return ( +
+ {/* Toolbar */} +
+ + + + {displayPath} + + {props.workspacePath} + +
+ + + + + Refresh + + {hasExpandedDirs && ( + + + + + Collapse All + + )} +
+
+ + {/* Tree */} +
+ {state.error &&
{state.error}
} + {isRootLoading && rootEntries.length === 0 ? ( +
+ +
+ ) : ( + rootEntries.map((node) => renderNode(node, 0)) + )} + {!isRootLoading && rootEntries.length === 0 && !state.error && ( +
No files found
+ )} +
+
+ ); +}; diff --git a/src/browser/components/RightSidebar/tabs/TabLabels.tsx b/src/browser/components/RightSidebar/tabs/TabLabels.tsx index f4f01f8548..e92f6116d3 100644 --- a/src/browser/components/RightSidebar/tabs/TabLabels.tsx +++ b/src/browser/components/RightSidebar/tabs/TabLabels.tsx @@ -5,7 +5,7 @@ */ import React from "react"; -import { ExternalLink, Terminal as TerminalIcon, X } from "lucide-react"; +import { ExternalLink, FolderTree, Terminal as TerminalIcon, X } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; import { formatTabDuration, type ReviewStats } from "./registry"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; @@ -62,6 +62,14 @@ export const StatsTabLabel: React.FC = ({ sessionDuration }) ); +/** Explorer tab label with folder tree icon */ +export const ExplorerTabLabel: React.FC = () => ( + + + Explorer + +); + interface TerminalTabLabelProps { /** Dynamic title from OSC sequences, if available */ dynamicTitle?: string; diff --git a/src/browser/components/RightSidebar/tabs/index.ts b/src/browser/components/RightSidebar/tabs/index.ts index 0ae1a5739a..13ff1f846b 100644 --- a/src/browser/components/RightSidebar/tabs/index.ts +++ b/src/browser/components/RightSidebar/tabs/index.ts @@ -20,4 +20,10 @@ export { type ReviewStats, } from "./registry"; -export { CostsTabLabel, ReviewTabLabel, StatsTabLabel, TerminalTabLabel } from "./TabLabels"; +export { + CostsTabLabel, + ExplorerTabLabel, + ReviewTabLabel, + StatsTabLabel, + TerminalTabLabel, +} from "./TabLabels"; diff --git a/src/browser/components/RightSidebar/tabs/registry.ts b/src/browser/components/RightSidebar/tabs/registry.ts index bd25d6131b..a02e73b900 100644 --- a/src/browser/components/RightSidebar/tabs/registry.ts +++ b/src/browser/components/RightSidebar/tabs/registry.ts @@ -78,7 +78,7 @@ export interface TabConfig { } /** Static tab configurations (non-terminal tabs) */ -export const TAB_CONFIGS: Record<"costs" | "review" | "stats", TabConfig> = { +export const TAB_CONFIGS: Record<"costs" | "review" | "explorer" | "stats", TabConfig> = { costs: { name: "Costs", contentClassName: "overflow-y-auto p-[15px]", @@ -87,6 +87,10 @@ export const TAB_CONFIGS: Record<"costs" | "review" | "stats", TabConfig> = { name: "Review", contentClassName: "overflow-y-auto p-0", }, + explorer: { + name: "Explorer", + contentClassName: "overflow-y-auto p-0", + }, stats: { name: "Stats", contentClassName: "overflow-y-auto p-[15px]", @@ -103,7 +107,7 @@ export const TERMINAL_TAB_CONFIG: TabConfig = { /** Get config for a tab type */ export function getTabConfig(tab: TabType): TabConfig { - if (tab === "costs" || tab === "review" || tab === "stats") { + if (tab === "costs" || tab === "review" || tab === "explorer" || tab === "stats") { return TAB_CONFIGS[tab]; } // All terminal tabs (including "terminal" placeholder) diff --git a/src/browser/stories/App.rightSidebar.stories.tsx b/src/browser/stories/App.rightSidebar.stories.tsx index 583f04174b..4cdd0e20b9 100644 --- a/src/browser/stories/App.rightSidebar.stories.tsx +++ b/src/browser/stories/App.rightSidebar.stories.tsx @@ -227,6 +227,171 @@ export const ReviewTab: AppStory = { }, }; +/** + * Explorer tab showing workspace file tree with folders and files + */ +export const ExplorerTab: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("explorer")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "350"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-explorer")); + + const client = setupSimpleChatStory({ + workspaceId: "ws-explorer", + workspaceName: "feature/files", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Show me the project structure", { historySequence: 1 }), + createAssistantMessage("msg-2", "Here is the project structure.", { + historySequence: 2, + }), + ], + }); + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for explorer tab to be available and click it + const explorerTab = await canvas.findByRole("tab", { name: /^explorer/i }, { timeout: 3000 }); + await userEvent.click(explorerTab); + + // Wait for file tree to load (mock returns src, tests, node_modules, etc.) + await waitFor( + () => { + canvas.getByText("src"); + canvas.getByText("package.json"); + }, + { timeout: 5000 } + ); + + // Verify ignored folder is shown with reduced opacity (node_modules) + await waitFor(() => { + canvas.getByText("node_modules"); + }); + }, +}; + +/** + * Explorer tab with expanded directory showing Collapse All button + */ +export const ExplorerTabExpanded: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("explorer")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "350"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-explorer-expanded")); + + const client = setupSimpleChatStory({ + workspaceId: "ws-explorer-expanded", + workspaceName: "feature/files", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Show me the project structure", { historySequence: 1 }), + createAssistantMessage("msg-2", "Here is the project structure.", { + historySequence: 2, + }), + ], + }); + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for explorer tab and click it + const explorerTab = await canvas.findByRole("tab", { name: /^explorer/i }, { timeout: 3000 }); + await userEvent.click(explorerTab); + + // Wait for file tree to load + await waitFor( + () => { + canvas.getByText("src"); + }, + { timeout: 5000 } + ); + + // Click on src folder to expand it + const srcFolder = canvas.getByText("src"); + await userEvent.click(srcFolder); + + // Wait for src contents to load and collapse all button to appear + await waitFor( + () => { + canvas.getByText("App.tsx"); + canvas.getByText("components"); + }, + { timeout: 5000 } + ); + + // Verify collapse all button is visible (tooltip text) + await waitFor(() => { + canvas.getByRole("button", { name: /collapse all/i }); + }); + + // Blur to get clean screenshot + blurActiveElement(); + }, +}; + +/** + * Explorer tab with selected item showing blue background + */ +export const ExplorerTabSelected: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("explorer")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "350"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-explorer-selected")); + + const client = setupSimpleChatStory({ + workspaceId: "ws-explorer-selected", + workspaceName: "feature/files", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Show me the project structure", { historySequence: 1 }), + createAssistantMessage("msg-2", "Here is the project structure.", { + historySequence: 2, + }), + ], + }); + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for explorer tab and click it + const explorerTab = await canvas.findByRole("tab", { name: /^explorer/i }, { timeout: 3000 }); + await userEvent.click(explorerTab); + + // Wait for file tree to load + await waitFor( + () => { + canvas.getByText("package.json"); + }, + { timeout: 5000 } + ); + + // Click on package.json to select it (will have focus/selected blue background) + const packageJson = canvas.getByText("package.json"); + await userEvent.click(packageJson); + + // Don't blur - keep the item selected/focused for the screenshot + }, +}; + /** * Stats tab when idle (no timing data) - shows placeholder message */ @@ -235,6 +400,7 @@ export const StatsTabIdle: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "400"); // Clear persisted layout to ensure stats tab appears in fresh default layout localStorage.removeItem(getRightSidebarLayoutKey("ws-stats-idle")); @@ -275,6 +441,7 @@ export const StatsTabStreaming: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "400"); // Clear persisted layout to ensure stats tab appears in fresh default layout localStorage.removeItem(getRightSidebarLayoutKey("ws-stats-streaming")); diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 67aadd72e7..a28b0555d1 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -428,6 +428,43 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, general: { listDirectory: () => Promise.resolve({ entries: [], hasMore: false }), + listWorkspaceDirectory: (input: { workspaceId: string; relativePath?: string }) => { + // Return different contents based on the requested path + if (input.relativePath === "src") { + return Promise.resolve({ + success: true as const, + data: [ + { + name: "components", + path: "src/components", + isDirectory: true, + children: [], + }, + { name: "utils", path: "src/utils", isDirectory: true, children: [] }, + { name: "App.tsx", path: "src/App.tsx", isDirectory: false, children: [] }, + { name: "index.ts", path: "src/index.ts", isDirectory: false, children: [] }, + ], + }); + } + // Root directory + return Promise.resolve({ + success: true as const, + data: [ + { name: "src", path: "src", isDirectory: true, children: [] }, + { name: "tests", path: "tests", isDirectory: true, children: [] }, + { + name: "node_modules", + path: "node_modules", + isDirectory: true, + children: [], + ignored: true, + }, + { name: "package.json", path: "package.json", isDirectory: false, children: [] }, + { name: "README.md", path: "README.md", isDirectory: false, children: [] }, + { name: "tsconfig.json", path: "tsconfig.json", isDirectory: false, children: [] }, + ], + }); + }, ping: (input: string) => Promise.resolve(`Pong: ${input}`), tick: async function* () { // No ticks in the mock, but keep the subscription open. diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 0218288a35..429f123741 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -81,6 +81,9 @@ --color-runtime-docker: #2496ed; /* Docker blue */ --color-runtime-docker-text: #54b3f4; + /* File explorer */ + --color-folder-icon: #dcb67a; /* gold/yellow folder color */ + /* Background & Layout */ --color-background: hsl(0 0% 12%); --color-background-secondary: hsl(60 1% 15%); @@ -356,6 +359,9 @@ --color-runtime-docker: #2496ed; /* Docker blue */ --color-runtime-docker-text: #0d7cc4; + /* File explorer */ + --color-folder-icon: #c9a227; /* darker gold for light theme */ + --color-background: hsl(210 33% 98%); --color-background-secondary: hsl(210 36% 95%); --color-border: hsl(210 24% 82%); @@ -606,6 +612,9 @@ --color-runtime-docker: #2aa198; /* cyan */ --color-runtime-docker-text: #2aa198; + /* File explorer */ + --color-folder-icon: #b58900; /* solarized yellow */ + /* Background & Layout - Solarized base colors */ --color-background: #fdf6e3; /* base3 */ --color-background-secondary: #eee8d5; /* base2 */ @@ -817,6 +826,9 @@ --color-runtime-docker: #2aa198; /* cyan */ --color-runtime-docker-text: #2aa198; + /* File explorer */ + --color-folder-icon: #b58900; /* solarized yellow */ + /* Background & Layout - Solarized dark base colors Palette reference: base03 #002b36 (darkest) base3 #fdf6e3 (lightest) diff --git a/src/browser/types/rightSidebar.ts b/src/browser/types/rightSidebar.ts index 837177eb58..3485d393d4 100644 --- a/src/browser/types/rightSidebar.ts +++ b/src/browser/types/rightSidebar.ts @@ -1,4 +1,4 @@ -export const RIGHT_SIDEBAR_TABS = ["costs", "review", "terminal", "stats"] as const; +export const RIGHT_SIDEBAR_TABS = ["costs", "review", "terminal", "explorer", "stats"] as const; /** Base tab types that are always valid */ export type BaseTabType = (typeof RIGHT_SIDEBAR_TABS)[number]; diff --git a/src/browser/utils/rightSidebarLayout.test.ts b/src/browser/utils/rightSidebarLayout.test.ts index 6619c762fd..eecaba2c55 100644 --- a/src/browser/utils/rightSidebarLayout.test.ts +++ b/src/browser/utils/rightSidebarLayout.test.ts @@ -122,19 +122,19 @@ test("moveTabToTabset removes empty source tabset", () => { }); test("reorderTabInTabset reorders tabs within a tabset", () => { - // Default layout has ["costs", "review"]; reorder costs from 0 to 1 + // Default layout has ["costs", "review", "explorer"]; reorder costs from 0 to 1 const s0 = getDefaultRightSidebarLayoutState("costs"); const s1 = reorderTabInTabset(s0, "tabset-1", 0, 1); expect(s1.root.type).toBe("tabset"); if (s1.root.type !== "tabset") throw new Error("expected tabset"); - expect(s1.root.tabs).toEqual(["review", "costs"]); + expect(s1.root.tabs).toEqual(["review", "costs", "explorer"]); expect(s1.root.activeTab).toBe("costs"); }); test("dockTabToEdge splits a tabset and moves the dragged tab into the new pane", () => { - // Default layout has ["costs", "review"]; drag review into a bottom split + // Default layout has ["costs", "review", "explorer"]; drag review into a bottom split const s0 = getDefaultRightSidebarLayoutState("costs"); const s1 = dockTabToEdge(s0, "review", "tabset-1", "tabset-1", "bottom"); diff --git a/src/browser/utils/rightSidebarLayout.ts b/src/browser/utils/rightSidebarLayout.ts index 0f78d7c3ed..00e5b303a1 100644 --- a/src/browser/utils/rightSidebarLayout.ts +++ b/src/browser/utils/rightSidebarLayout.ts @@ -58,7 +58,7 @@ export interface RightSidebarLayoutState { export function getDefaultRightSidebarLayoutState(activeTab: TabType): RightSidebarLayoutState { // Default tabs exclude terminal - users add terminals via the "+" button - const baseTabs: TabType[] = ["costs", "review"]; + const baseTabs: TabType[] = ["costs", "review", "explorer"]; const tabs = baseTabs.includes(activeTab) ? baseTabs : [...baseTabs, activeTab]; return { @@ -74,11 +74,41 @@ export function getDefaultRightSidebarLayoutState(activeTab: TabType): RightSide }; } +/** + * Recursively inject a tab into the first tabset that doesn't have it. + * Returns true if injection happened. + */ +function injectTabIntoLayout(node: RightSidebarLayoutNode, tab: TabType): boolean { + if (node.type === "tabset") { + if (!node.tabs.includes(tab)) { + node.tabs.push(tab); + return true; + } + return false; + } + // Split node - try first child, then second + return injectTabIntoLayout(node.children[0], tab) || injectTabIntoLayout(node.children[1], tab); +} + +/** + * Check if a tab exists anywhere in the layout tree. + */ +function layoutContainsTab(node: RightSidebarLayoutNode, tab: TabType): boolean { + if (node.type === "tabset") { + return node.tabs.includes(tab); + } + return layoutContainsTab(node.children[0], tab) || layoutContainsTab(node.children[1], tab); +} + export function parseRightSidebarLayoutState( raw: unknown, activeTabFallback: TabType ): RightSidebarLayoutState { if (isRightSidebarLayoutState(raw)) { + // Migrate: inject "explorer" tab if missing from persisted layout + if (!layoutContainsTab(raw.root, "explorer")) { + injectTabIntoLayout(raw.root, "explorer"); + } return raw; } diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index a84bd8ed4b..1289cf1a4c 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -976,6 +976,18 @@ export const features = { // General export const general = { + /** + * List workspace directory contents (files and directories). + * Unlike listDirectory (directories only), this returns both files and directories. + * Sorted: directories first, then files, both alphabetically. .git is filtered out. + */ + listWorkspaceDirectory: { + input: z.object({ + workspaceId: z.string(), + relativePath: z.string().optional(), + }), + output: ResultSchema(z.array(FileTreeNodeSchema)), + }, listDirectory: { input: z.object({ path: z.string() }), output: ResultSchema(FileTreeNodeSchema), diff --git a/src/common/orpc/schemas/tools.ts b/src/common/orpc/schemas/tools.ts index 1007dfb922..f8fc19c770 100644 --- a/src/common/orpc/schemas/tools.ts +++ b/src/common/orpc/schemas/tools.ts @@ -37,6 +37,8 @@ export const FileTreeNodeSchema = z.object({ get children() { return z.array(FileTreeNodeSchema); }, + /** Whether this file/directory is gitignored */ + ignored: z.boolean().optional(), stats: z .object({ filePath: z.string(), diff --git a/src/common/utils/git/numstatParser.ts b/src/common/utils/git/numstatParser.ts index f66ecc0b67..d58b9fa8d4 100644 --- a/src/common/utils/git/numstatParser.ts +++ b/src/common/utils/git/numstatParser.ts @@ -73,6 +73,8 @@ export interface FileTreeNode { path: string; isDirectory: boolean; children: FileTreeNode[]; + /** Whether this file/directory is gitignored */ + ignored?: boolean; stats?: FileStats; /** Total stats including all children (for directories) */ totalStats?: FileStats; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 24c16e395c..b6355f0c89 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -574,6 +574,32 @@ export const router = (authToken?: string) => { }), }, general: { + listWorkspaceDirectory: t + .input(schemas.general.listWorkspaceDirectory.input) + .output(schemas.general.listWorkspaceDirectory.output) + .handler(async ({ context, input }) => { + // Get workspace metadata to derive the actual path (don't trust frontend path) + const metadataResult = await context.aiService.getWorkspaceMetadata(input.workspaceId); + if (!metadataResult.success) { + return { success: false as const, error: metadataResult.error }; + } + + const metadata = metadataResult.data; + const runtimeType = metadata.runtimeConfig?.type; + + // Remote runtimes (SSH/Docker) not yet supported - return empty list + if (runtimeType && runtimeType !== "local" && runtimeType !== "worktree") { + return { success: true as const, data: [] }; + } + + // Derive workspace path from metadata (secure - not from frontend input) + const runtime = createRuntime(metadata.runtimeConfig, { + projectPath: metadata.projectPath, + }); + const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + return context.projectService.listWorkspaceDirectory(workspacePath, input.relativePath); + }), listDirectory: t .input(schemas.general.listDirectory.input) .output(schemas.general.listDirectory.output) diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index a7baeea7c5..4faf6b9445 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -22,6 +22,7 @@ import type { BranchListResult } from "@/common/orpc/types"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import * as path from "path"; import * as os from "os"; +import ignore from "ignore"; /** * List directory contents for the DirectoryPickerModal. @@ -60,6 +61,92 @@ async function listDirectory(requestedPath: string): Promise { const FILE_COMPLETIONS_CACHE_TTL_MS = 10_000; +/** + * Load and parse .gitignore file from workspace root. + * Returns an ignore instance that can check if paths are ignored. + */ +async function loadGitignore( + workspacePath: string +): Promise<{ ignores: (path: string) => boolean } | null> { + try { + const gitignorePath = path.join(workspacePath, ".gitignore"); + const content = await fsPromises.readFile(gitignorePath, "utf-8"); + return ignore().add(content); + } catch { + // No .gitignore or can't read it + return null; + } +} + +/** + * List workspace directory contents (files AND directories). + * Unlike listDirectory (directories only), this returns both. + * Sorted: directories first, then files, both alphabetically. .git is filtered out. + * Marks files/directories as ignored if they match .gitignore patterns. + */ +async function listWorkspaceDirectory( + workspacePath: string, + relativePath?: string +): Promise> { + try { + // Validate relativePath doesn't escape workspace + if (relativePath) { + // Reject absolute paths + if (path.isAbsolute(relativePath)) { + return Err("Absolute paths are not allowed"); + } + // Normalize and verify it stays within workspace + const resolved = path.resolve(workspacePath, relativePath); + const normalizedWorkspace = path.resolve(workspacePath); + if ( + !resolved.startsWith(normalizedWorkspace + path.sep) && + resolved !== normalizedWorkspace + ) { + return Err("Path traversal not allowed"); + } + } + + const targetPath = relativePath ? path.join(workspacePath, relativePath) : workspacePath; + const normalizedPath = path.resolve(targetPath); + + const [entries, ig] = await Promise.all([ + fsPromises.readdir(normalizedPath, { withFileTypes: true }), + loadGitignore(workspacePath), + ]); + + const nodes: FileTreeNode[] = entries + .filter((entry) => entry.name !== ".git") + .map((entry) => { + const entryPath = relativePath ? path.join(relativePath, entry.name) : entry.name; + // For directories, append / to match gitignore directory patterns + // Use POSIX separators for gitignore matching (Windows uses backslashes) + const posixPath = entryPath.split(path.sep).join("/"); + const pathToCheck = entry.isDirectory() ? `${posixPath}/` : posixPath; + const ignored = ig ? ig.ignores(pathToCheck) : false; + + return { + name: entry.name, + path: entryPath, + isDirectory: entry.isDirectory(), + children: [], + ignored: ignored || undefined, // Only include if true + }; + }) + // Sort: directories first, then files, both alphabetically + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return Ok(nodes); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to list directory: ${message}`); + } +} + interface FileCompletionsCacheEntry { index: FileCompletionsIndex; fetchedAt: number; @@ -320,6 +407,10 @@ export class ProjectService { } } + async listWorkspaceDirectory(workspacePath: string, relativePath?: string) { + return listWorkspaceDirectory(workspacePath, relativePath); + } + async createDirectory( requestedPath: string ): Promise> { diff --git a/tests/e2e/scenarios/sidebarDragDrop.spec.ts b/tests/e2e/scenarios/sidebarDragDrop.spec.ts index 3f7e190638..a1dd839ab2 100644 --- a/tests/e2e/scenarios/sidebarDragDrop.spec.ts +++ b/tests/e2e/scenarios/sidebarDragDrop.spec.ts @@ -203,7 +203,7 @@ test.describe("sidebar drag and drop", () => { const topTabs = await tablists[0].getByRole("tab").all(); const bottomTabs = await tablists[1].getByRole("tab").all(); - expect(topTabs.length).toBe(2); // Costs, Review + expect(topTabs.length).toBe(3); // Costs, Review, Explorer expect(bottomTabs.length).toBe(1); // Costs (duplicate tab in split) });