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('statusTruncated')}
+ )}
{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']) + }) +})