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