diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx
index 6c5ab26..66acbe9 100644
--- a/src/client/GitView.tsx
+++ b/src/client/GitView.tsx
@@ -471,6 +471,9 @@ export function GitView(props: {
{status !== null && status.isRepo && (
<>
+ {status.truncated === true && (
+
{t('staged')} ({stagedEntries.length})
diff --git a/src/client/api.ts b/src/client/api.ts
index cf4e14f..8173cd9 100644
--- a/src/client/api.ts
+++ b/src/client/api.ts
@@ -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[]
}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 385b427..3431011 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -105,6 +105,7 @@ export const ar: Record = {
splitDown: 'تقسيم لأسفل',
notRepo: 'هذا الدليل ليس مستودع git',
noChanges: 'لا تغييرات',
+ statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
stage: 'إدراج',
unstage: 'إلغاء الإدراج',
stageAll: 'إدراج الكل',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index b4177c9..a73124e 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -90,6 +90,7 @@ export const de: Record = {
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',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index ae1c6e5..eb645a6 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -97,6 +97,7 @@ export const fr: Record = {
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',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index 58e66a5..cf98156 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -104,6 +104,7 @@ export const hi: Record = {
splitDown: 'नीचे स्प्लिट',
notRepo: 'यह डायरेक्टरी एक git रिपॉज़िटरी नहीं है',
noChanges: 'कोई बदलाव नहीं',
+ statusTruncated: 'बहुत अधिक बदलाव; केवल पहली 2000 प्रविष्टियाँ दिखाई जा रही हैं',
stage: 'स्टेज',
unstage: 'अनस्टेज',
stageAll: 'सभी स्टेज करें',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 6e418be..e14502f 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -102,6 +102,7 @@ export const id: Record = {
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',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 89d6bd3..dcff13f 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -95,6 +95,7 @@ export const it: Record = {
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',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index 4dfed03..f984147 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -104,6 +104,7 @@ export const ja: Record = {
splitDown: '下に分割',
notRepo: 'このディレクトリは git リポジトリではありません',
noChanges: '変更はありません',
+ statusTruncated: '変更が多すぎるため、最初の 2000 件のみ表示しています',
stage: 'ステージ',
unstage: 'ステージ解除',
stageAll: 'すべてステージ',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index dbe0882..9b8d241 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -96,6 +96,7 @@ export const ko: Record = {
splitDown: '아래로 분할',
notRepo: '현재 디렉터리는 git 저장소가 아닙니다',
noChanges: '변경 사항 없음',
+ statusTruncated: '변경 사항이 너무 많아 처음 2000개만 표시합니다',
stage: '스테이징',
unstage: '스테이징 해제',
stageAll: '모두 스테이징',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 54943d3..dd5854f 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -102,6 +102,7 @@ export const nl: Record = {
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',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 298e9b0..0b2fe4f 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -106,6 +106,7 @@ export const pl: Record = {
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',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 78bd21e..8804450 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -87,6 +87,7 @@ export const pt: Record = {
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',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index d25ae20..5afceb9 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -100,6 +100,7 @@ export const ru: Record = {
splitDown: 'Разделить вниз',
notRepo: 'Этот каталог не является git-репозиторием',
noChanges: 'Нет изменений',
+ statusTruncated: 'Слишком много изменений; показаны только первые 2000 записей',
stage: 'Индексировать',
unstage: 'Убрать из индекса',
stageAll: 'Индексировать всё',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 2527872..cdb7824 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -87,6 +87,7 @@ export const sv: Record = {
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',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 48db69c..937d557 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -104,6 +104,7 @@ export const th: Record = {
splitDown: 'แยกลง',
notRepo: 'ไดเรกทอรีนี้ไม่ใช่ git repository',
noChanges: 'ไม่มีการเปลี่ยนแปลง',
+ statusTruncated: 'การเปลี่ยนแปลงมากเกินไป แสดงเพียง 2000 รายการแรก',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage ทั้งหมด',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index b558673..45010eb 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -104,6 +104,7 @@ export const tr: Record = {
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',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index cb27ede..94c8e6a 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -104,6 +104,7 @@ export const vi: Record = {
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ả',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index 04fd5b5..7e8cb94 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -119,6 +119,7 @@ export const zhHK: Record = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
+ statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index e20f8a0..e21f71b 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -119,6 +119,7 @@ export const zhMO: Record = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
+ statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 92a0d58..2d81e3f 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -119,6 +119,7 @@ export const zhTW: Record = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
+ statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 59ff3eb..23ee8f7 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -110,6 +110,7 @@ export const zh = {
splitDown: '向下分栏',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
+ statusTruncated: '变更过多,仅显示前 2000 条',
stage: '暂存',
unstage: '取消暂存',
stageAll: '全部暂存',
@@ -453,6 +454,7 @@ export const en: Record = {
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',
diff --git a/src/client/state.ts b/src/client/state.ts
index 7420e54..f596eec 100644
--- a/src/client/state.ts
+++ b/src/client/state.ts
@@ -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
diff --git a/src/git.ts b/src/git.ts
index dc0879f..9b9f151 100644
--- a/src/git.ts
+++ b/src/git.ts
@@ -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()
+const repoRootsInFlight = new Map>()
+
+/** 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 {
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 {
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
async function directRepoRoot(cwd: string): Promise {
- 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 {
+/** 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 {
+ 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 {
try {
return [await directRepoRoot(cwd)]
} catch {
@@ -209,7 +253,8 @@ export async function repoRoots(cwd: string): Promise {
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 {
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 '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. */
diff --git a/tests/git.spec.ts b/tests/git.spec.ts
index 7d6e02d..6d7059e 100644
--- a/tests/git.spec.ts
+++ b/tests/git.spec.ts
@@ -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'
diff --git a/tests/state.spec.ts b/tests/state.spec.ts
index 0142559..48ad578 100644
--- a/tests/state.spec.ts
+++ b/tests/state.spec.ts
@@ -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
+ g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800, location: { search: '' } }
+ g.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
+ })
+ afterEach(() => {
+ const g = globalThis as Record
+ 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
+ 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
+ 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'])
+ })
+})