fix: session-cwd attach race, terminal failure surfacing, white-screen hardening; CodeMirror editor

- cwd resolution falls back (attached header → client summary cwd → process
  cwd) across the JSON API, media route and terminal upgrade, so explorer/
  git/terminal work from first paint instead of 'session ... has no working
  directory' while the web client hydrates the session (refresh-only fix).
- terminal client threads the summary cwd into the WS URL and stops the
  reconnect loop after three unreasoned failures, showing the close code
  with a manual retry; a 1011+reason refusal already showed the reason.
- white screen: persisted state is structurally sanitized on load (malformed
  shapes fall back to default) and the restored width is clamped to the
  viewport; the layout margin is capped at the viewport; the client entry
  wraps every lifecycle step in try/catch and pins a visible diagnostic bar
  on load/mount/interception failure instead of failing silently.
- editor: textarea replaced with CodeMirror 6 — line wrapping, per-extension
  syntax highlighting (js/ts/jsx/tsx, json, python, html, css, xml, yaml,
  sql, java, c/c++, rust, go, php, shell, toml, nginx, dockerfile,
  properties), one-dark theme, dirty dot + Ctrl/Cmd+S save kept.
- tests: +9 (cwd fallback chain over the API route, state sanitization,
  language mapping); 36 pass, typecheck + build + vm materialize clean.
This commit is contained in:
Menghuan1918
2026-08-08 15:12:56 +08:00
parent 8950e2ae69
commit 47ebffcf9b
17 changed files with 1063 additions and 140 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ control panel — **isolated per conversation session**.
- 右侧侧边栏(文件夹预览 / 文件编辑与预览 / 终端 / Git)——按会话记忆与隔离
- Explorer: 懒加载目录树,根目录 = 当前会话工作目录
- Editor: 文本编辑(脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
- Editor: CodeMirror 文本编辑(自动换行 + 按扩展名语法高亮、脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
- Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活)
- Git: status / diff / stage / commit / branch / history(基础集)
- 分栏工作台: VSCode 式拖拽分栏(拖 Tab 到分栏边缘即左右/上下拆分,拖到中间合并)、
@@ -24,9 +24,9 @@ control panel — **isolated per conversation session**.
| host | `src/index.ts` → `lib/index.js` | cordis 插件:`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| client | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 |
- 所有 API 携带 `sessionId`;cwd 从会话存储取权威值;终端按 `${sessionId}:${tabId}` 键控。
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话尚未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd(`session.cwd` / fs / git / 终端 / 媒体路由一致);终端按 `${sessionId}:${tabId}` 键控。
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)。
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`):面板几何、分栏树、Tabs、树展开。
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`):面板几何、分栏树、Tabs、树展开;读取时做结构校验(旧/损坏状态回退默认)并把宽度钳制到当前视口,防止陈旧全屏宽度压垮应用布局。
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm 等全部内联。
## 安装(web profile)
+21
View File
@@ -29,6 +29,27 @@
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@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.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.8",
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1",
"node-pty": "^1.1.0",
+456
View File
@@ -8,6 +8,69 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: ^6.10.4
version: 6.10.4
'@codemirror/lang-cpp':
specifier: ^6.0.3
version: 6.0.3
'@codemirror/lang-css':
specifier: ^6.3.1
version: 6.3.1
'@codemirror/lang-go':
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-html':
specifier: ^6.4.12
version: 6.4.12
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/lang-php':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-python':
specifier: ^6.2.1
version: 6.2.1
'@codemirror/lang-rust':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
'@codemirror/lang-xml':
specifier: ^6.1.0
version: 6.1.0
'@codemirror/lang-yaml':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/legacy-modes':
specifier: ^6.5.3
version: 6.5.3
'@codemirror/search':
specifier: ^6.7.1
version: 6.7.1
'@codemirror/state':
specifier: ^6.7.1
version: 6.7.1
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/view':
specifier: ^6.43.8
version: 6.43.8
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.4.0)
@@ -78,9 +141,129 @@ importers:
packages:
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.10.4':
resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.12':
resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-markdown@6.5.2':
resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/legacy-modes@6.5.3':
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.1':
resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
'@codemirror/state@6.7.1':
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.8':
resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/cpp@1.1.6':
resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
'@lezer/css@1.3.6':
resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@lezer/markdown@1.7.2':
resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.19':
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@oxc-project/types@0.143.0':
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
@@ -417,6 +600,9 @@ packages:
cosmokit@1.8.1:
resolution: {integrity: sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -644,6 +830,9 @@ packages:
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -796,6 +985,9 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -828,8 +1020,266 @@ packages:
snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.4':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/cpp': 1.1.6
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.12':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.4
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/json': 1.0.3
'@codemirror/lang-markdown@6.5.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/rust': 1.0.2
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
crelt: 1.0.7
'@codemirror/search@6.7.1':
dependencies:
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
crelt: 1.0.7
'@codemirror/state@6.7.1':
dependencies:
'@marijn/find-cluster-break': 1.0.3
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.1
'@codemirror/view': 6.43.8
'@lezer/highlight': 1.2.3
'@codemirror/view@6.43.8':
dependencies:
'@codemirror/state': 6.7.1
crelt: 1.0.7
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@jridgewell/sourcemap-codec@1.5.5': {}
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/css@1.3.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.2
'@lezer/markdown@1.7.2':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/python@1.1.19':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
'@marijn/find-cluster-break@1.0.3': {}
'@oxc-project/types@0.143.0': {}
'@quansync/fs@1.0.0':
@@ -1050,6 +1500,8 @@ snapshots:
cosmokit@1.8.1: {}
crelt@1.0.7: {}
csstype@3.2.3: {}
defu@6.1.7: {}
@@ -1224,6 +1676,8 @@ snapshots:
std-env@4.2.0: {}
style-mod@4.1.3: {}
tinybench@2.9.0: {}
tinyexec@1.3.0: {}
@@ -1311,6 +1765,8 @@ snapshots:
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
+79 -37
View File
@@ -1,46 +1,48 @@
/**
* The file editor tab: text editing with a dirty dot and Ctrl/Cmd+S save,
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, Markdown preview through the shared
* MarkdownText component, and a plain notice for binary files. Reads cap at
* the host's 512KB bound (a banner marks truncation).
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { oneDark } from '@codemirror/theme-one-dark'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, mediaUrl } from './api.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { languageForPath } from './lang.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
const MD_EXT = ['.md', '.markdown']
function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
export function EditorView(props: { sessionId: string; path: string; title: string }) {
const { sessionId, path, title } = props
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<CodeMirrorView | null>(null)
const savingRef = useRef(false)
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setDraft(null)
setDirty(false)
setSaveState('idle')
api.fsRead(sessionId, path).then((result) => {
api.fsRead(scope, path).then((result) => {
if (cancelled) return
const ext = extOf(path)
const ext = extOfPath(path)
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
@@ -60,20 +62,66 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [sessionId, path])
}, [scope.sessionId, scope.cwd, path])
// Create the CodeMirror editor once the text is loaded. The view owns the
// document; React only tracks dirty/save state through the update listener.
useEffect(() => {
if (load.status !== 'ready' || load.kind !== 'text') return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const state = EditorState.create({
doc: load.content,
extensions: [
CodeMirrorView.lineWrapping,
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
CodeMirrorView.theme({
'&': { height: '100%', fontSize: '13px', backgroundColor: 'transparent' },
'.cm-scroller': { overflow: 'auto', fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' },
'.cm-content': { caretColor: 'var(--dsw-alias-label-primary)' },
}),
...(language !== null ? [language] : []),
oneDark,
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) setDirty(true)
}),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => { save(); return true },
},
...defaultKeymap,
...historyKeymap,
]),
],
})
const view = new CodeMirrorView({ state, parent: host })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
}, [load])
const save = (): void => {
if (load.status !== 'ready' || load.kind !== 'text' || draft === null || saveState === 'saving') return
const view = viewRef.current
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(sessionId, path, draft).then(() => {
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDirty(false)
setSaveState('saved')
setDraft(null)
}).catch(() => {
savingRef.current = false
setSaveState('failed')
})
}
const dirty = load.status === 'ready' && load.kind === 'text' && draft !== null
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
@@ -100,26 +148,12 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
{load.status === 'ready' && load.kind === 'text' && (
<>
{load.truncated && <div className={css.editorBanner}>{t('truncation')}</div>}
<textarea
className={css.editorTextarea}
spellCheck={false}
value={draft ?? load.content}
onChange={(event) => {
setDraft(event.target.value)
setSaveState('idle')
}}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
save()
}
}}
/>
<div className={css.editorCm} ref={hostRef} />
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={mediaUrl(sessionId, path)} alt={title} />
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
</div>
)}
{load.status === 'ready' && load.kind === 'md' && (
@@ -130,3 +164,11 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
</div>
)
}
/** The lowercased file extension of a path ('' when none). */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
+2 -2
View File
@@ -45,12 +45,12 @@ export function ExplorerView(props: {
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
storeLevel(dir, {})
api.fsTree(sessionId, dir).then((listing) => {
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, storeLevel])
}, [sessionId, cwd, storeLevel])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
+16 -16
View File
@@ -11,7 +11,7 @@ import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult } from './api.ts'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -25,8 +25,8 @@ function badgeOf(entry: GitStatusEntry): string {
return '?'
}
export function GitView(props: { sessionId: string }) {
const { sessionId } = props
export function GitView(props: { scope: SessionScope }) {
const { scope } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -47,9 +47,9 @@ export function GitView(props: { sessionId: string }) {
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(sessionId),
api.gitBranch(sessionId).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(sessionId).catch(() => [] as GitLogEntry[]),
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
@@ -59,7 +59,7 @@ export function GitView(props: { sessionId: string }) {
} finally {
setLoading(false)
}
}, [sessionId])
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
@@ -69,12 +69,12 @@ export function GitView(props: { sessionId: string }) {
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(sessionId, 'HEAD', entry.path)
const oldPromise = api.gitShow(scope, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(sessionId, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(sessionId, entry.path)
? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(scope, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
@@ -85,8 +85,8 @@ export function GitView(props: { sessionId: string }) {
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId, entry.path)
else await api.gitStage(sessionId, entry.path)
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
setDiffPath(null)
await refresh()
} finally {
@@ -97,8 +97,8 @@ export function GitView(props: { sessionId: string }) {
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId)
else await api.gitStage(sessionId)
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
setDiffPath(null)
await refresh()
} finally {
@@ -112,7 +112,7 @@ export function GitView(props: { sessionId: string }) {
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(sessionId, message)
await api.gitCommit(scope, message)
setCommitMsg('')
setDiffPath(null)
await refresh()
@@ -128,7 +128,7 @@ export function GitView(props: { sessionId: string }) {
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(sessionId, branch)
await api.gitCheckout(scope, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
+29 -7
View File
@@ -27,6 +27,7 @@ import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
import css from './sidebar.module.css'
@@ -40,6 +41,7 @@ function TabContent(props: {
ctx: Context
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, ctx } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
return (
@@ -52,11 +54,11 @@ function TabContent(props: {
/>
)
case 'git':
return <GitView sessionId={sessionId} />
return <GitView scope={scope} />
case 'terminal':
return <TerminalView sessionId={sessionId} tabId={tab.id} />
return <TerminalView scope={scope} tabId={tab.id} />
case 'editor':
return <EditorView sessionId={sessionId} path={tab.path ?? ''} title={tab.title} />
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
}
}
@@ -95,7 +97,23 @@ export function Sidebar(props: { ctx: Context }) {
const state = snapshot.state
const sessionId = snapshot.sessionId
const cwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
@@ -103,9 +121,13 @@ export function Sidebar(props: { ctx: Context }) {
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. Dragging disables the layout transition.
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const width = snapshot.state?.panelOpen === true ? snapshot.state.width : 0
const width = snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
useEffect(() => {
@@ -215,7 +237,7 @@ export function Sidebar(props: { ctx: Context }) {
*/}
<div
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: state.width }}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
>
<div
+22 -5
View File
@@ -5,17 +5,22 @@
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, so the banner never spins forever.
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
export function TerminalView(props: { sessionId: string; tabId: string }) {
const { sessionId, tabId } = props
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
export function TerminalView(props: { scope: SessionScope; tabId: string }) {
const { scope, tabId } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
@@ -40,10 +45,12 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ sessionId, tab: tabId })
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${proto}//${location.host}/sidebar/ws/terminal?${params.toString()}`
}
@@ -57,6 +64,7 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
if (closed) return
socket = new WebSocket(wsUrl())
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
@@ -72,6 +80,15 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
@@ -109,7 +126,7 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
term.dispose()
connectRef.current = null
}
}, [sessionId, tabId])
}, [scope.sessionId, scope.cwd, tabId])
return (
<div className={css.terminalWrap}>
+48 -31
View File
@@ -1,7 +1,10 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId; failures surface as
* {@link SidebarApiError} with the wire error code.
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
/** One wire failure. */
@@ -71,37 +74,51 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
return parsed.value as T
}
/** The sidebar API surface (sessionId is threaded through every call). */
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (sessionId: string, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', { sessionId }, signal),
fsTree: (sessionId: string, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', { sessionId, path }, signal),
fsRead: (sessionId: string, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', { sessionId, path }, signal),
fsWrite: (sessionId: string, path: string, content: string) =>
call<{ ok: true }>('fs.write', { sessionId, path, content }),
gitStatus: (sessionId: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', { sessionId }, signal),
gitDiff: (sessionId: string, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', { sessionId, ...(path !== undefined ? { path } : {}), staged }, signal),
gitShow: (sessionId: string, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', { sessionId, rev, path }, signal),
gitStage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.stage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitUnstage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.unstage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitCommit: (sessionId: string, message: string) =>
call<{ ok: true }>('git.commit', { sessionId, message }),
gitBranch: (sessionId: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', { sessionId }, signal),
gitCheckout: (sessionId: string, branch: string) =>
call<{ ok: true }>('git.checkout', { sessionId, branch }),
gitLog: (sessionId: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', { sessionId }, signal),
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string) =>
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(sessionId: string, path: string): string {
return `/sidebar/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`
export function mediaUrl(scope: SessionScope, path: string): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `/sidebar/file?${params.toString()}`
}
+46 -14
View File
@@ -57,20 +57,52 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
ctx.effect(() => {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
return () => {
root.unmount()
host.remove()
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}, 'dsh-better-sidebar: sidebar mount')
}
try {
ctx.effect(() => {
try {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
return () => {
root.unmount()
host.remove()
}
} catch (error) {
fail('mount', error)
return undefined
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => registerTurnTailInterception(ctx),
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: turn-tail interception',
)
} catch (error) {
fail('load', error)
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Syntax highlighting for the file editor: extension → CodeMirror language
* mapping. The key derivation is pure and unit-tested; the factories pull in
* the CodeMirror language packages (bundled into the client).
*/
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { xml } from '@codemirror/lang-xml'
import { yaml } from '@codemirror/lang-yaml'
import { sql } from '@codemirror/lang-sql'
import { java } from '@codemirror/lang-java'
import { cpp } from '@codemirror/lang-cpp'
import { rust } from '@codemirror/lang-rust'
import { go } from '@codemirror/lang-go'
import { php } from '@codemirror/lang-php'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/** Language key for an extension, or null for plain text. Pure (tested). */
export function languageKeyForExt(ext: string): string | null {
switch (ext) {
case 'js': case 'mjs': case 'cjs': return 'js'
case 'jsx': return 'jsx'
case 'ts': case 'mts': case 'cts': return 'ts'
case 'tsx': return 'tsx'
case 'json': case 'jsonc': return 'json'
case 'py': case 'pyw': return 'python'
case 'html': case 'htm': return 'html'
case 'css': return 'css'
case 'xml': case 'xsl': return 'xml'
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
case 'go': return 'go'
case 'php': return 'php'
case 'sh': case 'bash': case 'zsh': return 'shell'
case 'toml': return 'toml'
case 'nginx': case 'conf': return 'nginx'
case 'dockerfile': case 'docker': return 'dockerfile'
case 'properties': case 'env': return 'properties'
default: return null
}
}
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
js: () => javascript({ jsx: true }),
jsx: () => javascript({ jsx: true }),
ts: () => javascript({ typescript: true }),
tsx: () => javascript({ typescript: true, jsx: true }),
json: () => json(),
python: () => python(),
html: () => html(),
css: () => css(),
xml: () => xml(),
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
go: () => go(),
php: () => php(),
shell: () => StreamLanguage.define(shell),
toml: () => StreamLanguage.define(toml),
nginx: () => StreamLanguage.define(nginx),
dockerfile: () => StreamLanguage.define(dockerFile),
properties: () => StreamLanguage.define(properties),
}
/** The CodeMirror language support for a path, or null for plain text. */
export function languageForPath(path: string): Language | LanguageSupport | null {
const key = languageKeyForExt(extOf(path))
return key === null ? null : FACTORIES[key]!()
}
+2
View File
@@ -21,6 +21,7 @@ const zh = {
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
refresh: '刷新',
save: '保存',
@@ -82,6 +83,7 @@ const en: Record<keyof typeof zh, string> = {
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
refresh: 'Refresh',
save: 'Save',
+12 -10
View File
@@ -538,19 +538,21 @@
background: var(--dsw-alias-state-warn-tertiary);
}
.editorTextarea {
.editorCm {
flex: 1;
min-height: 0;
width: 100%;
padding: 10px 12px;
border: none;
outline: none;
resize: none;
overflow: hidden;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code-block-small);
tab-size: 2;
white-space: pre;
}
/* CodeMirror fills the pane; the theme supplies its own token palette
(a designed dark theme, same policy as the xterm palette). */
.editorCm :global(.cm-editor) {
height: 100%;
}
.editorCm :global(.cm-editor.cm-focused) {
outline: none;
}
.editorImageWrap {
+82 -4
View File
@@ -366,10 +366,8 @@ function loadState(sessionId: string): SidebarState {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
if (raw !== null) {
const parsed = JSON.parse(raw) as SidebarState
if (parsed !== null && typeof parsed === 'object' && 'splits' in parsed && 'width' in parsed) {
return parsed
}
const sanitized = sanitizeState(JSON.parse(raw) as unknown)
if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
@@ -383,6 +381,86 @@ function loadState(sessionId: string): SidebarState {
return makeDefaultState(contentHalf)
}
/**
* Structural validation of one persisted state. A malformed or stale shape
* (older layouts, hand-edited storage) must fall back to the default instead
* of crashing the panel on every reload; the restored width is also clamped
* to the current viewport so a stale fullscreen width can never crush the
* app shell (margin-right larger than the window) or cover the whole screen.
* @returns a clean state, or undefined to fall back to the default.
*/
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.panelOpen !== 'boolean') return undefined
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
const splits = sanitizeNode(record.splits)
if (splits === undefined) return undefined
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
activePane: typeof record.activePane === 'string' ? record.activePane : null,
nextTerminal: record.nextTerminal,
expanded: record.expanded as string[],
splits,
}
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
const record = node as Record<string, unknown>
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
) {
return undefined
}
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
return { kind: 'leaf', id: record.id, tabs, active }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
const children: SplitNode[] = []
for (const child of record.children) {
const clean = sanitizeNode(child)
if (clean === undefined) return undefined
children.push(clean)
}
if (children.length < 2) return undefined
if (
record.sizes.length !== children.length
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
) {
return undefined
}
return { kind: 'split', id: record.id, dir: record.dir, sizes: record.sizes as number[], children }
}
return undefined
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()
+25 -9
View File
@@ -63,14 +63,28 @@ function trustedHostsOf(ctx: Context): string[] {
return []
}
/** Resolve a session's authoritative working directory or throw not-found. */
function sessionCwdOf(ctx: Context, sessionId: string): string {
/**
* Resolve a session's authoritative working directory. The attached session
* header wins; while the session is still hydrating from persistence (the
* web client attaches the current conversation a moment after page load, so
* the very first sidebar requests can arrive detached) the caller's own
* list-summary cwd is used; the process cwd is the last resort (blank
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
* explorer/git/terminal work from first paint instead of surfacing
* "session ... has no working directory".
*/
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
const session = ctx.sessions.get(sessionId)
const cwd = session?.header.cwd
if (cwd === undefined) {
throw new SidebarError('not-found', `session "${sessionId}" has no working directory`, 404)
const headerCwd = session?.header.cwd
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
if (clientCwd !== undefined && clientCwd !== '') {
try {
return requireAbsolute(clientCwd)
} catch {
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
}
}
return cwd
return process.cwd()
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
@@ -104,7 +118,9 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMethod> {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
const sessionId = requireString(payload, 'sessionId')
return { sessionId, cwd: sessionCwdOf(ctx, sessionId) }
const record = payload as { cwd?: unknown } | null
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
return {
'session.cwd': (payload) => {
@@ -256,7 +272,7 @@ export function apply(ctx: Context): void {
const sessionId = url.searchParams.get('sessionId')
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = sessionCwdOf(ctx, sessionId)
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = requireAbsolute(raw)
if (!path.startsWith(cwd)) {
// Only files under the session cwd are served as media (the editor
@@ -311,7 +327,7 @@ async function attachTerminal(
ws.close(1008, 'sessionId and tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId)
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Replay the transcript, then follow live output.
if (handle.transcript !== '') ws.send(handle.transcript)
+72 -1
View File
@@ -16,7 +16,7 @@ interface FakeContext {
register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => undefined }
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
effect: (fn: () => void | (() => void), label?: string) => void
}
@@ -126,3 +126,74 @@ describe('host plugin smoke', () => {
expect(listing.truncated).toBe(false)
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
}
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
// The vendored cordis runs registration effects immediately.
effect: (fn: () => void | (() => void)) => { fn() },
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
const invoke = async (route: SidebarWebRoute, payload: unknown): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: '/sidebar/api/session.cwd',
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { message: string } }
}
it('uses the client summary cwd while the session is detached', async () => {
const route = mount()
const result = await invoke(route, { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe('/tmp/summary-cwd')
})
it('falls back to the process cwd with no summary cwd', async () => {
const route = mount()
const result = await invoke(route, { sessionId: 's-unknown' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('prefers the attached session header over the client summary', async () => {
const route = mount({
sessions: {
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
},
})
const result = await invoke(route, { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe('/attached-cwd')
})
it('rejects a non-absolute client cwd', async () => {
const route = mount()
const result = await invoke(route, { sessionId: 's-detached', cwd: 'relative/path' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
})
+56 -1
View File
@@ -3,8 +3,9 @@ import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import {
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
resizeSplit, splitPane, toggleExpanded, type SidebarState, type SplitNode,
resizeSplit, sanitizeState, splitPane, toggleExpanded, type SidebarState, type SplitNode,
} from '../src/client/state.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
@@ -260,3 +261,57 @@ describe('produced-files derivation', () => {
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
describe('persisted state sanitization', () => {
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
const state = makeDefaultState(400)
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean).toEqual(state)
})
it('clamps undersized widths to the panel minimum', () => {
const state = { ...makeDefaultState(400), width: 10 }
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean?.width).toBe(280)
})
it('rejects malformed shapes instead of crashing the panel', () => {
expect(sanitizeState(null)).toBeUndefined()
expect(sanitizeState('nope')).toBeUndefined()
expect(sanitizeState({})).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
// A split whose sizes do not match its children is rejected.
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
expect(sanitizeState(withSplit)).toBeUndefined()
// Unknown tab types (older layouts) are rejected.
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadTab.splits.tabs[0].type = 'watcher'
expect(sanitizeState(withBadTab)).toBeUndefined()
// An active id that no tab carries is rejected.
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadActive.splits.active = 'ghost-tab'
expect(sanitizeState(withBadActive)).toBeUndefined()
})
})
describe('editor language mapping', () => {
it('derives extensions from paths', () => {
expect(extOf('/a/b/main.tsx')).toBe('tsx')
expect(extOf('README.MD')).toBe('md')
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
expect(extOf('noext')).toBe('')
})
it('maps common extensions to languages and falls back to plain text', () => {
expect(languageKeyForExt('tsx')).toBe('tsx')
expect(languageKeyForExt('js')).toBe('js')
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()
})
})