diff --git a/bun.lock b/bun.lock index 3e1bb084f8..742f3cc006 100644 --- a/bun.lock +++ b/bun.lock @@ -13,12 +13,32 @@ "@ai-sdk/openai": "^2.0.76", "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.1", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.11", "@coder/mux-md-client": "^0.1.0-main.14", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@homebridge/ciao": "^1.3.4", "@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0", + "@lezer/highlight": "^1.2.1", "@lydell/node-pty": "1.1.0", "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^1.2.5", @@ -533,6 +553,48 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q=="], + + "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + + "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + + "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], + + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="], + + "@codemirror/language": ["@codemirror/language@6.12.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ=="], + + "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + + "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + + "@codemirror/view": ["@codemirror/view@6.39.11", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ=="], + "@coder/mux-md-client": ["@coder/mux-md-client@0.1.0-main.14", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/ed25519": "^3.0.0", "@noble/hashes": "^2.0.1" } }, "sha512-f7ePWaitBh/QzlzGZVH9QRy5JNNVnZEyHb+efapqIRan4gS3aLS622frasPV/w8JGGft/zgBHGfaJFlXreIesw=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -793,6 +855,38 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@lezer/common": ["@lezer/common@1.5.0", "", {}, "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA=="], + + "@lezer/cpp": ["@lezer/cpp@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw=="], + + "@lezer/css": ["@lezer/css@1.3.0", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw=="], + + "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/java": ["@lezer/java@1.1.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.7", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-wNIFWdSUfX9Jc6ePMzxSPVgTVB4EOfDIwLQLWASyiUdHKaMsiilj9bYiGkGQCKVodd0x6bgQCV207PILGFCF9Q=="], + + "@lezer/markdown": ["@lezer/markdown@1.6.3", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="], + + "@lezer/php": ["@lezer/php@1.0.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA=="], + + "@lezer/python": ["@lezer/python@1.1.18", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="], + + "@lezer/rust": ["@lezer/rust@1.0.2", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="], + + "@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], + + "@lezer/yaml": ["@lezer/yaml@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA=="], + "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], @@ -811,6 +905,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], @@ -1873,6 +1969,8 @@ "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], @@ -3465,6 +3563,8 @@ "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -3683,6 +3783,8 @@ "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "wait-on": ["wait-on@7.2.0", "", { "dependencies": { "axios": "^1.6.1", "joi": "^17.11.0", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.1" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ=="], diff --git a/package.json b/package.json index 4f86a84fbc..2e313b2ae4 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,26 @@ "@ai-sdk/openai": "^2.0.76", "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.1", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.11", + "@lezer/highlight": "^1.2.1", "@coder/mux-md-client": "^0.1.0-main.14", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 2d63315661..ca8e22d688 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -56,6 +56,9 @@ import { updateSplitSizes, type RightSidebarLayoutNode, type RightSidebarLayoutState, + type RightSidebarTabState, + type RightSidebarTabStates, + type FileDraftHistory, } from "@/browser/utils/rightSidebarLayout"; import { RightSidebarTabStrip, @@ -222,6 +225,16 @@ interface RightSidebarTabsetNodeProps { onAutoFocusConsumed: () => void; /** Handler to open a file in a new tab */ onOpenFile: (relativePath: string) => void; + /** Map of file tabs to dirty (unsaved) state */ + fileDirtyMap: Map; + /** Handler to update file dirty state */ + onFileDirtyChange: (tab: TabType, dirty: boolean) => void; + /** Persisted tab state for right sidebar tabs */ + tabStates: RightSidebarTabStates; + /** Handler to update file draft content */ + onFileDraftChange: (tab: TabType, content: string | null) => void; + /** Handler to update file draft history */ + onFileDraftHistoryChange: (tab: TabType, history: FileDraftHistory | null) => void; /** Handler to close a file tab */ onCloseFile: (tab: TabType) => void; } @@ -329,7 +342,13 @@ const RightSidebarTabsetNode: React.FC = (props) => ); } else if (isFileTab(tab)) { const filePath = getFilePath(tab); - label = props.onCloseFile(tab)} />; + label = ( + props.onCloseFile(tab)} + /> + ); } else { label = tab; } @@ -503,6 +522,9 @@ const RightSidebarTabsetNode: React.FC = (props) => const fileTabId = `${tabsetBaseId}-tab-${fileTab}`; const filePanelId = `${tabsetBaseId}-panel-${fileTab}`; const isActive = props.node.activeTab === fileTab; + const fileTabState = props.tabStates[fileTab]; + const draftContent = fileTabState?.fileDraft ?? null; + const draftHistory = fileTabState?.fileHistory ?? null; return (
= (props) => hidden={!isActive} > {isActive && filePath && ( - + + props.onFileDraftHistoryChange(fileTab, history) + } + draftContent={draftContent} + onDraftChange={(content) => props.onFileDraftChange(fileTab, content)} + onDirtyChange={(dirty) => props.onFileDirtyChange(fileTab, dirty)} + /> )}
); @@ -622,6 +654,7 @@ const RightSidebarComponent: React.FC = ({ () => parseRightSidebarLayoutState(layoutDraft ?? layoutRaw, initialActiveTab), [layoutDraft, layoutRaw, initialActiveTab] ); + const tabStates = React.useMemo(() => layout.tabStates ?? {}, [layout.tabStates]); // If the Stats tab feature is enabled, ensure it exists in the layout. // If disabled, ensure it doesn't linger in persisted layouts. @@ -747,6 +780,28 @@ const RightSidebarComponent: React.FC = ({ // Terminal titles from OSC sequences (e.g., shell setting window title) // Persisted to localStorage so they survive reload const terminalTitlesKey = getTerminalTitlesKey(workspaceId); + + const [fileDirtyMap, setFileDirtyMap] = React.useState>(() => new Map()); + + React.useEffect(() => { + const draftTabs = (Object.entries(tabStates) as Array<[TabType, RightSidebarTabState]>) + .filter(([, state]) => state?.fileDraft !== undefined) + .map(([tab]) => tab); + if (draftTabs.length === 0) return; + setFileDirtyMap((prev) => { + let next = prev; + for (const tab of draftTabs) { + if (!next.has(tab)) { + if (next === prev) { + next = new Map(prev); + } + next.set(tab, true); + } + } + return next; + }); + }, [tabStates]); + const [terminalTitles, setTerminalTitles] = React.useState>(() => { const stored = readPersistedState>(terminalTitlesKey, {}); return new Map(Object.entries(stored) as Array<[TabType, string]>); @@ -793,6 +848,12 @@ const RightSidebarComponent: React.FC = ({ if (isFileTab(activeTab)) { e.preventDefault(); setLayout((prev) => removeTabEverywhere(prev, activeTab)); + setFileDirtyMap((prev) => { + if (!prev.has(activeTab)) return prev; + const next = new Map(prev); + next.delete(activeTab); + return next; + }); } }; @@ -950,6 +1011,85 @@ const RightSidebarComponent: React.FC = ({ [workspaceId, api, setLayout, terminalTitlesKey] ); + const handleFileDraftChange = React.useCallback( + (tab: TabType, content: string | null) => { + setLayout((prev) => { + const openTabs = collectAllTabs(prev.root); + if (!openTabs.includes(tab)) { + return prev; + } + const tabStates: RightSidebarTabStates = prev.tabStates ?? {}; + const currentState = tabStates[tab] ?? {}; + if (content === null) { + if (currentState.fileDraft === undefined) { + return prev; + } + const { fileDraft: _removedDraft, ...restState } = currentState; + if (Object.keys(restState).length === 0) { + const { [tab]: _removedState, ...rest } = tabStates; + return { ...prev, tabStates: rest }; + } + return { ...prev, tabStates: { ...tabStates, [tab]: restState } }; + } + if (currentState.fileDraft === content) return prev; + return { + ...prev, + tabStates: { + ...tabStates, + [tab]: { ...currentState, fileDraft: content }, + }, + }; + }); + }, + [setLayout] + ); + + const handleFileDraftHistoryChange = React.useCallback( + (tab: TabType, history: FileDraftHistory | null) => { + setLayout((prev) => { + const openTabs = collectAllTabs(prev.root); + if (!openTabs.includes(tab)) { + return prev; + } + const tabStates: RightSidebarTabStates = prev.tabStates ?? {}; + const currentState = tabStates[tab] ?? {}; + if (history === null) { + if (currentState.fileHistory === undefined) return prev; + const { fileHistory: _removedHistory, ...restState } = currentState; + if (Object.keys(restState).length === 0) { + const { [tab]: _removedState, ...rest } = tabStates; + return { ...prev, tabStates: rest }; + } + return { ...prev, tabStates: { ...tabStates, [tab]: restState } }; + } + if (currentState.fileHistory === history) return prev; + return { + ...prev, + tabStates: { + ...tabStates, + [tab]: { ...currentState, fileHistory: history }, + }, + }; + }); + }, + [setLayout] + ); + + const handleFileDirtyChange = React.useCallback((tab: TabType, dirty: boolean) => { + setFileDirtyMap((prev) => { + const hasEntry = prev.has(tab); + if (dirty && hasEntry) return prev; + if (!dirty && !hasEntry) return prev; + const next = new Map(prev); + if (dirty) { + next.set(tab, true); + } else { + next.delete(tab); + } + return next; + }); + }, []); + // Configure sensors with distance threshold for click vs drag disambiguation // Handler to open a file in a new tab @@ -983,6 +1123,12 @@ const RightSidebarComponent: React.FC = ({ const handleCloseFile = React.useCallback( (tab: TabType) => { setLayout((prev) => removeTabEverywhere(prev, tab)); + setFileDirtyMap((prev) => { + if (!prev.has(tab)) return prev; + const next = new Map(prev); + next.delete(tab); + return next; + }); }, [setLayout] ); @@ -1138,6 +1284,11 @@ const RightSidebarComponent: React.FC = ({ onTerminalTitleChange={handleTerminalTitleChange} tabPositions={tabPositions} autoFocusTerminalSession={autoFocusTerminalSession} + tabStates={tabStates} + onFileDraftHistoryChange={handleFileDraftHistoryChange} + onFileDraftChange={handleFileDraftChange} + fileDirtyMap={fileDirtyMap} + onFileDirtyChange={handleFileDirtyChange} onAutoFocusConsumed={() => setAutoFocusTerminalSession(null)} onOpenFile={handleOpenFile} onCloseFile={handleCloseFile} diff --git a/src/browser/components/RightSidebar/ExplorerTab.tsx b/src/browser/components/RightSidebar/ExplorerTab.tsx index e5cb6e4756..4eef26de07 100644 --- a/src/browser/components/RightSidebar/ExplorerTab.tsx +++ b/src/browser/components/RightSidebar/ExplorerTab.tsx @@ -17,10 +17,12 @@ import { FolderClosed, FolderOpen, RefreshCw, + Search, } from "lucide-react"; import { FileIcon } from "../FileIcon"; import { cn } from "@/common/lib/utils"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; +import { KEYBINDS, formatKeybind, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { validateRelativePath, @@ -56,6 +58,9 @@ const INDENT_PX = 12; export const ExplorerTab: React.FC = (props) => { const { api } = useAPI(); + const [searchQuery, setSearchQuery] = React.useState(""); + const searchInputRef = React.useRef(null); + const [state, setState] = React.useState({ entries: new Map(), expanded: new Set(), @@ -267,6 +272,19 @@ export const ExplorerTab: React.FC = (props) => { } }, [api, fetchDirectory]); + // Global search shortcut (Ctrl+F / Cmd+F) + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!matchesKeybind(e, KEYBINDS.FOCUS_EXPLORER_SEARCH)) return; + e.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + // Subscribe to file-modifying tool events and debounce refresh React.useEffect(() => { let timeoutId: ReturnType | null = null; @@ -318,6 +336,17 @@ export const ExplorerTab: React.FC = (props) => { void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p))); }; + // Search filtering + const normalizedQuery = searchQuery.trim().toLowerCase(); + const isSearchActive = normalizedQuery.length > 0; + + const matchesQuery = (node: FileTreeNode): boolean => { + if (!isSearchActive) return true; + const nameMatch = node.name.toLowerCase().includes(normalizedQuery); + const pathMatch = node.path.toLowerCase().includes(normalizedQuery); + return nameMatch || pathMatch; + }; + // Collapse all const handleCollapseAll = () => { setState((prev) => ({ @@ -329,11 +358,22 @@ export const ExplorerTab: React.FC = (props) => { const hasExpandedDirs = state.expanded.size > 0; // Render a tree node recursively - const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => { + const renderNode = (node: FileTreeNode, depth: number): React.ReactNode | null => { const key = node.path; const isExpanded = state.expanded.has(key); const isLoading = state.loading.has(key); const children = state.entries.get(key) ?? []; + const renderedChildren = node.isDirectory + ? children + .map((child) => renderNode(child, depth + 1)) + .filter((child): child is React.ReactNode => child !== null) + : []; + const matches = matchesQuery(node); + if (isSearchActive && !matches && renderedChildren.length === 0) { + return null; + } + const isExpandedForRender = + node.isDirectory && (isSearchActive ? renderedChildren.length > 0 : isExpanded); // Check both node.ignored flag and ignoredDirs cache const isIgnored = node.ignored === true || state.ignoredDirs.has(node.path); const isModified = state.gitStatus.modified.has(node.path); @@ -368,12 +408,12 @@ export const ExplorerTab: React.FC = (props) => { <> {isLoading ? ( - ) : isExpanded ? ( + ) : isExpandedForRender ? ( ) : ( )} - {isExpanded ? ( + {isExpandedForRender ? ( ) : ( @@ -390,15 +430,17 @@ export const ExplorerTab: React.FC = (props) => { - {node.isDirectory && isExpanded && ( -
{children.map((child) => renderNode(child, depth + 1))}
- )} + {node.isDirectory && isExpandedForRender &&
{renderedChildren}
} ); }; const rootEntries = state.entries.get("") ?? []; const isRootLoading = state.loading.has(""); + const renderedRootNodes = rootEntries + .map((node) => renderNode(node, 0)) + .filter((node): node is React.ReactNode => node !== null); + const emptyMessage = isSearchActive ? "No matching files" : "No files found"; // Shorten workspace path for display (replace home dir with ~) const shortenPath = (fullPath: string): string => { @@ -465,6 +507,18 @@ export const ExplorerTab: React.FC = (props) => { )} +
+ + setSearchQuery(event.target.value)} + placeholder={`Search files... (${formatKeybind(KEYBINDS.FOCUS_EXPLORER_SEARCH)})`} + className="bg-background text-foreground border-border-medium placeholder:text-dim hover:border-accent focus:border-accent min-w-0 flex-1 rounded border px-1.5 py-0.5 font-mono text-[11px] transition-[border-color] duration-150 focus:outline-none" + aria-label="Search files" + /> +
{/* Tree */}
@@ -474,10 +528,10 @@ export const ExplorerTab: React.FC = (props) => {
) : ( - rootEntries.map((node) => renderNode(node, 0)) + renderedRootNodes )} - {!isRootLoading && rootEntries.length === 0 && !state.error && ( -
No files found
+ {!isRootLoading && renderedRootNodes.length === 0 && !state.error && ( +
{emptyMessage}
)} diff --git a/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx b/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx index f7fb521c4c..c03e4b7623 100644 --- a/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx +++ b/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx @@ -1,27 +1,36 @@ /** * FileViewerTab - Main orchestrator for the file viewer pane. * Fetches file data via ORPC and routes to appropriate viewer component. - * Auto-refreshes on file-modifying tool completion (debounced). + * Surfaces a reload banner when file-modifying tools complete. */ import React from "react"; import { useAPI } from "@/browser/contexts/API"; import { workspaceStore } from "@/browser/stores/WorkspaceStore"; import { RefreshCw, AlertCircle } from "lucide-react"; -import { TextFileViewer } from "./TextFileViewer"; +import { TextFileEditor } from "./TextFileEditor"; import { ImageFileViewer } from "./ImageFileViewer"; +import type { FileDraftHistory } from "@/browser/utils/rightSidebarLayout"; import { validateRelativePath, buildReadFileScript, buildFileDiffScript, + buildWriteFileScript, processFileContents, EXIT_CODE_TOO_LARGE, + MAX_FILE_SIZE, + MAX_FILE_SIZE_LABEL, type FileContentsResult, } from "@/browser/utils/fileExplorer"; interface FileViewerTabProps { workspaceId: string; relativePath: string; + onDirtyChange?: (dirty: boolean) => void; + draftContent?: string | null; + draftHistory?: FileDraftHistory | null; + onDraftChange?: (content: string | null) => void; + onDraftHistoryChange?: (history: FileDraftHistory | null) => void; } interface LoadedData { @@ -29,42 +38,158 @@ interface LoadedData { diff: string | null; } -const DEBOUNCE_MS = 2000; +const DRAFT_DEBOUNCE_MS = 300; +const ENCODE_CHUNK_SIZE = 0x8000; + +const normalizeLineEndings = (content: string): string => content.replace(/\r\n/g, "\n"); + +interface DraftPersistenceParams { + draftContent?: string | null; + draftHistory?: FileDraftHistory | null; + relativePath: string; + onDraftChange?: (content: string | null) => void; + onDraftHistoryChange?: (history: FileDraftHistory | null) => void; +} + +const useDraftPersistence = (params: DraftPersistenceParams) => { + const { draftContent, draftHistory, relativePath, onDraftChange, onDraftHistoryChange } = params; + const draftRef = React.useRef(draftContent ?? null); + const draftHistoryRef = React.useRef(draftHistory ?? null); + const draftTimeoutRef = React.useRef | null>(null); + + const clearDraftTimeout = React.useCallback(() => { + if (draftTimeoutRef.current) { + clearTimeout(draftTimeoutRef.current); + draftTimeoutRef.current = null; + } + }, []); + + const clearDraft = React.useCallback(() => { + if (draftRef.current === null && !draftTimeoutRef.current && draftHistoryRef.current === null) { + return; + } + clearDraftTimeout(); + draftRef.current = null; + draftHistoryRef.current = null; + onDraftChange?.(null); + onDraftHistoryChange?.(null); + }, [clearDraftTimeout, onDraftChange, onDraftHistoryChange]); + + const scheduleDraftPersist = React.useCallback( + (content: string) => { + draftRef.current = content; + if (!onDraftChange && !onDraftHistoryChange) return; + clearDraftTimeout(); + draftTimeoutRef.current = setTimeout(() => { + draftTimeoutRef.current = null; + onDraftChange?.(draftRef.current); + onDraftHistoryChange?.(draftHistoryRef.current); + }, DRAFT_DEBOUNCE_MS); + }, + [clearDraftTimeout, onDraftChange, onDraftHistoryChange] + ); + + const setDraftHistory = React.useCallback((history: FileDraftHistory | null) => { + draftHistoryRef.current = history; + }, []); + + React.useEffect(() => { + draftRef.current = draftContent ?? null; + draftHistoryRef.current = draftHistory ?? null; + clearDraftTimeout(); + }, [clearDraftTimeout, draftContent, draftHistory, relativePath]); + + React.useEffect(() => { + return () => { + if (!onDraftChange && !onDraftHistoryChange) return; + if (draftTimeoutRef.current) { + clearTimeout(draftTimeoutRef.current); + draftTimeoutRef.current = null; + if (draftRef.current !== null) { + onDraftChange?.(draftRef.current); + onDraftHistoryChange?.(draftHistoryRef.current); + } + } + }; + }, [onDraftChange, onDraftHistoryChange]); + + return { draftRef, scheduleDraftPersist, clearDraft, setDraftHistory }; +}; +function encodeTextToBase64(content: string): { base64: string; size: number } { + const bytes = new TextEncoder().encode(content); + let binary = ""; + for (let i = 0; i < bytes.length; i += ENCODE_CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + ENCODE_CHUNK_SIZE); + binary += String.fromCharCode(...chunk); + } + return { base64: btoa(binary), size: bytes.length }; +} export const FileViewerTab: React.FC = (props) => { const { api } = useAPI(); + const { + workspaceId, + relativePath, + onDirtyChange, + draftContent, + draftHistory, + onDraftChange, + onDraftHistoryChange, + } = props; // Separate loading flag from loaded data - keeps content visible during refresh const [isLoading, setIsLoading] = React.useState(true); const [error, setError] = React.useState(null); const [loaded, setLoaded] = React.useState(null); - // Track which path the loaded data is for (to detect file switches) - // Using ref to avoid effect dep issues - we only read this to decide loading state + // Track which path/type the loaded data is for (to detect file switches). + // Using refs to avoid effect dep issues - we only read these to decide loading state. + const loadedTypeRef = React.useRef(null); const loadedPathRef = React.useRef(null); + const [contentVersion, setContentVersion] = React.useState(0); + const [pendingExternalChange, setPendingExternalChange] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const dirtyRef = React.useRef(false); + const lineEndingRef = React.useRef<"lf" | "crlf">("lf"); + + const { draftRef, scheduleDraftPersist, clearDraft, setDraftHistory } = useDraftPersistence({ + draftContent, + draftHistory, + relativePath, + onDraftChange, + onDraftHistoryChange, + }); + // Refresh counter to trigger re-fetch const [refreshCounter, setRefreshCounter] = React.useState(0); - // Subscribe to file-modifying tool events and debounce refresh + // Reset editor state when switching files React.useEffect(() => { - let timeoutId: ReturnType | null = null; + dirtyRef.current = false; + setPendingExternalChange(false); + setIsSaving(false); + setSaveError(null); + }, [relativePath]); + // Subscribe to file-modifying tool events: refresh non-text files, banner for text. + React.useEffect(() => { const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - setRefreshCounter((c) => c + 1); - }, DEBOUNCE_MS); - }, props.workspaceId); + if (loadedTypeRef.current && loadedTypeRef.current !== "text") { + setRefreshCounter((count) => count + 1); + return; + } + setPendingExternalChange(true); + }, workspaceId); return () => { unsubscribe(); - if (timeoutId) clearTimeout(timeoutId); }; - }, [props.workspaceId]); + }, [workspaceId]); React.useEffect(() => { if (!api) return; // Validate path before making request - const pathError = validateRelativePath(props.relativePath); + const pathError = validateRelativePath(relativePath); if (pathError) { setError(pathError); setIsLoading(false); @@ -72,7 +197,7 @@ export const FileViewerTab: React.FC = (props) => { } // Empty path is not valid for file viewing - if (!props.relativePath) { + if (!relativePath) { setError("No file selected"); setIsLoading(false); return; @@ -80,7 +205,7 @@ export const FileViewerTab: React.FC = (props) => { let cancelled = false; // Show loading spinner on initial load or when switching files, but not on refresh - const isSameFile = loadedPathRef.current === props.relativePath; + const isSameFile = loadedPathRef.current === relativePath; if (!isSameFile) { setIsLoading(true); } @@ -91,12 +216,12 @@ export const FileViewerTab: React.FC = (props) => { // Fetch file contents and diff in parallel via bash const [fileResult, diffResult] = await Promise.all([ api!.workspace.executeBash({ - workspaceId: props.workspaceId, - script: buildReadFileScript(props.relativePath), + workspaceId, + script: buildReadFileScript(relativePath), }), api!.workspace.executeBash({ - workspaceId: props.workspaceId, - script: buildFileDiffScript(props.relativePath), + workspaceId, + script: buildFileDiffScript(relativePath), }), ]); @@ -114,11 +239,18 @@ export const FileViewerTab: React.FC = (props) => { // Check for "too large" exit code (custom exit code from our script) if (bashResult.exitCode === EXIT_CODE_TOO_LARGE) { setLoaded({ - data: { type: "error", message: "File is too large to display. Maximum: 10 MB." }, + data: { + type: "error", + message: `File is too large to display. Maximum: ${MAX_FILE_SIZE_LABEL}.`, + }, diff: null, }); - loadedPathRef.current = props.relativePath; + loadedTypeRef.current = "error"; + loadedPathRef.current = relativePath; setIsLoading(false); + setSaveError(null); + setPendingExternalChange(false); + dirtyRef.current = false; return; } @@ -136,6 +268,10 @@ export const FileViewerTab: React.FC = (props) => { if (cancelled) return; + if (data.type === "text") { + lineEndingRef.current = data.content.includes("\r\n") ? "crlf" : "lf"; + } + // Diff is optional - don't fail if it errors let diff: string | null = null; if (diffResult.success && diffResult.data.success) { @@ -143,8 +279,24 @@ export const FileViewerTab: React.FC = (props) => { } setLoaded({ data, diff }); - loadedPathRef.current = props.relativePath; + loadedTypeRef.current = data.type; + loadedPathRef.current = relativePath; setIsLoading(false); + setSaveError(null); + setPendingExternalChange(false); + if (data.type === "text") { + const draftContent = draftRef.current; + const hasDraft = + draftContent !== null && + normalizeLineEndings(draftContent) !== normalizeLineEndings(data.content); + dirtyRef.current = hasDraft; + if (!hasDraft && (draftRef.current !== null || draftHistory !== null)) { + clearDraft(); + } + setContentVersion((version) => version + 1); + } else { + dirtyRef.current = false; + } } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : "Failed to load file"); @@ -157,10 +309,35 @@ export const FileViewerTab: React.FC = (props) => { return () => { cancelled = true; }; - }, [api, props.workspaceId, props.relativePath, refreshCounter]); + }, [api, workspaceId, relativePath, refreshCounter, draftRef, draftHistory, clearDraft]); + + const handleDirtyChange = (dirty: boolean) => { + dirtyRef.current = dirty; + if (!dirty) { + setSaveError(null); + clearDraft(); + } + onDirtyChange?.(dirty); + }; + + const handleHistoryChange = React.useCallback( + (nextHistory: FileDraftHistory | null) => { + setDraftHistory(nextHistory); + }, + [setDraftHistory] + ); + + const handleContentChange = React.useCallback( + (nextContent: string) => { + draftRef.current = nextContent; + if (!dirtyRef.current) return; + scheduleDraftPersist(nextContent); + }, + [draftRef, scheduleDraftPersist] + ); // Check if we have valid cached content for the current file - const hasValidCache = loaded && loadedPathRef.current === props.relativePath; + const hasValidCache = loaded && loadedPathRef.current === relativePath; // Show loading spinner only on initial load or file switch (no valid cached content) if (isLoading && !hasValidCache) { @@ -202,17 +379,107 @@ export const FileViewerTab: React.FC = (props) => { ); } - const handleRefresh = () => setRefreshCounter((c) => c + 1); + const handleDismissExternal = () => { + setPendingExternalChange(false); + }; + + const handleReloadExternal = () => { + setPendingExternalChange(false); + dirtyRef.current = false; + setSaveError(null); + clearDraft(); + setRefreshCounter((c) => c + 1); + }; + const handleRefresh = () => { + if (dirtyRef.current) { + setPendingExternalChange(true); + return; + } + setPendingExternalChange(false); + setRefreshCounter((c) => c + 1); + }; // Route to appropriate viewer if (data.type === "text") { + const handleSave = async (nextContent: string) => { + if (!api) { + setSaveError("API not available"); + return; + } + if (isSaving) return; + setIsSaving(true); + setSaveError(null); + + try { + const contentToWrite = + lineEndingRef.current === "crlf" ? nextContent.replace(/\n/g, "\r\n") : nextContent; + const { base64, size } = encodeTextToBase64(contentToWrite); + if (size > MAX_FILE_SIZE) { + setSaveError(`File is too large to save. Maximum: ${MAX_FILE_SIZE_LABEL}.`); + return; + } + const writeResult = await api.workspace.executeBash({ + workspaceId, + script: buildWriteFileScript(relativePath, base64), + }); + + if (!writeResult.success) { + setSaveError(writeResult.error ?? "Failed to save file"); + return; + } + + const bashResult = writeResult.data; + if (!bashResult.success) { + const errorMsg = bashResult.error ?? "Failed to save file"; + setSaveError(errorMsg.length > 128 ? errorMsg.slice(0, 128) + "..." : errorMsg); + return; + } + + let updatedDiff: string | null = null; + const diffResult = await api.workspace.executeBash({ + workspaceId, + script: buildFileDiffScript(relativePath), + }); + if (diffResult.success && diffResult.data.success) { + updatedDiff = diffResult.data.output; + } + + setLoaded({ + data: { type: "text", content: contentToWrite, size }, + diff: updatedDiff, + }); + loadedTypeRef.current = "text"; + loadedPathRef.current = relativePath; + setContentVersion((version) => version + 1); + dirtyRef.current = false; + setPendingExternalChange(false); + clearDraft(); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Failed to save file"); + } finally { + setIsSaving(false); + } + }; + return ( - ); } diff --git a/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx new file mode 100644 index 0000000000..3d412b0bf0 --- /dev/null +++ b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx @@ -0,0 +1,881 @@ +/** + * TextFileEditor - Editable CodeMirror-backed viewer for text files. + * Includes inline git diff indicators and save support. + */ + +import React from "react"; +import { parsePatch } from "diff"; +import { Check, Copy, Save, RefreshCw, Undo2, Redo2 } from "lucide-react"; +import { + defaultKeymap, + history, + historyField, + historyKeymap, + indentWithTab, + redo, + redoDepth, + undo, + undoDepth, +} from "@codemirror/commands"; +import { javascript } from "@codemirror/lang-javascript"; +import { css } from "@codemirror/lang-css"; +import { html } from "@codemirror/lang-html"; +import { json } from "@codemirror/lang-json"; +import { markdown } from "@codemirror/lang-markdown"; +import { python } from "@codemirror/lang-python"; +import { rust } from "@codemirror/lang-rust"; +import { go } from "@codemirror/lang-go"; +import { java } from "@codemirror/lang-java"; +import { sql } from "@codemirror/lang-sql"; +import { xml } from "@codemirror/lang-xml"; +import { yaml } from "@codemirror/lang-yaml"; +import { cpp } from "@codemirror/lang-cpp"; +import { + HighlightStyle, + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import { highlightSelectionMatches } from "@codemirror/search"; +import { php } from "@codemirror/lang-php"; +import type { Extension, Range } from "@codemirror/state"; +import { Compartment, EditorState, Prec, StateField, Text } from "@codemirror/state"; +import { + Decoration, + EditorView, + WidgetType, + keymap, + lineNumbers, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + drawSelection, + dropCursor, + rectangularSelection, +} from "@codemirror/view"; +import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; +import type { DecorationSet } from "@codemirror/view"; +import { useTheme } from "@/browser/contexts/ThemeContext"; +import type { FileDraftHistory } from "@/browser/utils/rightSidebarLayout"; +import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; +import { getLanguageFromPath, getLanguageDisplayName } from "@/common/utils/git/languageDetector"; + +interface TextFileEditorProps { + content: string; + /** Unsaved draft content to rehydrate (optional) */ + draftContent?: string | null; + /** Serialized history state to restore (optional) */ + draftHistory?: FileDraftHistory | null; + filePath: string; + size: number; + /** Git diff for uncommitted changes (null if no changes or error) */ + diff: string | null; + /** Bump when content should reset dirty state */ + contentVersion: number; + /** Save in-progress flag */ + isSaving?: boolean; + /** Save error message */ + saveError?: string | null; + /** Callback to refresh the file contents */ + onRefresh?: () => void; + /** Callback when editor dirty state changes */ + onDirtyChange?: (dirty: boolean) => void; + /** Callback when editor history changes */ + onHistoryChange?: (history: FileDraftHistory | null) => void; + /** Callback when editor content changes */ + onContentChange?: (content: string) => void; + /** Callback when save is requested */ + onSave?: (content: string) => Promise | void; + /** File changed on disk while dirty */ + externalChange?: boolean; + onReloadExternal?: () => void; + onDismissExternal?: () => void; +} + +interface RemovedLineMarker { + line: number; + oldLineNumber: number; + content: string; +} + +interface DiffHighlights { + addedLines: number[]; + removedLines: RemovedLineMarker[]; +} + +interface MinThemePalette { + foreground: string; + keyword: string; + string: string; + comment: string; + number: string; + variable: string; + function: string; + type: string; + tag: string; + attribute: string; + property: string; +} + +const MIN_DARK_COLORS: MinThemePalette = { + foreground: "#B392F0", + keyword: "#F97583", + string: "#9DB1C5", + comment: "#6B737C", + number: "#F8F8F8", + variable: "#79B8FF", + function: "#B392F0", + type: "#B392F0", + tag: "#FFAB70", + attribute: "#B392F0", + property: "#79B8FF", +}; + +const MIN_LIGHT_COLORS: MinThemePalette = { + foreground: "#24292E", + keyword: "#D32F2F", + string: "#2B5581", + comment: "#C2C3C5", + number: "#1976D2", + variable: "#1976D2", + function: "#6F42C1", + type: "#6F42C1", + tag: "#22863A", + attribute: "#6F42C1", + property: "#1976D2", +}; + +const createMinHighlightStyle = (colors: MinThemePalette): HighlightStyle => + HighlightStyle.define([ + { + tag: [tags.comment, tags.lineComment, tags.blockComment, tags.docComment], + color: colors.comment, + }, + { + tag: [ + tags.string, + tags.docString, + tags.character, + tags.attributeValue, + tags.special(tags.string), + tags.regexp, + ], + color: colors.string, + }, + { + tag: [tags.number, tags.integer, tags.float], + color: colors.number, + }, + { + tag: [tags.bool, tags.null, tags.atom, tags.unit], + color: colors.variable, + }, + { + tag: [ + tags.keyword, + tags.controlKeyword, + tags.definitionKeyword, + tags.moduleKeyword, + tags.modifier, + tags.operatorKeyword, + ], + color: colors.keyword, + }, + { + tag: [ + tags.operator, + tags.compareOperator, + tags.logicOperator, + tags.bitwiseOperator, + tags.arithmeticOperator, + tags.updateOperator, + tags.definitionOperator, + tags.typeOperator, + tags.controlOperator, + ], + color: colors.keyword, + }, + { + tag: [tags.variableName, tags.self, tags.special(tags.variableName)], + color: colors.variable, + }, + { + tag: [tags.propertyName], + color: colors.property, + }, + { + tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], + color: colors.function, + }, + { + tag: [tags.typeName, tags.className], + color: colors.type, + }, + { + tag: [tags.tagName], + color: colors.tag, + }, + { + tag: [tags.attributeName], + color: colors.attribute, + }, + { + tag: [tags.link, tags.url], + color: colors.string, + }, + ]); + +const MIN_DARK_HIGHLIGHT_STYLE = createMinHighlightStyle(MIN_DARK_COLORS); +const MIN_LIGHT_HIGHLIGHT_STYLE = createMinHighlightStyle(MIN_LIGHT_COLORS); + +const getMinThemePalette = (isDark: boolean): MinThemePalette => + isDark ? MIN_DARK_COLORS : MIN_LIGHT_COLORS; + +const getMinHighlightStyle = (isDark: boolean): HighlightStyle => + isDark ? MIN_DARK_HIGHLIGHT_STYLE : MIN_LIGHT_HIGHLIGHT_STYLE; +// Normalize line endings for consistent dirty tracking +const normalizeLineEndings = (content: string): string => content.replace(/\r\n/g, "\n"); + +// Format file size for display +const formatSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const baseExtensions: Extension = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + foldGutter(), + highlightActiveLine(), + highlightSelectionMatches(), + rectangularSelection(), + keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, indentWithTab]), +]; + +function getDocLineCount(doc: Text): number { + if (doc.lines === 0) return 0; + const lastLine = doc.line(doc.lines); + return lastLine.length === 0 ? Math.max(doc.lines - 1, 0) : doc.lines; +} + +function parseDiffHighlights(diffText: string | null): DiffHighlights { + if (!diffText) { + return { addedLines: [], removedLines: [] }; + } + + try { + const patches = parsePatch(diffText); + if (patches.length === 0) { + return { addedLines: [], removedLines: [] }; + } + + const addedLines: number[] = []; + const removedLines: RemovedLineMarker[] = []; + + for (const patch of patches) { + if (!patch.hunks) continue; + + for (const hunk of patch.hunks) { + let oldLineNumber = hunk.oldStart; + let newLineNumber = hunk.newStart; + + for (const line of hunk.lines) { + const prefix = line[0]; + const content = line.slice(1); + + if (prefix === "+") { + addedLines.push(newLineNumber); + newLineNumber += 1; + continue; + } + + if (prefix === "-") { + removedLines.push({ + line: newLineNumber, + oldLineNumber, + content, + }); + oldLineNumber += 1; + continue; + } + + if (prefix === " ") { + oldLineNumber += 1; + newLineNumber += 1; + } + } + } + } + + return { addedLines, removedLines }; + } catch { + return { addedLines: [], removedLines: [] }; + } +} + +class RemovedLineWidget extends WidgetType { + private readonly text: string; + private readonly oldLineNumber: number; + + constructor(text: string, oldLineNumber: number) { + super(); + this.text = text; + this.oldLineNumber = oldLineNumber; + } + + eq(other: RemovedLineWidget): boolean { + return other.text === this.text && other.oldLineNumber === this.oldLineNumber; + } + + toDOM(): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "cm-diff-removed-line"; + + const gutter = document.createElement("span"); + gutter.className = "cm-diff-removed-gutter"; + gutter.textContent = this.oldLineNumber ? String(this.oldLineNumber) : ""; + + const marker = document.createElement("span"); + marker.className = "cm-diff-removed-marker"; + marker.textContent = "−"; + + const content = document.createElement("span"); + content.className = "cm-diff-removed-content"; + content.textContent = this.text || "\u00A0"; + + wrapper.append(gutter, marker, content); + return wrapper; + } +} + +function createDiffDecorations(doc: Text, diff: DiffHighlights): DecorationSet { + const decorations: Array> = []; + + for (const lineNumber of diff.addedLines) { + if (lineNumber <= 0 || lineNumber > doc.lines) continue; + const line = doc.line(lineNumber); + decorations.push(Decoration.line({ class: "cm-diff-added-line" }).range(line.from)); + } + + for (const removedLine of diff.removedLines) { + const anchorLine = Math.min(Math.max(removedLine.line, 1), doc.lines || 1); + const line = doc.line(anchorLine); + const insertBefore = removedLine.line <= doc.lines; + const pos = insertBefore ? line.from : line.to; + decorations.push( + Decoration.widget({ + widget: new RemovedLineWidget(removedLine.content, removedLine.oldLineNumber), + side: insertBefore ? -1 : 1, + block: true, + }).range(pos) + ); + } + + return Decoration.set(decorations, true); +} + +function createDiffExtension(diffText: string | null): Extension { + const highlights = parseDiffHighlights(diffText); + if (highlights.addedLines.length === 0 && highlights.removedLines.length === 0) { + return []; + } + + const field = StateField.define({ + create(state) { + return createDiffDecorations(state.doc, highlights); + }, + update(decorations, transaction) { + return decorations.map(transaction.changes); + }, + provide: (field) => EditorView.decorations.from(field), + }); + + return [field]; +} + +function getLanguageExtension(language: string): Extension { + switch (language) { + case "typescript": + return javascript({ typescript: true }); + case "tsx": + return javascript({ typescript: true, jsx: true }); + case "javascript": + return javascript({ typescript: false }); + case "jsx": + return javascript({ typescript: false, jsx: true }); + case "html": + return html(); + case "css": + case "scss": + case "sass": + case "less": + return css(); + case "json": + return json(); + case "markdown": + return markdown(); + case "python": + return python(); + case "rust": + return rust(); + case "go": + return go(); + case "java": + return java(); + case "sql": + return sql(); + case "xml": + return xml(); + case "yaml": + return yaml(); + case "c": + case "cpp": + return cpp(); + case "php": + return php(); + default: + return []; + } +} + +function createEditorTheme(isDark: boolean, useNeutralForeground: boolean): Extension { + const palette = getMinThemePalette(isDark); + const baseForeground = useNeutralForeground ? "var(--color-foreground)" : palette.foreground; + + return [ + EditorView.theme( + { + "&": { + backgroundColor: "var(--color-code-bg)", + color: baseForeground, + height: "100%", + fontFamily: "var(--font-monospace)", + "--mux-editor-gutter-width": "3.5rem", + }, + ".cm-scroller": { + fontFamily: "var(--font-monospace)", + fontSize: "11px", + lineHeight: "1.6", + }, + ".cm-content": { + padding: "6px 0", + caretColor: baseForeground, + }, + ".cm-line": { + padding: "0 8px", + }, + ".cm-gutters": { + backgroundColor: "var(--color-line-number-bg)", + color: "var(--color-line-number-text)", + borderRight: "1px solid var(--color-line-number-border)", + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 8px 0 6px", + }, + ".cm-activeLine": { + backgroundColor: "color-mix(in srgb, var(--color-foreground) 6%, transparent)", + }, + ".cm-activeLineGutter": { + backgroundColor: "color-mix(in srgb, var(--color-foreground) 6%, transparent)", + }, + ".cm-selectionBackground": { + backgroundColor: "color-mix(in srgb, var(--color-accent) 35%, transparent)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "color-mix(in srgb, var(--color-accent) 45%, transparent)", + }, + ".cm-cursor": { + borderLeftColor: baseForeground, + }, + ".cm-diff-added-line": { + backgroundColor: "color-mix(in srgb, var(--color-success) 20%, transparent)", + }, + ".cm-diff-removed-line": { + display: "grid", + gridTemplateColumns: "var(--mux-editor-gutter-width) 16px 1fr", + alignItems: "center", + padding: "0 8px 0 0", + fontFamily: "var(--font-monospace)", + fontSize: "11px", + lineHeight: "1.6", + backgroundColor: "color-mix(in srgb, var(--color-danger) 18%, transparent)", + }, + ".cm-diff-removed-gutter": { + padding: "0 8px 0 6px", + textAlign: "right", + color: "var(--color-line-number-text)", + borderRight: "1px solid var(--color-line-number-border)", + }, + ".cm-diff-removed-marker": { + textAlign: "center", + color: "var(--color-danger)", + fontWeight: "600", + }, + ".cm-diff-removed-content": { + paddingLeft: "8px", + whiteSpace: "pre-wrap", + }, + }, + { dark: isDark } + ), + syntaxHighlighting(getMinHighlightStyle(isDark), { fallback: true }), + ]; +} + +export const TextFileEditor: React.FC = (props) => { + const { theme: themeMode } = useTheme(); + const { copied, copyToClipboard } = useCopyToClipboard(); + const normalizedBaseContent = normalizeLineEndings(props.content); + const draftContent = props.draftContent; + const normalizedInitialContent = + draftContent !== null && draftContent !== undefined + ? normalizeLineEndings(draftContent) + : normalizedBaseContent; + const language = getLanguageFromPath(props.filePath); + const languageDisplayName = getLanguageDisplayName(language); + const isMarkdown = language === "markdown"; + const isDark = themeMode !== "light" && !themeMode.endsWith("-light"); + + const editorRootRef = React.useRef(null); + const viewRef = React.useRef(null); + const baseDocRef = React.useRef(Text.of(normalizedBaseContent.split("\n"))); + const contentRef = React.useRef(normalizedInitialContent); + const dirtyRef = React.useRef(false); + const historyStateRef = React.useRef({ canUndo: false, canRedo: false }); + + const [canUndo, setCanUndo] = React.useState(false); + const [canRedo, setCanRedo] = React.useState(false); + const lineCountRef = React.useRef(getDocLineCount(Text.of(normalizedInitialContent.split("\n")))); + + const [lineCount, setLineCount] = React.useState(lineCountRef.current); + const [isDirty, setIsDirty] = React.useState(false); + + const themeCompartmentRef = React.useRef(new Compartment()); + const languageCompartmentRef = React.useRef(new Compartment()); + const diffCompartmentRef = React.useRef(new Compartment()); + + const themeExtensionRef = React.useRef(createEditorTheme(isDark, isMarkdown)); + const languageExtensionRef = React.useRef(getLanguageExtension(language)); + const diffExtensionRef = React.useRef(createDiffExtension(props.diff)); + + const callbacksRef = React.useRef({ + onDirtyChange: props.onDirtyChange, + onHistoryChange: props.onHistoryChange, + onContentChange: props.onContentChange, + onSave: props.onSave, + }); + + React.useEffect(() => { + callbacksRef.current = { + onDirtyChange: props.onDirtyChange, + onHistoryChange: props.onHistoryChange, + onContentChange: props.onContentChange, + onSave: props.onSave, + }; + }, [props.onContentChange, props.onDirtyChange, props.onHistoryChange, props.onSave]); + + const diffHighlights = parseDiffHighlights(props.diff); + const addedCount = diffHighlights.addedLines.length; + const removedCount = diffHighlights.removedLines.length; + + const syncGutterWidth = () => { + const view = viewRef.current; + if (!view) return; + const gutters = view.dom.querySelector(".cm-gutters"); + if (!(gutters instanceof HTMLElement)) return; + const width = gutters.getBoundingClientRect().width; + view.dom.style.setProperty("--mux-editor-gutter-width", `${width}px`); + }; + + const updateDirtyState = (nextDirty: boolean) => { + if (dirtyRef.current === nextDirty) return; + dirtyRef.current = nextDirty; + setIsDirty(nextDirty); + callbacksRef.current.onDirtyChange?.(nextDirty); + }; + + const updateHistoryState = (state: EditorState) => { + const nextCanUndo = undoDepth(state) > 0; + const nextCanRedo = redoDepth(state) > 0; + if (historyStateRef.current.canUndo !== nextCanUndo) { + historyStateRef.current.canUndo = nextCanUndo; + setCanUndo(nextCanUndo); + } + if (historyStateRef.current.canRedo !== nextCanRedo) { + historyStateRef.current.canRedo = nextCanRedo; + setCanRedo(nextCanRedo); + } + }; + + const handleUndo = () => { + const view = viewRef.current; + if (!view) return; + if (undo(view)) { + view.focus(); + } + }; + + const handleRedo = () => { + const view = viewRef.current; + if (!view) return; + if (redo(view)) { + view.focus(); + } + }; + + const updateLineCount = (doc: Text) => { + const nextCount = getDocLineCount(doc); + if (lineCountRef.current === nextCount) return; + lineCountRef.current = nextCount; + setLineCount(nextCount); + requestAnimationFrame(syncGutterWidth); + }; + + const requestSave = () => { + const onSave = callbacksRef.current.onSave; + if (!onSave) return true; + if (!dirtyRef.current) return true; + const content = contentRef.current; + const result = onSave(content); + if (result && typeof result.catch === "function") { + result.catch(() => undefined); + } + return true; + }; + + const createEditorState = (doc: string, historyState?: FileDraftHistory | null): EditorState => { + const updateListener = EditorView.updateListener.of((update) => { + updateHistoryState(update.state); + if (!update.docChanged) return; + const nextDoc = update.state.doc; + contentRef.current = nextDoc.toString(); + updateLineCount(nextDoc); + updateDirtyState(!nextDoc.eq(baseDocRef.current)); + callbacksRef.current.onContentChange?.(contentRef.current); + callbacksRef.current.onHistoryChange?.( + update.state.toJSON({ history: historyField }) as FileDraftHistory + ); + }); + + const saveKeymap = Prec.highest( + keymap.of([ + { + key: "Mod-s", + run: () => requestSave(), + }, + ]) + ); + + const extensions = [ + baseExtensions, + EditorView.lineWrapping, + saveKeymap, + updateListener, + themeCompartmentRef.current.of(themeExtensionRef.current), + languageCompartmentRef.current.of(languageExtensionRef.current), + diffCompartmentRef.current.of(diffExtensionRef.current), + ]; + + if (historyState && typeof historyState === "object") { + try { + const historyDoc = historyState.doc; + if (typeof historyDoc === "string" && normalizeLineEndings(historyDoc) === doc) { + return EditorState.fromJSON(historyState, { extensions }, { history: historyField }); + } + } catch { + // Fall back to fresh state if history cannot be restored. + } + } + + return EditorState.create({ + doc, + extensions, + }); + }; + + React.useEffect(() => { + if (!editorRootRef.current) return; + if (viewRef.current) return; + + const state = createEditorState(normalizedInitialContent, props.draftHistory); + const view = new EditorView({ state, parent: editorRootRef.current }); + viewRef.current = view; + updateLineCount(state.doc); + updateDirtyState(!state.doc.eq(baseDocRef.current)); + updateHistoryState(state); + requestAnimationFrame(syncGutterWidth); + + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- initialize editor once. + }, []); + + React.useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const normalizedBase = normalizeLineEndings(props.content); + const draftContentValue = props.draftContent; + const normalizedDraft = + draftContentValue !== null && draftContentValue !== undefined + ? normalizeLineEndings(draftContentValue) + : null; + const normalizedContent = normalizedDraft ?? normalizedBase; + baseDocRef.current = Text.of(normalizedBase.split("\n")); + contentRef.current = normalizedContent; + + const nextState = createEditorState(normalizedContent, props.draftHistory); + view.setState(nextState); + updateLineCount(nextState.doc); + updateDirtyState(!nextState.doc.eq(baseDocRef.current)); + updateHistoryState(nextState); + requestAnimationFrame(syncGutterWidth); + // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reinitializing on draft/history persistence. + }, [props.contentVersion, props.content]); + + React.useEffect(() => { + themeExtensionRef.current = createEditorTheme(isDark, isMarkdown); + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: themeCompartmentRef.current.reconfigure(themeExtensionRef.current), + }); + requestAnimationFrame(syncGutterWidth); + }, [isDark, isMarkdown]); + + React.useEffect(() => { + languageExtensionRef.current = getLanguageExtension(language); + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: languageCompartmentRef.current.reconfigure(languageExtensionRef.current), + }); + }, [language]); + + React.useEffect(() => { + diffExtensionRef.current = createDiffExtension(props.diff); + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: diffCompartmentRef.current.reconfigure(diffExtensionRef.current), + }); + }, [props.diff]); + + const undoKeybindLabel = formatKeybind(KEYBINDS.UNDO); + const redoKeybindLabel = formatKeybind(KEYBINDS.REDO); + const saveKeybindLabel = formatKeybind(KEYBINDS.SAVE_FILE); + + return ( +
+
+ + {props.filePath} + + +
+ {props.externalChange && ( +
+ File changed on disk. + + +
+ )} +
+
+
+ + {props.saveError && ( +
+ {props.saveError} +
+ )} + + {/* Status line */} +
+ {formatSize(props.size)} + {lineCount.toLocaleString()} lines + {(addedCount > 0 || removedCount > 0) && ( + + +{addedCount} + / + -{removedCount} + + )} + {isDirty && Unsaved} + {languageDisplayName} + + + {props.onSave && ( + + )} + {props.onRefresh && ( + + )} +
+
+ ); +}; diff --git a/src/browser/components/RightSidebar/FileViewer/TextFileViewer.tsx b/src/browser/components/RightSidebar/FileViewer/TextFileViewer.tsx deleted file mode 100644 index f294a2c790..0000000000 --- a/src/browser/components/RightSidebar/FileViewer/TextFileViewer.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/** - * TextFileViewer - Displays text file contents with syntax highlighting. - * Shows inline diff when there are uncommitted changes. - */ - -import React from "react"; -import { parsePatch } from "diff"; -import { RefreshCw } from "lucide-react"; -import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient"; -import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared"; -import { useTheme } from "@/browser/contexts/ThemeContext"; -import { getLanguageFromPath, getLanguageDisplayName } from "@/common/utils/git/languageDetector"; - -interface TextFileViewerProps { - content: string; - filePath: string; - size: number; - /** Git diff for uncommitted changes (null if no changes or error) */ - diff: string | null; - /** Callback to refresh the file contents */ - onRefresh?: () => void; -} - -// Format file size for display -const formatSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - -// Line type for unified view -type LineType = "normal" | "added" | "removed"; - -interface UnifiedLine { - type: LineType; - content: string; - oldLineNumber: number | null; // line number in old file (null for added lines) - newLineNumber: number | null; // line number in new file (null for removed lines) -} - -/** - * Build a unified view of the file with diff information. - * Returns lines with type annotations for coloring. - */ -function buildUnifiedView(content: string, diffText: string): UnifiedLine[] | null { - try { - const patches = parsePatch(diffText); - if (patches.length === 0) return null; - - const patch = patches[0]; - if (!patch.hunks || patch.hunks.length === 0) return null; - - const fileLines = content.split("\n"); - const result: UnifiedLine[] = []; - let newLineIdx = 0; // 0-based index into new file (current content) - let oldLineIdx = 0; // 0-based index into old file - - for (const hunk of patch.hunks) { - // Add unchanged lines before this hunk - const hunkStartInNew = hunk.newStart - 1; // 0-based - const hunkStartInOld = hunk.oldStart - 1; // 0-based - - // Lines before hunk exist in both old and new - while (newLineIdx < hunkStartInNew && newLineIdx < fileLines.length) { - result.push({ - type: "normal", - content: fileLines[newLineIdx], - oldLineNumber: oldLineIdx + 1, - newLineNumber: newLineIdx + 1, - }); - newLineIdx++; - oldLineIdx++; - } - - // Sync old line index to hunk start - oldLineIdx = hunkStartInOld; - - // Process hunk lines - for (const line of hunk.lines) { - const prefix = line[0]; - const lineContent = line.slice(1); - - if (prefix === "-") { - // Removed line - exists in old file only - result.push({ - type: "removed", - content: lineContent, - oldLineNumber: oldLineIdx + 1, - newLineNumber: null, - }); - oldLineIdx++; - } else if (prefix === "+") { - // Added line - exists in new file only - result.push({ - type: "added", - content: lineContent, - oldLineNumber: null, - newLineNumber: newLineIdx + 1, - }); - newLineIdx++; - } else if (prefix === " ") { - // Context line - exists in both - result.push({ - type: "normal", - content: lineContent, - oldLineNumber: oldLineIdx + 1, - newLineNumber: newLineIdx + 1, - }); - newLineIdx++; - oldLineIdx++; - } - // Skip other prefixes (like '\') - } - } - - // Add remaining lines after last hunk - while (newLineIdx < fileLines.length) { - const line = fileLines[newLineIdx]; - // Skip trailing empty line - if (newLineIdx === fileLines.length - 1 && line === "") { - break; - } - result.push({ - type: "normal", - content: line, - oldLineNumber: oldLineIdx + 1, - newLineNumber: newLineIdx + 1, - }); - newLineIdx++; - oldLineIdx++; - } - - return result; - } catch { - return null; - } -} - -export const TextFileViewer: React.FC = (props) => { - const { theme: themeMode } = useTheme(); - const language = getLanguageFromPath(props.filePath); - const languageDisplayName = getLanguageDisplayName(language); - - // Count lines - const fileLines = props.content.split("\n"); - const lineCount = fileLines.length - (fileLines[fileLines.length - 1] === "" ? 1 : 0); - - // Build unified view if we have a diff - const unifiedLines = React.useMemo(() => { - if (!props.diff) return null; - return buildUnifiedView(props.content, props.diff); - }, [props.content, props.diff]); - - // Syntax highlight all unique line contents - // Store highlighted lines by index to preserve context for repeated lines - const [highlightedLines, setHighlightedLines] = React.useState(null); - - React.useEffect(() => { - const linesToHighlight = unifiedLines - ? unifiedLines.map((l) => l.content) - : fileLines.filter((l, i, arr) => i < arr.length - 1 || l !== ""); - - const theme = themeMode === "light" || themeMode.endsWith("-light") ? "light" : "dark"; - - let cancelled = false; - - async function highlight() { - try { - const code = linesToHighlight.join("\n"); - const html = await highlightCode(code, language, theme); - if (cancelled) return; - - const highlighted = extractShikiLines(html); - setHighlightedLines(highlighted); - } catch { - if (!cancelled) setHighlightedLines(null); - } - } - - void highlight(); - return () => { - cancelled = true; - }; - }, [unifiedLines, fileLines, language, themeMode]); - - const addedCount = unifiedLines?.filter((l) => l.type === "added").length ?? 0; - const removedCount = unifiedLines?.filter((l) => l.type === "removed").length ?? 0; - - const hasDiff = unifiedLines !== null; - - // Render a single line (with one or two line number columns) - const renderLine = ( - content: string, - oldLineNum: number | null, - newLineNum: number | null, - type: LineType, - key: number - ) => { - const highlighted = highlightedLines?.[key]; - const bgClass = - type === "added" ? "bg-green-500/20" : type === "removed" ? "bg-red-500/20" : ""; - - return ( -
- {hasDiff ? ( - <> -
- {oldLineNum ?? ""} -
-
- {newLineNum ?? ""} -
- - ) : ( -
- {newLineNum ?? ""} -
- )} -
-
- ); - }; - - return ( -
-
-
- {unifiedLines - ? unifiedLines.map((line, idx) => - renderLine(line.content, line.oldLineNumber, line.newLineNumber, line.type, idx) - ) - : fileLines - .filter((_, i, arr) => i < arr.length - 1 || fileLines[i] !== "") - .map((content, idx) => renderLine(content, idx + 1, idx + 1, "normal", idx))} -
-
- - {/* Status line */} -
- {formatSize(props.size)} - {lineCount.toLocaleString()} lines - {(addedCount > 0 || removedCount > 0) && ( - - +{addedCount} - / - -{removedCount} - - )} - {languageDisplayName} - {props.onRefresh && ( - - )} -
-
- ); -}; diff --git a/src/browser/components/RightSidebar/FileViewer/index.ts b/src/browser/components/RightSidebar/FileViewer/index.ts index 5bb7097f95..6114c1fdee 100644 --- a/src/browser/components/RightSidebar/FileViewer/index.ts +++ b/src/browser/components/RightSidebar/FileViewer/index.ts @@ -3,5 +3,5 @@ */ export { FileViewerTab } from "./FileViewerTab"; -export { TextFileViewer } from "./TextFileViewer"; +export { TextFileEditor } from "./TextFileEditor"; export { ImageFileViewer } from "./ImageFileViewer"; diff --git a/src/browser/components/RightSidebar/tabs/TabLabels.tsx b/src/browser/components/RightSidebar/tabs/TabLabels.tsx index 26c33d9734..fed8ea9f52 100644 --- a/src/browser/components/RightSidebar/tabs/TabLabels.tsx +++ b/src/browser/components/RightSidebar/tabs/TabLabels.tsx @@ -74,12 +74,14 @@ export const ExplorerTabLabel: React.FC = () => ( interface FileTabLabelProps { /** File path (relative to workspace) */ filePath: string; + /** Whether the file has unsaved changes */ + isDirty?: boolean; /** Callback when close button is clicked */ onClose: () => void; } /** File tab label with file icon, filename, and close button */ -export const FileTabLabel: React.FC = ({ filePath, onClose }) => { +export const FileTabLabel: React.FC = ({ filePath, isDirty, onClose }) => { // Extract just the filename for display const fileName = filePath.split("/").pop() ?? filePath; @@ -89,6 +91,11 @@ export const FileTabLabel: React.FC = ({ filePath, onClose }) {fileName} + {isDirty && ( + + ● + + )}