Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9153dfc1c | ||
|
|
bba7bb6073 | ||
|
|
eed40bb11a | ||
|
|
2cbcba8d11 | ||
|
|
6f6568d29f | ||
|
|
9c4a8270a7 | ||
|
|
e5f106b576 |
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/991c4b70-d45a-461f-a8c8-0a28b4218e60" />
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.16.0"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.16.1"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -255,6 +255,14 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.16.1
|
||||
|
||||
自 v0.16.0 以来的全部更改:
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🧊 **Git 面板卡死 + 重启死循环**([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376),修复 [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)):开启「源代码管理」面板可能整页冻结、重启后自动恢复冻结状态且无法退出——三层无上限操作叠加所致,现已全部设界:**① status 截断**——`git status --untracked-files=all` 响应上限 2000 条(超限置 `truncated`,面板显示截断提示,对齐 `fs.read` 截断语义;worktree 变更计数同步有界),海量未跟踪文件不再冻结浏览器主线程;**② 仓库发现限界**——cwd 非 Git 仓库(如家目录)时不再对每个可见子目录串行无界探测:探测超时 30s→5s、子目录探测上限 200 个、并发请求共享同一次扫描并按 60s TTL 缓存,家目录不再引发 `git rev-parse` 进程风暴;**③ 重置逃生通道**——带 `?dsh-sidebar-reset` 打开页面即丢弃持久化布局(含共享宽度)从默认布局启动,即使原页面已卡死也能自救,移除参数后恢复持久化;`statusTruncated` 文案同步全部 19 个词典
|
||||
|
||||
### v0.16.0
|
||||
|
||||
自 v0.15.2 以来的全部更改:
|
||||
@@ -280,6 +288,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
- 🔐 **文件 API 会话工作区边界**([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345),修复 [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)):`fs.tree / fs.read / fs.write` 的 workspace 越界访问修复;媒体、HTML 预览与上传统一 real-path 符号链接校验;新增绝对路径 / 符号链接 / 上传 / 嵌套 Git 会话回归测试
|
||||
- 🪟 **面板宿主层级与视口裁剪**([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278),修复 [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)):面板宿主层 z-index 40→25——低于 DSH cordis 动态插件面板 30,工作台不再遮挡 cordis 清单 / 审批面(AppFrame 20 之上、100+ 浮层之下);宿主 `overflow: hidden` 裁剪视口边缘,收起的面板不再把文档撑出双向滚动(实测 `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032,任意皮肤)
|
||||
- 📐 **布局推挤加固**([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):对话列补 `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere`(长不可断 URL / OAuth 链接不再把 composer 与左侧设置按钮挤出视口);layout-push effect 拆「仅设置 + 仅卸载移除」并按 `panelOpen` 门控宽度 push——右栏关闭时拖底部高度不再挤压对话区、松手瞬间不再整页右铺再回弹;`useLayoutEffect` 消除跨 paint 全宽闪帧;松手 flush 最终帧 + `centerRect.right` 同步提交;底部高度按 `viewportHeight - PANEL_MIN` 封顶;拖拽手柄拖动中不再高亮
|
||||
- 📱 **移动端无会话状态说明 + 1px 溢出修复**([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)):无会话时开关改用 `aria-disabled` 保持不可执行语义、同时允许触摸 / 键盘聚焦显示「选择一个会话以使用侧边栏」提示;panel 改 `border-box`——移动端 `100vw` 含左边框,不再产生 1px 横向溢出
|
||||
- 📏 **侧边栏宽度跨会话共享**([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)):面板宽度是布局偏好而非会话内容——「最后一次拖拽胜出」写入全局 `dsh-sidebar:v1:width`,缓存会话切换与新建会话即时跟随;无全局键(首次运行 / 旧会话)时行为逐字节不变
|
||||
- 🧹 **会话删除立即关闭该会话终端**([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)):新增 `PtyManager.closeSession()` + 订阅 DSH `session/disposed`——删除会话不再等 30s 重连宽限到期(agent 终端由 agent 生命周期管理,不受影响)
|
||||
- 🔍 **文件名搜索跳过噪声目录**([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)):`node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` 等黑名单(小写不敏感,`.git` 仍跳)——超大依赖树不再耗尽 10 万访问预算提前 `truncated`,`docs/` 等后序目录里的真实文件能搜到;不引入 `.gitignore` 语义,保持「文件名查找」
|
||||
@@ -289,7 +298,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
- 🧪 **trust-fence Origin 改按 hostname 比较**([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)):Edge 151 把非默认端口 loopback 页面的 Origin 序列化为无端口形式——`http://127.0.0.1` 对 `Host: 127.0.0.1:3080` 不再 403(对齐 DSH 官方网关栅栏);不同 hostname / opaque null origin 仍拒绝
|
||||
- 🪟 **「在文件夹中显示」改为资源管理器揭示**([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)):不再把目录当文件开进编辑器(`"..." is a directory`)——`revealInExplorer` 切到资源管理器 tab、面板折叠时自动展开、展开父目录并高亮滚动到本轮产出文件;产物行数据改读引擎 Turn deliverable(与 ui-deliverables 同源)
|
||||
- 🖱️ **面板拖动布局闪烁**([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)):右侧栏关闭时拖底部高度不再左移挤压对话区;松手瞬间不再整体右铺再回弹
|
||||
- 🖥️ **PowerShell 安装脚本修复**([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)):远程入口统一 `irm` 下载 → `.TrimStart([char]0xFEFF)` 去 BOM → `ScriptBlock.Create` 执行(`-Version` / `-DryRun` 参数在 Windows PowerShell 5.1 下恢复生效);安装前校验 `pnpm --version`(主版本 <10 时明确报错并以退出码 1 结束,不再写一半 profile)
|
||||
- 🖥️ **PowerShell 安装脚本修复**([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)):远程入口统一为「下载脚本 → 移除 UTF-8 BOM → 内存执行」,`-Version` / `-DryRun` 参数在 Windows PowerShell 5.1 下恢复生效(BOM 解析不再吃掉首行 `param(...)`);安装前校验 `pnpm --version`(主版本 <10 时明确报错并以退出码 1 结束,不再写一半 profile)
|
||||
- 🔄 **浏览器嵌入探测 GET 兜底**([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)):HEAD 响应同时缺 CSP 与 X-Frame-Options 时回退 GET 重试一次——阿里云百炼等只在 GET 回头发嵌入策略的站点不再显示误导性「拒绝连接请求」,而是正确显示「该站点拒绝嵌入」面板 + 「在浏览器中打开」
|
||||
- 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响)
|
||||
- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
|
||||
|
||||
+12
-3
@@ -89,7 +89,7 @@ If anything fails, check the troubleshooting table in the README at https://gith
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.16.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.16.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -130,7 +130,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.16.0"` and re-run `pnpm install`.
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.16.1"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -260,6 +260,14 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.16.1
|
||||
|
||||
All changes since v0.16.0:
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🧊 **Git panel freeze + restart loop** ([#376](https://github.com/omdsh-dev/DSH-better-sidebar/pull/376), fixes [#369](https://github.com/omdsh-dev/DSH-better-sidebar/issues/369)): opening the Source Control panel could freeze the whole page, and the frozen layout restored itself after every reload with no way out — three unbounded layers compounding, now all bounded: **① status truncation** — the `git status --untracked-files=all` response is capped at 2,000 entries (capped results carry `truncated` and the panel shows a notice, mirroring `fs.read`'s truncation semantics; worktree change counts inherit the bound), so a huge untracked set can no longer freeze the browser main thread; **② bounded repository discovery** — a non-repository cwd (e.g. the home directory) no longer probes every visible child directory serially without limit: probe timeout 30s→5s, at most 200 probed directories, concurrent requests share one in-flight scan plus a 60s TTL cache, ending the `git rev-parse` spawn storm under `~`; **③ reset escape hatch** — loading with `?dsh-sidebar-reset` drops the persisted layout (shared width included) and starts from the default, breaking the loop even when the page is already hung; persisting resumes once the param is gone; `statusTruncated` copy synced across all 19 dictionaries
|
||||
|
||||
### v0.16.0
|
||||
|
||||
All changes since v0.15.2:
|
||||
@@ -285,6 +293,7 @@ All changes since v0.15.2:
|
||||
- 🔐 **Session-workspace boundary for file APIs** ([#345](https://github.com/omdsh-dev/DSH-better-sidebar/pull/345), fixes [#328](https://github.com/omdsh-dev/DSH-better-sidebar/issues/328)): fixed `fs.tree / fs.read / fs.write` workspace escapes; media, HTML preview and uploads share real-path symlink validation; regression tests for absolute paths, symlinks, uploads and nested Git sessions
|
||||
- 🪟 **Panel-host layering and viewport clipping** ([#330](https://github.com/omdsh-dev/DSH-better-sidebar/pull/330) [#278](https://github.com/omdsh-dev/DSH-better-sidebar/pull/278), fixes [#277](https://github.com/omdsh-dev/DSH-better-sidebar/issues/277)): panel host z-index 40→25 — below the DSH cordis dynamic-plugin layer (30), so the workbench no longer covers cordis inventory/approval surfaces (still above AppFrame's 20 and below the 100+ float stack); `overflow: hidden` on the host clips at the viewport edge, so collapsed panels no longer stretch the document into bidirectional scrolling (measured `scrollWidth` 2289→1672 / `scrollHeight` 1280→1032, any skin)
|
||||
- 📐 **Layout-push hardening** ([#310](https://github.com/omdsh-dev/DSH-better-sidebar/pull/310) [#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130) [#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): conversation column gets `min-height: 0` + `overflow: hidden` + `overflow-wrap: anywhere` (long unbreakable URLs / OAuth links no longer push the composer and left-rail Settings out of the viewport); the layout-push effect is split into "set only" + "remove only on unmount" and the width push is gated on `panelOpen` — dragging the bottom height with the right panel closed no longer squeezes the conversation, and release no longer flashes full-width; `useLayoutEffect` removes cross-paint full-width frames; all three drags flush the final frame and sync `centerRect.right` before committing; bottom height caps at `viewportHeight - PANEL_MIN`; drag handles no longer highlight mid-drag
|
||||
- 📱 **Mobile: unavailable-sidebar clarity + 1px overflow** ([#254](https://github.com/omdsh-dev/DSH-better-sidebar/pull/254)): with no session the toggle uses `aria-disabled` — keeping the non-executable semantics while allowing touch/keyboard focus so the "select a session to use the sidebar" tooltip is reachable; the panel uses `border-box` so mobile `100vw` includes the left border and no longer causes 1px horizontal overflow
|
||||
- 📏 **Side-card width shared across sessions** ([#36](https://github.com/omdsh-dev/DSH-better-sidebar/pull/36)): panel width is a layout preference, not session content — "last drag wins" writes the global `dsh-sidebar:v1:width` key that fresh and cached session switches adopt; without a global key (first run / pre-existing sessions) behavior is byte-for-byte unchanged
|
||||
- 🧹 **Terminals close immediately on session delete** ([#130](https://github.com/omdsh-dev/DSH-better-sidebar/pull/130)): new `PtyManager.closeSession()` subscribing to DSH's `session/disposed` — deleted sessions no longer wait for the 30s reconnect grace to release their terminals (agent terminals are agent-lifecycle-owned and untouched)
|
||||
- 🔍 **Filename search skips noise directories** ([#342](https://github.com/omdsh-dev/DSH-better-sidebar/pull/342)): `node_modules` / `.pnpm-store` / `.yarn` / `.turbo` / `.next` / `dist` / `build` / `coverage` etc. (case-insensitive, `.git` still skipped) — huge dependency trees no longer burn the 100k visit budget and return `truncated` early, so real files in later directories (`docs/`) are found; no `.gitignore` semantics, still filename lookup
|
||||
@@ -294,7 +303,7 @@ All changes since v0.15.2:
|
||||
- 🧪 **trust-fence Origin compared by hostname** ([#182](https://github.com/omdsh-dev/DSH-better-sidebar/pull/182)): some Chromium builds (Edge 151) serialize the Origin of non-default-port loopback pages without the port — `http://127.0.0.1` against `Host: 127.0.0.1:3080` no longer 403s (mirrors DSH's official gateway fence); different hostnames and opaque null origins are still rejected
|
||||
- 🪟 **"Show in folder" now reveals in the explorer** ([#94](https://github.com/omdsh-dev/DSH-better-sidebar/pull/94)): the folder-reveal gesture no longer opens the directory as a file in the editor (`"..." is a directory`) — `revealInExplorer` switches to the explorer tab, auto-expands a collapsed panel, expands the parent folder and highlights/scrolls to the produced file; the produced-files selector now reads the engine's Turn deliverables (same source as ui-deliverables)
|
||||
- 🖱️ **Panel-drag layout flash fixed** ([#180](https://github.com/omdsh-dev/DSH-better-sidebar/pull/180)): dragging the bottom height with the right panel closed no longer shifts the conversation left; release no longer flashes full-width before snapping back
|
||||
- 🖥️ **PowerShell installer fixes** ([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)): remote entry unified as `irm` download → `.TrimStart([char]0xFEFF)` BOM removal → `ScriptBlock.Create` execution (`-Version` / `-DryRun` work again under Windows PowerShell 5.1); `pnpm --version` is checked before touching the profile (major <10 fails with a clear error and exit code 1)
|
||||
- 🖥️ **PowerShell installer fixes** ([#47](https://github.com/omdsh-dev/DSH-better-sidebar/pull/47)): the remote entry is now "download the script → strip the UTF-8 BOM → run it in memory", so `-Version` / `-DryRun` work again under Windows PowerShell 5.1 (the BOM no longer eats the leading `param(...)`); `pnpm --version` is checked before touching the profile (major <10 fails with a clear error and exit code 1)
|
||||
- 🔄 **Browser embed probing GET fallback** ([#69](https://github.com/omdsh-dev/DSH-better-sidebar/pull/69)): when a HEAD response omits both `Content-Security-Policy` and `X-Frame-Options`, probe retries once with GET — sites like Alibaba Bailian that only advertise their embed policy on GET no longer show a misleading "refused to connect"; they get the friendly "site refuses embedding" panel with an "open in browser" button
|
||||
- 🔧 **git-source installs fixed via `unrun` devDependency** ([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)): tsdown 0.22 loads its config through `unrun` and pnpm 11 does not auto-install peer deps — git-hosted `prepare` no longer fails with `Failed to import module "unrun"` (npm tarballs unaffected)
|
||||
- 🍃 **Strict `ctx.effect` also fixed 4 latent sites**: interception / IME-guard effect bodies returned `undefined` on failure instead of a disposer (invalid shape under the vendored cordis effect contract) — now a no-op disposer
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.1",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
@@ -471,6 +471,9 @@ export function GitView(props: {
|
||||
|
||||
{status !== null && status.isRepo && (
|
||||
<>
|
||||
{status.truncated === true && (
|
||||
<div className={css.gitEmpty}>{t('statusTruncated')}</div>
|
||||
)}
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('staged')} ({stagedEntries.length})</span>
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
/** True when the host capped `entries` (huge untracked set); the panel
|
||||
* shows a truncation notice instead of freezing (#369). */
|
||||
truncated?: boolean
|
||||
root?: string
|
||||
repositories?: string[]
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export const ar: Record<string, string> = {
|
||||
splitDown: 'تقسيم لأسفل',
|
||||
notRepo: 'هذا الدليل ليس مستودع git',
|
||||
noChanges: 'لا تغييرات',
|
||||
statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
|
||||
stage: 'إدراج',
|
||||
unstage: 'إلغاء الإدراج',
|
||||
stageAll: 'إدراج الكل',
|
||||
|
||||
@@ -90,6 +90,7 @@ export const de: Record<string, string> = {
|
||||
splitDown: 'Nach unten teilen',
|
||||
notRepo: 'Dieses Verzeichnis ist kein Git-Repository',
|
||||
noChanges: 'Keine Änderungen',
|
||||
statusTruncated: 'Zu viele Änderungen; nur die ersten 2000 Einträge werden angezeigt',
|
||||
stage: 'Stagen',
|
||||
unstage: 'Unstagen',
|
||||
stageAll: 'Alle stagen',
|
||||
|
||||
@@ -97,6 +97,7 @@ export const fr: Record<string, string> = {
|
||||
splitDown: 'Diviser en bas',
|
||||
notRepo: 'Ce répertoire n’est pas un dépôt git',
|
||||
noChanges: 'Aucune modification',
|
||||
statusTruncated: 'Trop de modifications ; seules les 2000 premières entrées sont affichées',
|
||||
stage: 'Mettre en scène',
|
||||
unstage: 'Annuler la mise en scène',
|
||||
stageAll: 'Tout mettre en scène',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const hi: Record<string, string> = {
|
||||
splitDown: 'नीचे स्प्लिट',
|
||||
notRepo: 'यह डायरेक्टरी एक git रिपॉज़िटरी नहीं है',
|
||||
noChanges: 'कोई बदलाव नहीं',
|
||||
statusTruncated: 'बहुत अधिक बदलाव; केवल पहली 2000 प्रविष्टियाँ दिखाई जा रही हैं',
|
||||
stage: 'स्टेज',
|
||||
unstage: 'अनस्टेज',
|
||||
stageAll: 'सभी स्टेज करें',
|
||||
|
||||
@@ -102,6 +102,7 @@ export const id: Record<string, string> = {
|
||||
splitDown: 'Bagi ke bawah',
|
||||
notRepo: 'Direktori ini bukan repositori git',
|
||||
noChanges: 'Tidak ada perubahan',
|
||||
statusTruncated: 'Terlalu banyak perubahan; hanya 2000 entri pertama yang ditampilkan',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage semua',
|
||||
|
||||
@@ -95,6 +95,7 @@ export const it: Record<string, string> = {
|
||||
splitDown: 'Dividi in basso',
|
||||
notRepo: 'Questa directory non è un repository git',
|
||||
noChanges: 'Nessuna modifica',
|
||||
statusTruncated: 'Troppe modifiche; vengono mostrate solo le prime 2000 voci',
|
||||
stage: 'Prepara',
|
||||
unstage: 'Rimuovi dalla preparazione',
|
||||
stageAll: 'Prepara tutto',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ja: Record<string, string> = {
|
||||
splitDown: '下に分割',
|
||||
notRepo: 'このディレクトリは git リポジトリではありません',
|
||||
noChanges: '変更はありません',
|
||||
statusTruncated: '変更が多すぎるため、最初の 2000 件のみ表示しています',
|
||||
stage: 'ステージ',
|
||||
unstage: 'ステージ解除',
|
||||
stageAll: 'すべてステージ',
|
||||
|
||||
@@ -96,6 +96,7 @@ export const ko: Record<string, string> = {
|
||||
splitDown: '아래로 분할',
|
||||
notRepo: '현재 디렉터리는 git 저장소가 아닙니다',
|
||||
noChanges: '변경 사항 없음',
|
||||
statusTruncated: '변경 사항이 너무 많아 처음 2000개만 표시합니다',
|
||||
stage: '스테이징',
|
||||
unstage: '스테이징 해제',
|
||||
stageAll: '모두 스테이징',
|
||||
|
||||
@@ -102,6 +102,7 @@ export const nl: Record<string, string> = {
|
||||
splitDown: 'Splitsen omlaag',
|
||||
notRepo: 'Deze map is geen git-repository',
|
||||
noChanges: 'Geen wijzigingen',
|
||||
statusTruncated: 'Te veel wijzigingen; alleen de eerste 2000 items worden weergegeven',
|
||||
stage: 'Stagen',
|
||||
unstage: 'Unstagen',
|
||||
stageAll: 'Alles stagen',
|
||||
|
||||
@@ -106,6 +106,7 @@ export const pl: Record<string, string> = {
|
||||
splitDown: 'Podziel w dół',
|
||||
notRepo: 'Ten katalog nie jest repozytorium git',
|
||||
noChanges: 'Brak zmian',
|
||||
statusTruncated: 'Zbyt wiele zmian; pokazywane jest tylko pierwsze 2000 wpisów',
|
||||
stage: 'Dodaj do indeksu',
|
||||
unstage: 'Usuń z indeksu',
|
||||
stageAll: 'Dodaj wszystko do indeksu',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const pt: Record<string, string> = {
|
||||
splitDown: 'Dividir abaixo',
|
||||
notRepo: 'Este diretório não é um repositório git',
|
||||
noChanges: 'Sem alterações',
|
||||
statusTruncated: 'Muitas alterações; exibindo apenas as primeiras 2000 entradas',
|
||||
stage: 'Preparar (stage)',
|
||||
unstage: 'Desfazer stage',
|
||||
stageAll: 'Preparar tudo',
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ru: Record<string, string> = {
|
||||
splitDown: 'Разделить вниз',
|
||||
notRepo: 'Этот каталог не является git-репозиторием',
|
||||
noChanges: 'Нет изменений',
|
||||
statusTruncated: 'Слишком много изменений; показаны только первые 2000 записей',
|
||||
stage: 'Индексировать',
|
||||
unstage: 'Убрать из индекса',
|
||||
stageAll: 'Индексировать всё',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const sv: Record<string, string> = {
|
||||
splitDown: 'Dela ner',
|
||||
notRepo: 'Denna katalog är inte ett Git-arkiv',
|
||||
noChanges: 'Inga ändringar',
|
||||
statusTruncated: 'För många ändringar; endast de första 2000 posterna visas',
|
||||
stage: 'Köa',
|
||||
unstage: 'Avköa',
|
||||
stageAll: 'Köa alla',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const th: Record<string, string> = {
|
||||
splitDown: 'แยกลง',
|
||||
notRepo: 'ไดเรกทอรีนี้ไม่ใช่ git repository',
|
||||
noChanges: 'ไม่มีการเปลี่ยนแปลง',
|
||||
statusTruncated: 'การเปลี่ยนแปลงมากเกินไป แสดงเพียง 2000 รายการแรก',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage ทั้งหมด',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const tr: Record<string, string> = {
|
||||
splitDown: 'Aşağı böl',
|
||||
notRepo: 'Bu dizin bir git deposu değil',
|
||||
noChanges: 'Değişiklik yok',
|
||||
statusTruncated: 'Çok fazla değişiklik var; yalnızca ilk 2000 giriş gösteriliyor',
|
||||
stage: 'Sahnele',
|
||||
unstage: 'Sahnelemeden kaldır',
|
||||
stageAll: 'Tümünü sahnele',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const vi: Record<string, string> = {
|
||||
splitDown: 'Chia xuống dưới',
|
||||
notRepo: 'Thư mục hiện tại không phải repo git',
|
||||
noChanges: 'Không có thay đổi',
|
||||
statusTruncated: 'Quá nhiều thay đổi; chỉ hiển thị 2000 mục đầu tiên',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage tất cả',
|
||||
|
||||
@@ -119,6 +119,7 @@ export const zhHK: Record<string, string> = {
|
||||
splitDown: '向下分欄',
|
||||
notRepo: '目前目錄不是 git 倉庫',
|
||||
noChanges: '沒有變更',
|
||||
statusTruncated: '變更過多,僅顯示前 2000 條',
|
||||
stage: '暫存',
|
||||
unstage: '取消暫存',
|
||||
stageAll: '全部暫存',
|
||||
|
||||
@@ -119,6 +119,7 @@ export const zhMO: Record<string, string> = {
|
||||
splitDown: '向下分欄',
|
||||
notRepo: '目前目錄不是 git 倉庫',
|
||||
noChanges: '沒有變更',
|
||||
statusTruncated: '變更過多,僅顯示前 2000 條',
|
||||
stage: '暫存',
|
||||
unstage: '取消暫存',
|
||||
stageAll: '全部暫存',
|
||||
|
||||
@@ -119,6 +119,7 @@ export const zhTW: Record<string, string> = {
|
||||
splitDown: '向下分欄',
|
||||
notRepo: '目前目錄不是 git 倉庫',
|
||||
noChanges: '沒有變更',
|
||||
statusTruncated: '變更過多,僅顯示前 2000 條',
|
||||
stage: '暫存',
|
||||
unstage: '取消暫存',
|
||||
stageAll: '全部暫存',
|
||||
|
||||
@@ -110,6 +110,7 @@ export const zh = {
|
||||
splitDown: '向下分栏',
|
||||
notRepo: '当前目录不是 git 仓库',
|
||||
noChanges: '没有变更',
|
||||
statusTruncated: '变更过多,仅显示前 2000 条',
|
||||
stage: '暂存',
|
||||
unstage: '取消暂存',
|
||||
stageAll: '全部暂存',
|
||||
@@ -453,6 +454,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
splitDown: 'Split down',
|
||||
notRepo: 'This directory is not a git repository',
|
||||
noChanges: 'No changes',
|
||||
statusTruncated: 'Too many changes; showing the first 2,000 entries',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage all',
|
||||
|
||||
@@ -472,7 +472,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.16.0'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.16.1'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
+43
-13
@@ -1036,25 +1036,55 @@ export function defaultWidthFor(viewport: number, percent: number): number {
|
||||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||||
}
|
||||
|
||||
/**
|
||||
* URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops
|
||||
* the persisted layout for the session instead of restoring it. When a
|
||||
* restored tab hangs the page on mount (the #369 freeze loop), reloading
|
||||
* into the same state replays the hang forever; this param starts from the
|
||||
* default layout and clears the stored copy, breaking the loop. Persisting
|
||||
* resumes as soon as the param is gone from the URL.
|
||||
*/
|
||||
const RESET_PARAM = 'dsh-sidebar-reset'
|
||||
|
||||
/** Whether the current page load asked for a persisted-state reset. */
|
||||
function resetRequested(): boolean {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).has(RESET_PARAM)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
const reset = resetRequested()
|
||||
if (reset) {
|
||||
try {
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
localStorage.removeItem(GLOBAL_WIDTH_KEY)
|
||||
} catch {
|
||||
// Storage unavailable: the default layout below is still the escape.
|
||||
}
|
||||
}
|
||||
// The panel width is a cross-session preference: the last dragged width
|
||||
// wins over a session's own persisted value, so switching conversations
|
||||
// keeps the width the user chose anywhere.
|
||||
const globalWidth = readGlobalWidth()
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
// Seed the uid counter past the persisted ids (it resets on reload);
|
||||
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||||
nextIdCounter = maxCounterId(parsed)
|
||||
const sanitized = sanitizeState(parsed)
|
||||
if (sanitized !== undefined) {
|
||||
return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
||||
const globalWidth = reset ? undefined : readGlobalWidth()
|
||||
if (!reset) {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
// Seed the uid counter past the persisted ids (it resets on reload);
|
||||
// sanitize re-ids any duplicates the pre-seeding counter left behind.
|
||||
nextIdCounter = maxCounterId(parsed)
|
||||
const sanitized = sanitizeState(parsed)
|
||||
if (sanitized !== undefined) {
|
||||
return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
|
||||
+67
-7
@@ -26,6 +26,10 @@ export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
/** True when the working tree had more rows than `GIT_STATUS_LIMIT`; the
|
||||
* panel shows a truncation notice instead of freezing on a huge untracked
|
||||
* set (issue #369). */
|
||||
truncated?: boolean
|
||||
/** Selected repository root, or the discovered roots when the cwd is a container. */
|
||||
root?: string
|
||||
repositories?: string[]
|
||||
@@ -184,10 +188,28 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
||||
/** Cap on child directories probed by the workspace-container fallback scan.
|
||||
* A home-directory cwd can hold hundreds of visible folders (Library, iCloud
|
||||
* mounts…); probing them all serially is what froze the panel in #369. */
|
||||
const DISCOVERY_LIMIT = 200
|
||||
/** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
|
||||
* a healthy checkout; a probe that needs longer is a stalled mount and is
|
||||
* better abandoned than waited on. */
|
||||
const DISCOVERY_TIMEOUT_MS = 5_000
|
||||
/** Discovery results are cheap to recompute but expensive to storm: the panel
|
||||
* polls every 2s and each poll fans out into several git.* calls that all
|
||||
* resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
|
||||
const DISCOVERY_CACHE_TTL_MS = 60_000
|
||||
|
||||
const repoRootsCache = new Map<string, { roots: string[]; expires: number }>()
|
||||
const repoRootsInFlight = new Map<string, Promise<string[]>>()
|
||||
|
||||
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
|
||||
* Probe timeout is short: a cwd on a stalled mount must not hold the panel
|
||||
* hostage for the full command budget (issue #369). */
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
|
||||
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], DISCOVERY_TIMEOUT_MS)
|
||||
return out.trim() === 'true'
|
||||
} catch {
|
||||
return false
|
||||
@@ -196,12 +218,34 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
|
||||
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
||||
async function directRepoRoot(cwd: string): Promise<string> {
|
||||
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
||||
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'], DISCOVERY_TIMEOUT_MS)
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** Discover the current repository or direct child repositories. */
|
||||
export async function repoRoots(cwd: string): Promise<string[]> {
|
||||
/** Discover the current repository or direct child repositories. Results are
|
||||
* cached per cwd and concurrent callers share one in-flight scan, so opening
|
||||
* the panel (three parallel git.* requests) costs a single discovery pass. */
|
||||
export function repoRoots(cwd: string): Promise<string[]> {
|
||||
const cached = repoRootsCache.get(cwd)
|
||||
if (cached !== undefined && cached.expires > Date.now()) return Promise.resolve(cached.roots)
|
||||
const pending = repoRootsInFlight.get(cwd)
|
||||
if (pending !== undefined) return pending
|
||||
const promise = discoverRepoRoots(cwd).then(
|
||||
(roots) => {
|
||||
repoRootsCache.set(cwd, { roots, expires: Date.now() + DISCOVERY_CACHE_TTL_MS })
|
||||
repoRootsInFlight.delete(cwd)
|
||||
return roots
|
||||
},
|
||||
(error: unknown) => {
|
||||
repoRootsInFlight.delete(cwd)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
repoRootsInFlight.set(cwd, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function discoverRepoRoots(cwd: string): Promise<string[]> {
|
||||
try {
|
||||
return [await directRepoRoot(cwd)]
|
||||
} catch {
|
||||
@@ -209,7 +253,8 @@ export async function repoRoots(cwd: string): Promise<string[]> {
|
||||
const roots: string[] = []
|
||||
for (const entry of entries
|
||||
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
||||
.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.slice(0, DISCOVERY_LIMIT)) {
|
||||
try {
|
||||
const root = await directRepoRoot(join(cwd, entry.name))
|
||||
if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
|
||||
@@ -241,6 +286,12 @@ export async function currentBranch(cwd: string): Promise<string> {
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** Upper bound on status rows shipped to the client. Beyond this the result
|
||||
* is truncated (with `truncated: true`) so a pathological untracked set —
|
||||
* e.g. the working tree discovered under a home-directory cwd — cannot
|
||||
* freeze the browser main thread on JSON parse or list render (#369). */
|
||||
const GIT_STATUS_LIMIT = 2_000
|
||||
|
||||
/**
|
||||
* Working-tree status (untracked included). `--untracked-files=all` lists
|
||||
* the contents of new directories as individual entries, while preserving
|
||||
@@ -254,7 +305,16 @@ export async function status(cwd: string, selected?: string): Promise<GitStatusR
|
||||
currentBranch(root).catch(() => 'HEAD'),
|
||||
runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
||||
])
|
||||
return { isRepo: true, branch, entries: parsePorcelainZ(raw), root, repositories }
|
||||
const parsed = parsePorcelainZ(raw)
|
||||
const truncated = parsed.length > GIT_STATUS_LIMIT
|
||||
return {
|
||||
isRepo: true,
|
||||
branch,
|
||||
entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
|
||||
truncated,
|
||||
root,
|
||||
repositories,
|
||||
}
|
||||
}
|
||||
|
||||
/** Platform-aware identity used only for comparing absolute checkout roots. */
|
||||
|
||||
@@ -78,6 +78,60 @@ describe('git parsing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('caps status entries at the truncation limit (issue #369)', async () => {
|
||||
// A pathological untracked set — e.g. a repository discovered under a
|
||||
// home-directory cwd — must ship a bounded payload: the browser main
|
||||
// thread froze when one status response carried tens of thousands of
|
||||
// rows into response.json() and the unvirtualized change list.
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-git-truncate-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '-q'], { cwd: root })
|
||||
const many = join(root, 'many')
|
||||
mkdirSync(many)
|
||||
for (let index = 0; index <= 2_000; index += 1) writeFileSync(join(many, `f${index}.ts`), 'x')
|
||||
const result = await status(root)
|
||||
expect(result.entries).toHaveLength(2_000)
|
||||
expect(result.truncated).toBe(true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves ordinary statuses untruncated', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-git-untruncated-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '-q'], { cwd: root })
|
||||
for (const name of ['a.ts', 'b.ts', 'c.ts']) writeFileSync(join(root, name), 'x')
|
||||
const result = await status(root)
|
||||
expect(result.entries).toHaveLength(3)
|
||||
expect(result.truncated).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('shares one in-flight discovery scan between concurrent callers and caches the result (issue #369)', async () => {
|
||||
// The panel fires gitStatus/gitBranch/gitLog in parallel and then polls
|
||||
// every 2s; without sharing/caching, each call re-probed every visible
|
||||
// child directory of the cwd (the home-directory spawn storm of #369).
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-git-cache-'))
|
||||
const repo = join(workspace, 'a-repo')
|
||||
try {
|
||||
await mkdir(repo)
|
||||
await execFileAsync('git', ['-C', repo, 'init'])
|
||||
const first = repoRoots(workspace)
|
||||
// A second call while the first scan is still running joins it…
|
||||
expect(repoRoots(workspace)).toBe(first)
|
||||
const roots = await first
|
||||
expect(roots).toEqual([canonical(repo)])
|
||||
// …and once settled, the cached array (same reference) is served
|
||||
// without re-probing for the TTL window.
|
||||
await expect(repoRoots(workspace)).resolves.toBe(roots)
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses log rows with unit separators (full hash + refs)', () => {
|
||||
const rows = parseLogLines(
|
||||
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
|
||||
|
||||
@@ -1213,3 +1213,73 @@ describe('free windows (v0.16.0)', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL reset escape hatch (issue #369)', () => {
|
||||
// Same browser-global stubs as the v0.12.0 block above; loadState reads
|
||||
// window.location.search (reset param) and localStorage (persisted state).
|
||||
beforeEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800, location: { search: '' } }
|
||||
g.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
|
||||
})
|
||||
afterEach(() => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.localStorage
|
||||
})
|
||||
|
||||
/** A persisted layout whose restored git tab would re-hang the page. */
|
||||
const frozenState = JSON.stringify({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: { kind: 'leaf', id: 'pane:1', active: 'g1', tabs: [{ id: 'g1', type: 'git', title: 'Git' }] },
|
||||
bottomSplits: { kind: 'leaf', id: 'pane:b', active: null, tabs: [] },
|
||||
})
|
||||
|
||||
const searchOf = (): { search: string } =>
|
||||
(globalThis as unknown as { window: { location: { search: string } } }).window.location
|
||||
|
||||
it('restores the persisted layout when the param is absent', () => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.localStorage = {
|
||||
getItem: (key: string) => (key === 'dsh-sidebar:v1:s1' ? frozenState : null),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] }
|
||||
expect(leaf.tabs.map(tab => tab.type)).toEqual(['git'])
|
||||
})
|
||||
|
||||
it('?dsh-sidebar-reset drops the persisted layout and clears the stored copy', () => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const removed: string[] = []
|
||||
g.localStorage = {
|
||||
getItem: (key: string) => (key === 'dsh-sidebar:v1:s1' ? frozenState : null),
|
||||
setItem: () => {},
|
||||
removeItem: (key: string) => { removed.push(key) },
|
||||
}
|
||||
searchOf().search = '?dsh-sidebar-reset'
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
// The default layout (editor home tab) — NOT the frozen git-only state.
|
||||
const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] }
|
||||
expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
// The stored copy is gone, so reloading without the param cannot restore
|
||||
// the hanging layout either.
|
||||
expect(removed).toContain('dsh-sidebar:v1:s1')
|
||||
expect(removed).toContain('dsh-sidebar:v1:width')
|
||||
})
|
||||
|
||||
it('the reset param tolerates a value (?dsh-sidebar-reset=1)', () => {
|
||||
searchOf().search = '?foo=bar&dsh-sidebar-reset=1'
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const leaf = store.getSnapshot().state!.splits as { tabs: { type: string }[] }
|
||||
expect(leaf.tabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user