fix(git): bound status payload and repo discovery; add reset escape hatch (#369)
Opening the source-control panel could freeze the whole sidebar, and the frozen layout restored itself after every reload, leaving no way out (issue #369). Three unbounded layers compounded: - `git status --porcelain -z --untracked-files=all` shipped every row of a pathological untracked set into one response; the browser main thread froze on response.json() plus the unvirtualized change list, and the 2s poll replayed the freeze. status() now caps entries at 2000 and reports `truncated` (the panel shows a notice, mirroring fs.read's truncation semantics); worktree change counts inherit the bound for free. - repoRoots() probed every visible child directory of a non-repo cwd (e.g. ~) serially, once per git.* call, each with the full 30s command budget. Discovery now uses a 3s probe timeout, caps probes at 200 directories, and shares one in-flight scan plus a 60s TTL cache per cwd. - A hung layout persisted `panelOpen: true` with the git tab active, so a reload remounted straight back into the freeze with no escape hatch. Loading with `?dsh-sidebar-reset` now drops the persisted layout (and the shared width) and starts from the default. statusTruncated copy synced across all 19 shipped dictionaries.
This commit is contained in:
@@ -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