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:
Menghuan1918
2026-08-25 11:04:43 +08:00
parent 6f6568d29f
commit 2cbcba8d11
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