Merge pull request #376 from omdsh-dev/fix/369-git-panel-freeze

fix(git): bound status payload and repo discovery; add reset escape hatch
This commit is contained in:
Atlas Zeng
2026-08-25 11:19:06 +08:00
committed by GitHub
26 changed files with 261 additions and 20 deletions
+3
View File
@@ -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>
+3
View File
@@ -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[]
}
+1
View File
@@ -105,6 +105,7 @@ export const ar: Record<string, string> = {
splitDown: 'تقسيم لأسفل',
notRepo: 'هذا الدليل ليس مستودع git',
noChanges: 'لا تغييرات',
statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
stage: 'إدراج',
unstage: 'إلغاء الإدراج',
stageAll: 'إدراج الكل',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -104,6 +104,7 @@ export const hi: Record<string, string> = {
splitDown: 'नीचे स्प्लिट',
notRepo: 'यह डायरेक्टरी एक git रिपॉज़िटरी नहीं है',
noChanges: 'कोई बदलाव नहीं',
statusTruncated: 'बहुत अधिक बदलाव; केवल पहली 2000 प्रविष्टियाँ दिखाई जा रही हैं',
stage: 'स्टेज',
unstage: 'अनस्टेज',
stageAll: 'सभी स्टेज करें',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -104,6 +104,7 @@ export const ja: Record<string, string> = {
splitDown: '下に分割',
notRepo: 'このディレクトリは git リポジトリではありません',
noChanges: '変更はありません',
statusTruncated: '変更が多すぎるため、最初の 2000 件のみ表示しています',
stage: 'ステージ',
unstage: 'ステージ解除',
stageAll: 'すべてステージ',
+1
View File
@@ -96,6 +96,7 @@ export const ko: Record<string, string> = {
splitDown: '아래로 분할',
notRepo: '현재 디렉터리는 git 저장소가 아닙니다',
noChanges: '변경 사항 없음',
statusTruncated: '변경 사항이 너무 많아 처음 2000개만 표시합니다',
stage: '스테이징',
unstage: '스테이징 해제',
stageAll: '모두 스테이징',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -100,6 +100,7 @@ export const ru: Record<string, string> = {
splitDown: 'Разделить вниз',
notRepo: 'Этот каталог не является git-репозиторием',
noChanges: 'Нет изменений',
statusTruncated: 'Слишком много изменений; показаны только первые 2000 записей',
stage: 'Индексировать',
unstage: 'Убрать из индекса',
stageAll: 'Индексировать всё',
+1
View File
@@ -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',
+1
View File
@@ -104,6 +104,7 @@ export const th: Record<string, string> = {
splitDown: 'แยกลง',
notRepo: 'ไดเรกทอรีนี้ไม่ใช่ git repository',
noChanges: 'ไม่มีการเปลี่ยนแปลง',
statusTruncated: 'การเปลี่ยนแปลงมากเกินไป แสดงเพียง 2000 รายการแรก',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage ทั้งหมด',
+1
View File
@@ -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',
+1
View File
@@ -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ả',
+1
View File
@@ -119,6 +119,7 @@ export const zhHK: Record<string, string> = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
+1
View File
@@ -119,6 +119,7 @@ export const zhMO: Record<string, string> = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
+1
View File
@@ -119,6 +119,7 @@ export const zhTW: Record<string, string> = {
splitDown: '向下分欄',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
+2
View File
@@ -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',
+43 -13
View File
@@ -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
View File
@@ -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. */
+54
View File
@@ -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'
+70
View File
@@ -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'])
})
})