From 63a195b59dc8c9156151fe256bc7211f71406762 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 09:54:48 -0600 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=A4=96=20feat:=20replace=20TextFile?= =?UTF-8?q?Viewer=20with=20editable=20CodeMirror=20TextFileEditor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the read-only TextFileViewer with a full CodeMirror 6 editor: - **Editable + save**: Full editing support with Cmd/Ctrl+S save keybind - **Inline diff**: Git diff shown as line decorations (added lines highlighted, removed lines rendered as widgets with old line numbers) - **Theme integration**: Uses CSS variables for consistent light/dark theming - **Dirty state handling**: Shows 'Unsaved' indicator, prompts on external file changes, prevents auto-refresh when dirty - **Save pipeline**: Base64-encoded writes via executeBash for safe handling Language support: JS/TS/JSX/TSX, HTML, CSS, JSON, Markdown, Python, Rust, Go, Java, SQL, XML, YAML, C/C++, PHP. ImageFileViewer unchanged. --- bun.lock | 158 +++++ package.json | 20 + .../RightSidebar/FileViewer/FileViewerTab.tsx | 135 +++- .../FileViewer/TextFileEditor.tsx | 577 ++++++++++++++++++ .../FileViewer/TextFileViewer.tsx | 268 -------- .../RightSidebar/FileViewer/index.ts | 2 +- .../Settings/sections/KeybindsSection.tsx | 3 +- src/browser/utils/fileExplorer.ts | 18 + src/browser/utils/ui/keybinds.ts | 3 + 9 files changed, 911 insertions(+), 273 deletions(-) create mode 100644 src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx delete mode 100644 src/browser/components/RightSidebar/FileViewer/TextFileViewer.tsx diff --git a/bun.lock b/bun.lock index 3e1bb084f8..5c179a4f1a 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,26 @@ "@ai-sdk/openai": "^2.0.76", "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", + "@codemirror/basic-setup": "^0.20.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", @@ -533,6 +553,50 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@0.20.3", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw=="], + + "@codemirror/basic-setup": ["@codemirror/basic-setup@0.20.0", "", { "dependencies": { "@codemirror/autocomplete": "^0.20.0", "@codemirror/commands": "^0.20.0", "@codemirror/language": "^0.20.0", "@codemirror/lint": "^0.20.0", "@codemirror/search": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0" } }, "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg=="], + + "@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@0.20.3", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.2", "crelt": "^1.0.5" } }, "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA=="], + + "@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 +857,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 +907,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 +1971,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 +3565,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 +3785,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=="], @@ -3787,6 +3891,48 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@codemirror/autocomplete/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], + + "@codemirror/autocomplete/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/autocomplete/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/basic-setup/@codemirror/commands": ["@codemirror/commands@0.20.0", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q=="], + + "@codemirror/basic-setup/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], + + "@codemirror/basic-setup/@codemirror/search": ["@codemirror/search@0.20.1", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "crelt": "^1.0.5" } }, "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q=="], + + "@codemirror/basic-setup/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/basic-setup/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + + "@codemirror/lang-css/@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/lang-go/@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/lang-html/@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/lang-javascript/@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/lang-javascript/@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/lang-markdown/@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/lang-python/@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/lang-sql/@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/lang-xml/@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/lang-yaml/@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/lint/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], + + "@codemirror/lint/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4331,6 +4477,18 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@codemirror/autocomplete/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], + + "@codemirror/autocomplete/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], + + "@codemirror/basic-setup/@codemirror/commands/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], + + "@codemirror/basic-setup/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], + "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], diff --git a/package.json b/package.json index 4f86a84fbc..47824167f0 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/basic-setup": "^0.20.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", diff --git a/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx b/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx index f7fb521c4c..3c73f060df 100644 --- a/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx +++ b/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx @@ -8,14 +8,16 @@ 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 { validateRelativePath, buildReadFileScript, buildFileDiffScript, + buildWriteFileScript, processFileContents, EXIT_CODE_TOO_LARGE, + MAX_FILE_SIZE, type FileContentsResult, } from "@/browser/utils/fileExplorer"; @@ -30,6 +32,17 @@ interface LoadedData { } const DEBOUNCE_MS = 2000; +const ENCODE_CHUNK_SIZE = 0x8000; + +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(); @@ -40,16 +53,34 @@ export const FileViewerTab: React.FC = (props) => { // 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 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); // Refresh counter to trigger re-fetch const [refreshCounter, setRefreshCounter] = React.useState(0); + // Reset editor state when switching files + React.useEffect(() => { + dirtyRef.current = false; + setPendingExternalChange(false); + setIsSaving(false); + setSaveError(null); + }, [props.relativePath]); + // Subscribe to file-modifying tool events and debounce refresh React.useEffect(() => { let timeoutId: ReturnType | null = null; const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => { + if (dirtyRef.current) { + setPendingExternalChange(true); + return; + } if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { + setPendingExternalChange(false); setRefreshCounter((c) => c + 1); }, DEBOUNCE_MS); }, props.workspaceId); @@ -119,6 +150,9 @@ export const FileViewerTab: React.FC = (props) => { }); loadedPathRef.current = props.relativePath; setIsLoading(false); + setSaveError(null); + setPendingExternalChange(false); + dirtyRef.current = false; return; } @@ -145,6 +179,12 @@ export const FileViewerTab: React.FC = (props) => { setLoaded({ data, diff }); loadedPathRef.current = props.relativePath; setIsLoading(false); + setSaveError(null); + setPendingExternalChange(false); + dirtyRef.current = false; + if (data.type === "text") { + setContentVersion((version) => version + 1); + } } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : "Failed to load file"); @@ -202,17 +242,106 @@ export const FileViewerTab: React.FC = (props) => { ); } - const handleRefresh = () => setRefreshCounter((c) => c + 1); + const handleDirtyChange = (dirty: boolean) => { + dirtyRef.current = dirty; + if (!dirty) { + setSaveError(null); + } + }; + + const handleDismissExternal = () => { + setPendingExternalChange(false); + }; + + const handleReloadExternal = () => { + setPendingExternalChange(false); + dirtyRef.current = false; + setSaveError(null); + 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 { base64, size } = encodeTextToBase64(nextContent); + if (size > MAX_FILE_SIZE) { + setSaveError("File is too large to save. Maximum: 10 MB."); + return; + } + + const writeResult = await api.workspace.executeBash({ + workspaceId: props.workspaceId, + script: buildWriteFileScript(props.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: props.workspaceId, + script: buildFileDiffScript(props.relativePath), + }); + if (diffResult.success && diffResult.data.success) { + updatedDiff = diffResult.data.output; + } + + setLoaded({ + data: { type: "text", content: nextContent, size }, + diff: updatedDiff, + }); + loadedPathRef.current = props.relativePath; + setContentVersion((version) => version + 1); + dirtyRef.current = false; + setPendingExternalChange(false); + } 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..b3d18f9f9f --- /dev/null +++ b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx @@ -0,0 +1,577 @@ +/** + * 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 { Save, RefreshCw } from "lucide-react"; +import { basicSetup } from "@codemirror/basic-setup"; +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 { 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 } from "@codemirror/view"; +import type { DecorationSet } from "@codemirror/view"; +import { useTheme } from "@/browser/contexts/ThemeContext"; +import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; +import { getLanguageFromPath, getLanguageDisplayName } from "@/common/utils/git/languageDetector"; + +interface TextFileEditorProps { + content: string; + 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 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[]; +} + +// 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`; +}; + +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): Extension { + return EditorView.theme( + { + "&": { + backgroundColor: "var(--color-code-bg)", + color: "var(--color-foreground)", + 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: "var(--color-foreground)", + }, + ".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: "var(--color-foreground)", + }, + ".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 } + ); +} + +export const TextFileEditor: React.FC = (props) => { + const { theme: themeMode } = useTheme(); + const language = getLanguageFromPath(props.filePath); + const languageDisplayName = getLanguageDisplayName(language); + const isDark = themeMode !== "light" && !themeMode.endsWith("-light"); + + const editorRootRef = React.useRef(null); + const viewRef = React.useRef(null); + const baseDocRef = React.useRef(Text.of(props.content.split("\n"))); + const contentRef = React.useRef(props.content); + const dirtyRef = React.useRef(false); + const lineCountRef = React.useRef(getDocLineCount(baseDocRef.current)); + + 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)); + const languageExtensionRef = React.useRef(getLanguageExtension(language)); + const diffExtensionRef = React.useRef(createDiffExtension(props.diff)); + + const callbacksRef = React.useRef({ + onDirtyChange: props.onDirtyChange, + onSave: props.onSave, + }); + + React.useEffect(() => { + callbacksRef.current = { + onDirtyChange: props.onDirtyChange, + onSave: props.onSave, + }; + }, [props.onDirtyChange, 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 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): EditorState => { + const updateListener = EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + const nextDoc = update.state.doc; + contentRef.current = nextDoc.toString(); + updateLineCount(nextDoc); + updateDirtyState(!nextDoc.eq(baseDocRef.current)); + }); + + const saveKeymap = Prec.highest( + keymap.of([ + { + key: "Mod-s", + run: () => requestSave(), + }, + ]) + ); + + return EditorState.create({ + doc, + extensions: [ + basicSetup, + EditorView.lineWrapping, + saveKeymap, + updateListener, + themeCompartmentRef.current.of(themeExtensionRef.current), + languageCompartmentRef.current.of(languageExtensionRef.current), + diffCompartmentRef.current.of(diffExtensionRef.current), + ], + }); + }; + + React.useEffect(() => { + if (!editorRootRef.current) return; + if (viewRef.current) return; + + const state = createEditorState(props.content); + const view = new EditorView({ state, parent: editorRootRef.current }); + viewRef.current = view; + updateLineCount(state.doc); + updateDirtyState(false); + 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; + + baseDocRef.current = Text.of(props.content.split("\n")); + contentRef.current = props.content; + + const nextState = createEditorState(props.content); + view.setState(nextState); + updateLineCount(nextState.doc); + updateDirtyState(false); + requestAnimationFrame(syncGutterWidth); + // eslint-disable-next-line react-hooks/exhaustive-deps -- rebase only on content changes. + }, [props.contentVersion, props.content]); + + React.useEffect(() => { + themeExtensionRef.current = createEditorTheme(isDark); + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: themeCompartmentRef.current.reconfigure(themeExtensionRef.current), + }); + requestAnimationFrame(syncGutterWidth); + }, [isDark]); + + 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 saveKeybindLabel = formatKeybind(KEYBINDS.SAVE_FILE); + + return ( +
+ {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/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx index 6553a28425..c9fa43e7a1 100644 --- a/src/browser/components/Settings/sections/KeybindsSection.tsx +++ b/src/browser/components/Settings/sections/KeybindsSection.tsx @@ -12,6 +12,7 @@ const KEYBIND_LABELS: Record = { CANCEL: "Cancel / Close modal", CANCEL_EDIT: "Cancel editing message", SAVE_EDIT: "Save edit", + SAVE_FILE: "Save file", INTERRUPT_STREAM_VIM: "Interrupt stream (Vim mode)", INTERRUPT_STREAM_NORMAL: "Interrupt stream", FOCUS_INPUT_I: "Focus input (i)", @@ -78,7 +79,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array }, { label: "Editing", - keys: ["SAVE_EDIT", "CANCEL_EDIT"], + keys: ["SAVE_EDIT", "SAVE_FILE", "CANCEL_EDIT"], }, { label: "Navigation", diff --git a/src/browser/utils/fileExplorer.ts b/src/browser/utils/fileExplorer.ts index 6ec401adff..86be36624e 100644 --- a/src/browser/utils/fileExplorer.ts +++ b/src/browser/utils/fileExplorer.ts @@ -314,6 +314,24 @@ echo "$size" base64 < ${file}`; } +/** + * Generate bash script to write base64-encoded content to a file. + * Uses a temp file to keep existing permissions when overwriting. + */ +export function buildWriteFileScript(relativePath: string, base64Content: string): string { + const file = shellEscape(relativePath); + const payload = shellEscape(base64Content); + return `tmp=$(mktemp) +if printf '%s' ${payload} | base64 --decode > "$tmp" 2>/dev/null; then + cat "$tmp" > ${file} + rm "$tmp" + exit 0 +fi +printf '%s' ${payload} | base64 -D > "$tmp" +cat "$tmp" > ${file} +rm "$tmp"`; +} + /** * Generate bash script to get git diff for a file. */ diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index e8e06c9a0a..fdf369a594 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -233,6 +233,9 @@ export const KEYBINDS = { /** Cancel editing message (exit edit mode) */ CANCEL_EDIT: { key: "Escape" }, + /** Save file */ + SAVE_FILE: { key: "s", ctrl: true, macCtrlBehavior: "command" }, + /** Save edit (Cmd/Ctrl+Enter) */ SAVE_EDIT: { key: "Enter", ctrl: true }, From 4c9e52e8d204b41e81068c979def57ef82a6b06e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 10:04:46 -0600 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=A4=96=20fix:=20align=20CodeMirror?= =?UTF-8?q?=20setup=20and=20add=20e2e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 61 +------------------ package.json | 1 - .../FileViewer/TextFileEditor.tsx | 45 +++++++++++++- tests/e2e/scenarios/fileEditor.spec.ts | 50 +++++++++++++++ 4 files changed, 94 insertions(+), 63 deletions(-) create mode 100644 tests/e2e/scenarios/fileEditor.spec.ts diff --git a/bun.lock b/bun.lock index 5c179a4f1a..d7e4966a7c 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,6 @@ "@ai-sdk/openai": "^2.0.76", "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", - "@codemirror/basic-setup": "^0.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -553,9 +552,7 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@0.20.3", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw=="], - - "@codemirror/basic-setup": ["@codemirror/basic-setup@0.20.0", "", { "dependencies": { "@codemirror/autocomplete": "^0.20.0", "@codemirror/commands": "^0.20.0", "@codemirror/language": "^0.20.0", "@codemirror/lint": "^0.20.0", "@codemirror/search": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0" } }, "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg=="], + "@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=="], @@ -589,7 +586,7 @@ "@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@0.20.3", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.2", "crelt": "^1.0.5" } }, "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA=="], + "@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=="], @@ -3891,48 +3888,6 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@codemirror/autocomplete/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], - - "@codemirror/autocomplete/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], - - "@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], - - "@codemirror/autocomplete/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], - - "@codemirror/basic-setup/@codemirror/commands": ["@codemirror/commands@0.20.0", "", { "dependencies": { "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0" } }, "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q=="], - - "@codemirror/basic-setup/@codemirror/language": ["@codemirror/language@0.20.2", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "@lezer/common": "^0.16.0", "@lezer/highlight": "^0.16.0", "@lezer/lr": "^0.16.0", "style-mod": "^4.0.0" } }, "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw=="], - - "@codemirror/basic-setup/@codemirror/search": ["@codemirror/search@0.20.1", "", { "dependencies": { "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.0", "crelt": "^1.0.5" } }, "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q=="], - - "@codemirror/basic-setup/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], - - "@codemirror/basic-setup/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], - - "@codemirror/lang-css/@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/lang-go/@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/lang-html/@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/lang-javascript/@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/lang-javascript/@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/lang-markdown/@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/lang-python/@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/lang-sql/@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/lang-xml/@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/lang-yaml/@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/lint/@codemirror/state": ["@codemirror/state@0.20.1", "", {}, "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ=="], - - "@codemirror/lint/@codemirror/view": ["@codemirror/view@0.20.7", "", { "dependencies": { "@codemirror/state": "^0.20.0", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } }, "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4477,18 +4432,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@codemirror/autocomplete/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], - - "@codemirror/autocomplete/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], - - "@codemirror/basic-setup/@codemirror/commands/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], - - "@codemirror/basic-setup/@codemirror/language/@lezer/common": ["@lezer/common@0.16.1", "", {}, "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA=="], - - "@codemirror/basic-setup/@codemirror/language/@lezer/highlight": ["@lezer/highlight@0.16.0", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ=="], - - "@codemirror/basic-setup/@codemirror/language/@lezer/lr": ["@lezer/lr@0.16.3", "", { "dependencies": { "@lezer/common": "^0.16.0" } }, "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw=="], - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], diff --git a/package.json b/package.json index 47824167f0..7e2d3850e1 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "@ai-sdk/openai": "^2.0.76", "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", - "@codemirror/basic-setup": "^0.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx index b3d18f9f9f..ee676f8170 100644 --- a/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx +++ b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx @@ -6,7 +6,7 @@ import React from "react"; import { parsePatch } from "diff"; import { Save, RefreshCw } from "lucide-react"; -import { basicSetup } from "@codemirror/basic-setup"; +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { javascript } from "@codemirror/lang-javascript"; import { css } from "@codemirror/lang-css"; import { html } from "@codemirror/lang-html"; @@ -20,10 +20,31 @@ 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 { + bracketMatching, + defaultHighlightStyle, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from "@codemirror/language"; +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 } from "@codemirror/view"; +import { + Decoration, + EditorView, + WidgetType, + keymap, + lineNumbers, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + drawSelection, + dropCursor, + rectangularSelection, +} from "@codemirror/view"; import type { DecorationSet } from "@codemirror/view"; import { useTheme } from "@/browser/contexts/ThemeContext"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; @@ -71,6 +92,24 @@ const formatSize = (bytes: number): string => { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; +const baseExtensions: Extension = [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + 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); @@ -432,7 +471,7 @@ export const TextFileEditor: React.FC = (props) => { return EditorState.create({ doc, extensions: [ - basicSetup, + baseExtensions, EditorView.lineWrapping, saveKeymap, updateListener, diff --git a/tests/e2e/scenarios/fileEditor.spec.ts b/tests/e2e/scenarios/fileEditor.spec.ts new file mode 100644 index 0000000000..29eb60e38d --- /dev/null +++ b/tests/e2e/scenarios/fileEditor.spec.ts @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; +import { electronTest as test, electronExpect as expect } from "../electronTest"; + +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +test("text file editor can edit and save files", async ({ page, ui, workspace }) => { + const fileName = "editor-save-test.txt"; + const filePath = path.join(workspace.demoProject.workspacePath, fileName); + const initialContent = "First line\nSecond line\n"; + const updatedContent = "First line\nSecond line\nAdded line\n"; + fs.writeFileSync(filePath, initialContent); + + await ui.projects.openFirstWorkspace(); + await ui.metaSidebar.expectVisible(); + await ui.metaSidebar.selectTab("Explorer"); + + const fileButton = page.getByRole("button", { name: fileName }); + await expect(fileButton).toBeVisible(); + await fileButton.click(); + + const viewer = page.getByTestId("text-file-viewer"); + await expect(viewer).toBeVisible(); + + const content = viewer.locator(".cm-content"); + await expect(content).toBeVisible(); + await content.click(); + await page.keyboard.press("Control+A"); + await page.keyboard.type(updatedContent, { delay: 10 }); + + await expect(viewer.getByText("Unsaved")).toBeVisible(); + + await page.keyboard.press("Control+S"); + + await expect(viewer.getByText("Unsaved")).toBeHidden({ timeout: 5000 }); + + let savedContent = ""; + for (let attempt = 0; attempt < 10; attempt += 1) { + savedContent = fs.readFileSync(filePath, "utf-8"); + if (savedContent.includes("Added line")) { + break; + } + await page.waitForTimeout(200); + } + + expect(savedContent).toBe(updatedContent); +}); From 136060755033e77b902990f3a0db5ceebadf0e87 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 10:06:20 -0600 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fail=20save=20scrip?= =?UTF-8?q?t=20on=20write=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/fileExplorer.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/browser/utils/fileExplorer.ts b/src/browser/utils/fileExplorer.ts index 86be36624e..cee9a2ce85 100644 --- a/src/browser/utils/fileExplorer.ts +++ b/src/browser/utils/fileExplorer.ts @@ -321,15 +321,19 @@ base64 < ${file}`; export function buildWriteFileScript(relativePath: string, base64Content: string): string { const file = shellEscape(relativePath); const payload = shellEscape(base64Content); - return `tmp=$(mktemp) + return `tmp=$(mktemp) || exit 1 +cleanup() { rm -f "$tmp"; } if printf '%s' ${payload} | base64 --decode > "$tmp" 2>/dev/null; then - cat "$tmp" > ${file} - rm "$tmp" + cat "$tmp" > ${file} || { cleanup; exit 1; } + cleanup exit 0 fi -printf '%s' ${payload} | base64 -D > "$tmp" -cat "$tmp" > ${file} -rm "$tmp"`; +if ! printf '%s' ${payload} | base64 -D > "$tmp"; then + cleanup + exit 1 +fi +cat "$tmp" > ${file} || { cleanup; exit 1; } +cleanup`; } /** From eff6505b28588934a28a45a8fd873cdcf0f9a389 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 10:30:41 -0600 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20editor=20dir?= =?UTF-8?q?ty=20indicators=20and=20explorer=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/RightSidebar.tsx | 49 ++++++++++++- .../components/RightSidebar/ExplorerTab.tsx | 72 ++++++++++++++++--- .../RightSidebar/FileViewer/FileViewerTab.tsx | 2 + .../FileViewer/TextFileEditor.tsx | 18 ++++- .../RightSidebar/tabs/TabLabels.tsx | 9 ++- .../Settings/sections/KeybindsSection.tsx | 5 ++ src/browser/utils/ui/keybinds.ts | 4 ++ 7 files changed, 146 insertions(+), 13 deletions(-) diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 2d63315661..18b3a527ec 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -222,6 +222,10 @@ 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; /** Handler to close a file tab */ onCloseFile: (tab: TabType) => void; } @@ -329,7 +333,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; } @@ -514,7 +524,11 @@ const RightSidebarTabsetNode: React.FC = (props) => hidden={!isActive} > {isActive && filePath && ( - + props.onFileDirtyChange(fileTab, dirty)} + /> )}
); @@ -747,6 +761,8 @@ 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()); + const [terminalTitles, setTerminalTitles] = React.useState>(() => { const stored = readPersistedState>(terminalTitlesKey, {}); return new Map(Object.entries(stored) as Array<[TabType, string]>); @@ -793,6 +809,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 +972,21 @@ const RightSidebarComponent: React.FC = ({ [workspaceId, api, setLayout, terminalTitlesKey] ); + 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 +1020,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 +1181,8 @@ const RightSidebarComponent: React.FC = ({ onTerminalTitleChange={handleTerminalTitleChange} tabPositions={tabPositions} autoFocusTerminalSession={autoFocusTerminalSession} + 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 3c73f060df..332606b620 100644 --- a/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx +++ b/src/browser/components/RightSidebar/FileViewer/FileViewerTab.tsx @@ -24,6 +24,7 @@ import { interface FileViewerTabProps { workspaceId: string; relativePath: string; + onDirtyChange?: (dirty: boolean) => void; } interface LoadedData { @@ -247,6 +248,7 @@ export const FileViewerTab: React.FC = (props) => { if (!dirty) { setSaveError(null); } + props.onDirtyChange?.(dirty); }; const handleDismissExternal = () => { diff --git a/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx index ee676f8170..3551810614 100644 --- a/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx +++ b/src/browser/components/RightSidebar/FileViewer/TextFileEditor.tsx @@ -5,7 +5,7 @@ import React from "react"; import { parsePatch } from "diff"; -import { Save, RefreshCw } from "lucide-react"; +import { Check, Copy, Save, RefreshCw } from "lucide-react"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { javascript } from "@codemirror/lang-javascript"; import { css } from "@codemirror/lang-css"; @@ -45,6 +45,7 @@ import { dropCursor, rectangularSelection, } from "@codemirror/view"; +import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import type { DecorationSet } from "@codemirror/view"; import { useTheme } from "@/browser/contexts/ThemeContext"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; @@ -376,6 +377,7 @@ function createEditorTheme(isDark: boolean): Extension { export const TextFileEditor: React.FC = (props) => { const { theme: themeMode } = useTheme(); + const { copied, copyToClipboard } = useCopyToClipboard(); const language = getLanguageFromPath(props.filePath); const languageDisplayName = getLanguageDisplayName(language); const isDark = themeMode !== "light" && !themeMode.endsWith("-light"); @@ -547,6 +549,20 @@ export const TextFileEditor: React.FC = (props) => { return (
+
+ + {props.filePath} + + +
{props.externalChange && (
File changed on disk. 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 && ( + + ● + + )} + {props.onSave && (