dsh-remote-vs 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,9 +29,20 @@ import type { ChangeMark, ChangeSet } from './vsc/change-marks.ts'
29
29
  import { changeSetFromHunks } from './changes/model.ts'
30
30
  import { undoContext, type UndoContext } from './changes/undo-context.ts'
31
31
  import { applyStageOverrides, bucketStatus, buildTreeMarks, insertStagedEntry, optimisticStageDetail, type ChangeEntry, type StageOverride, type StatusBuckets, type TreeMarks } from './scm/status-map.ts'
32
- import { loadCommitFileDiff, findRepos, loadDiff, loadHead, loadStatusDetail, probeStatusDir, probeStatusPaths, resolveRepoRoot, applyHunk, stagePaths, unstagePaths, type DiffPayload } from './scm/actions.ts'
32
+ import { loadCommitFileDiff, findRepos, loadDiff, loadHead, loadStatusDetail, probeRepoRoot, probeStatusDir, probeStatusPaths, applyHunk, stagePaths, unstagePaths, type DiffPayload } from './scm/actions.ts'
33
33
  import { StageCoordinator } from './scm/stage-queue.ts'
34
34
  import { collapseDir, dropExpandedRows, expandDir, pruneDirExpansions, type DirExpansions } from './scm/untracked-dirs.ts'
35
+ import { ReadQueue } from './scm/read-queue.ts'
36
+ import {
37
+ gitCacheKey,
38
+ parseGitCacheKey,
39
+ patchCachedGitStatus,
40
+ patchCachedUntrackedDirs,
41
+ readGitSnapshot,
42
+ retainGitSnapshots,
43
+ writeGitSnapshot,
44
+ type GitSnapshot,
45
+ } from './scm/git-cache.ts'
35
46
  import { detectLang, makeT, type Lang } from './i18n.ts'
36
47
  import { effectiveEditorSurface, type EditorSurface } from './float-editor.ts'
37
48
  import { captureLiveEditorPosition, captureLiveEditorText, isLiveEditorDirty, isLiveEditorFor, markLiveEditorSaved } from './live-editor.ts'
@@ -132,6 +143,16 @@ interface State {
132
143
  repoMissing: boolean
133
144
  gitBusy: boolean
134
145
  gitError: string | undefined
146
+ /**
147
+ * True while the git slice on screen is a CACHED read of this workspace whose
148
+ * background refresh has not landed yet (see `scm/git-cache.ts`).
149
+ *
150
+ * It is what lets the panel say so: a monorepo re-read can take minutes, and
151
+ * a change list that old must not be mistaken for a fresh one. Cleared the
152
+ * moment a real read paints, or when the workspace was read moments ago and
153
+ * the refresh was skipped as redundant.
154
+ */
155
+ gitCached: boolean
135
156
  /** Effective per-git-command budget (ms), from the Host's config. */
136
157
  gitDeadlineMs: number
137
158
  tabs: EditorTab[]
@@ -329,7 +350,8 @@ const state: State = {
329
350
  error: undefined,
330
351
  hosts: [],
331
352
  workspaces: [],
332
- routing: { mode: 'off', aliasPaths: {}, tombstones: [] },
353
+ // Pre-bootstrap placeholder: same default as the plugin config (nforce).
354
+ routing: { mode: 'enforce', aliasPaths: {}, tombstones: [] },
333
355
  workspaceId: readWorkspaceId(),
334
356
  status: undefined,
335
357
  head: undefined,
@@ -338,6 +360,7 @@ const state: State = {
338
360
  repoMissing: false,
339
361
  gitBusy: false,
340
362
  gitError: undefined,
363
+ gitCached: false,
341
364
  gitDeadlineMs: DEFAULT_GIT_TIMEOUT_MS,
342
365
  tabs: [],
343
366
  activeTabId: undefined,
@@ -382,6 +405,8 @@ export interface ExplorerValue {
382
405
  head: HeadInfo | undefined
383
406
  gitBusy: boolean
384
407
  gitError: string | undefined
408
+ /** The painted git slice is a cached one; a background refresh is running. */
409
+ gitCached: boolean
385
410
  /** Effective per-git-command budget (ms), for the loading hint. */
386
411
  gitDeadlineMs: number
387
412
  tabs: EditorTab[]
@@ -428,7 +453,16 @@ export interface ExplorerValue {
428
453
  /** Repositories found under the workspace root, as workspace-relative dirs. */
429
454
  discoverRepos: () => Promise<string[]>
430
455
  refreshAll: () => void
431
- refreshGit: () => Promise<void>
456
+ /**
457
+ * Re-read git for the binding on screen.
458
+ *
459
+ * `join` shares a read that is already in flight — the right contract for an
460
+ * automatic refresh, since a second walk of the same repository answers
461
+ * exactly the same question. `fresh` (the default) waits for a running read
462
+ * and then reads again: the answer must include whatever the caller just
463
+ * wrote.
464
+ */
465
+ refreshGit: (mode?: 'join' | 'fresh') => Promise<void>
432
466
  afterWrite: () => Promise<void>
433
467
  /**
434
468
  * Stage / unstage paths: the row moves at once, and ONE path-limited status
@@ -545,6 +579,7 @@ function build(): ExplorerValue {
545
579
  head: state.head,
546
580
  gitBusy: state.gitBusy,
547
581
  gitError: state.gitError,
582
+ gitCached: state.gitCached,
548
583
  gitDeadlineMs: state.gitDeadlineMs,
549
584
  tabs: state.tabs,
550
585
  activeTab: state.tabs.find(item => item.id === state.activeTabId),
@@ -643,13 +678,106 @@ function activeBinding(): { workspace: RemoteWorkspaceBinding | undefined; key:
643
678
  }
644
679
 
645
680
  /**
646
- * Monotonic ticket for git refreshes. Two refreshes can overlap whenever the
647
- * binding changes or a write lands mid-read, and both panels stay mounted, so
648
- * an older response arriving last would otherwise write ITS repository into
649
- * the state the newer binding is using — after which every staged/discard
650
- * button would act on the wrong repo (scm/actions.ts takes alias + repoRoot).
681
+ * Git reads, one walk at a time per (workspace, repository directory).
682
+ *
683
+ * Two reads can be wanted at once — the background refresh of the binding on
684
+ * screen and a ⟳ / post-write read — and on a large repository a second walk of
685
+ * the same tree is minutes of duplicate work whose answers disagree about which
686
+ * one is current. The queue gives callers two contracts instead of that race
687
+ * (`scm/read-queue.ts`): `join` shares a read already in flight (automatic
688
+ * refreshes, workspace switches), `fresh` waits for it and reads again (⟳, and
689
+ * every write, whose answer must include the write).
690
+ */
691
+ const gitReads = new ReadQueue(refreshGitBusy)
692
+
693
+ /**
694
+ * Which read may repaint the visible slice, per binding.
695
+ *
696
+ * The repo picker can ask for a DIFFERENT directory of the same workspace while
697
+ * a read of another one is still walking; the older answer describes the
698
+ * directory the user just left and must not land on top of the newer request.
699
+ * Cache writes are deliberately not gated: they belong to the directory the read
700
+ * was about, and are what makes the next visit instant.
701
+ */
702
+ const latestGitRead = new Map<string, number>()
703
+ let gitReadTicket = 0
704
+
705
+ /** True when `bindingId`'s read `ticket` may paint the on-screen slice. */
706
+ function gitReadIsCurrent(bindingId: string, ticket: number): boolean {
707
+ return latestGitRead.get(bindingId) === ticket && activeBinding().key === bindingId
708
+ }
709
+
710
+ /** The cache key of what the panel is showing (workspace + repository directory). */
711
+ function currentGitKey(): string {
712
+ return gitCacheKey(activeBinding().key, state.repoSubdir)
713
+ }
714
+
715
+ /** Recompute the busy flag from the queue (called on every start/settle). */
716
+ function refreshGitBusy(): void {
717
+ const bindingId = activeBinding().key
718
+ const busy = gitReads.inflightKeys().some(key => parseGitCacheKey(key)?.bindingId === bindingId)
719
+ if (state.gitBusy !== busy) patch({ gitBusy: busy })
720
+ }
721
+
722
+ /**
723
+ * A cached read this young is what a refresh would answer anyway.
724
+ *
725
+ * Flipping between two workspaces must not start a monorepo-wide walk on every
726
+ * hop: within this window the cached slice is treated as current. Every explicit
727
+ * refresh (⟳, a write) and every switch to an OLDER snapshot still re-reads.
651
728
  */
652
- let gitGeneration = 0
729
+ const CACHED_FRESH_MS = 5_000
730
+
731
+ /**
732
+ * The state fields one cached read stands for — the switch's synchronous paint.
733
+ *
734
+ * A MISS clears the slice exactly like the old code did: the workspace has never
735
+ * been read, so there is nothing honest to show and the read about to start owns
736
+ * the "Loading…" state. `repoSubdir` keeps the persisted choice so that read
737
+ * runs in the directory the user last used.
738
+ *
739
+ * `refreshing` says whether a background read was actually started; when it is
740
+ * false the slice is merely a few seconds old and is presented as current — a
741
+ * "cached" badge with nothing refreshing would be noise, not information.
742
+ */
743
+ function cachedGitSlice(bindingId: string, refreshing: boolean): Partial<State> {
744
+ const subdir = readRepoSubdir(bindingId)
745
+ const snapshot = readGitSnapshot(gitCacheKey(bindingId, subdir))
746
+ if (snapshot === undefined) {
747
+ return {
748
+ status: undefined,
749
+ head: undefined,
750
+ repoRoot: undefined,
751
+ repoSubdir: subdir,
752
+ repoMissing: false,
753
+ untrackedDirs: new Map(),
754
+ gitError: undefined,
755
+ gitCached: false,
756
+ }
757
+ }
758
+ return {
759
+ status: snapshot.status,
760
+ head: snapshot.head,
761
+ repoRoot: snapshot.repoRoot,
762
+ repoSubdir: snapshot.repoSubdir,
763
+ repoMissing: snapshot.repoMissing,
764
+ untrackedDirs: snapshot.untrackedDirs,
765
+ gitError: undefined,
766
+ gitCached: refreshing,
767
+ }
768
+ }
769
+
770
+ /** Paint the cached slice of `bindingId` (a selection moved there). */
771
+ function adoptCachedGit(bindingId: string): void {
772
+ patch(cachedGitSlice(bindingId, true))
773
+ }
774
+
775
+ /** Mirror the expansion set into the cached snapshot of the binding on screen. */
776
+ function mirrorUntrackedDirs(): void {
777
+ const { workspace, key } = activeBinding()
778
+ if (workspace === undefined) return
779
+ patchCachedUntrackedDirs(gitCacheKey(key, state.repoSubdir), state.repoRoot, state.untrackedDirs)
780
+ }
653
781
 
654
782
  // -------------------------------------------------------------- lifecycle
655
783
 
@@ -689,7 +817,19 @@ export function refreshAll(): void {
689
817
  ready: true,
690
818
  error: undefined,
691
819
  })
692
- void refreshGit()
820
+ // A snapshot of a workspace the table no longer holds must never be
821
+ // handed to another one: its change list names paths of a foreign tree.
822
+ const bindingIds = new Set(payload.workspaces.map(item => item.id))
823
+ retainGitSnapshots(bindingIds)
824
+ for (const id of [...latestGitRead.keys()]) {
825
+ if (!bindingIds.has(id)) latestGitRead.delete(id)
826
+ }
827
+ // The selection can resolve ELSEWHERE after the table changed (a deleted
828
+ // workspace falls back to the first entry). That is a switch like any
829
+ // other, and its cached slice is what keeps the panel from "Loading…".
830
+ const resolved = activeBinding().key
831
+ if (resolved !== key) adoptCachedGit(resolved)
832
+ void refreshGit('join')
693
833
  },
694
834
  (error: unknown) => {
695
835
  patch({ ready: true, error: error instanceof RemoteApiError ? error.message : String(error) })
@@ -798,22 +938,27 @@ function applyWorkspaceSelection(id: string, byUser: boolean): void {
798
938
  // repository-relative too.
799
939
  stageOverrides = new Map()
800
940
  stageOverrideTarget = ''
941
+ // Hydrate in the SAME frame as the selection: a workspace that was read once
942
+ // repaints its whole change list here instead of showing "Loading…" for the
943
+ // minutes a repository-wide walk takes. A miss clears the slice and the read
944
+ // started below owns the loading state (see `scm/git-cache.ts`).
945
+ const snapshot = readGitSnapshot(gitCacheKey(id, readRepoSubdir(id)))
946
+ const stale = snapshot === undefined || Date.now() - snapshot.at >= CACHED_FRESH_MS
801
947
  patch({
802
948
  workspaceId: id,
803
949
  tabs: [],
804
950
  activeTabId: undefined,
805
- status: undefined,
806
- head: undefined,
807
- repoRoot: undefined,
808
- repoSubdir: readRepoSubdir(id),
809
- untrackedDirs: new Map(),
951
+ ...cachedGitSlice(id, stale),
810
952
  })
811
953
  try {
812
954
  localStorage.setItem(WS_KEY, id)
813
955
  } catch {
814
956
  // Private mode: ignore.
815
957
  }
816
- void refreshGit()
958
+ // A join can be a read that is ALREADY running (flip away and back): the busy
959
+ // flag must reflect that before anything is published again.
960
+ refreshGitBusy()
961
+ if (stale) void refreshGit('join')
817
962
  }
818
963
 
819
964
  let searchSeq = 0
@@ -1066,8 +1211,12 @@ export async function setRepoSubdir(subdir: string): Promise<void> {
1066
1211
  const { workspace, key } = activeBinding()
1067
1212
  const clean = normalizeRepoSubdir(subdir)
1068
1213
  if (workspace !== undefined) writeRepoSubdir(key, clean)
1069
- patch({ repoSubdir: clean, repoRoot: undefined, gitError: undefined })
1070
- await refreshGit()
1214
+ // Pointing git at another DIRECTORY of the workspace is a switch like any
1215
+ // other: a directory that was read before paints its cached slice at once, and
1216
+ // the read below replaces it. A directory never read clears the slice, exactly
1217
+ // like before (`cachedGitSlice` reads the persisted choice, just written).
1218
+ adoptCachedGit(key)
1219
+ await refreshGit('fresh')
1071
1220
  }
1072
1221
 
1073
1222
  /** Repositories found under the workspace root (workspace-relative, root first). */
@@ -1080,39 +1229,101 @@ export async function discoverRepos(): Promise<string[]> {
1080
1229
  .filter((dir): dir is string => dir !== undefined)
1081
1230
  }
1082
1231
 
1083
- /** Re-read git status for the current binding. */
1084
- export async function refreshGit(): Promise<void> {
1232
+ /**
1233
+ * Re-read git for the binding on screen.
1234
+ *
1235
+ * Reads go through `scm/read-queue.ts`, keyed by (binding, repository directory):
1236
+ * `join` shares a read that is already in flight — an automatic refresh has no
1237
+ * reason to duplicate a walk — while `fresh` waits for it and then reads again,
1238
+ * because the caller just wrote something and the answer must include it. Either
1239
+ * mode never runs two walks of the same repository at once, which is what lets a
1240
+ * background refresh and the ⟳ button coexist instead of racing.
1241
+ *
1242
+ * The answer is cached under the key the read STARTED with (even when the user
1243
+ * has already switched away: the walk is paid for either way, and the cache is
1244
+ * what makes the next visit instant) and repaints only while it is still that
1245
+ * binding's latest read.
1246
+ *
1247
+ * @param mode - `fresh` (default) never reuses a read that started before the
1248
+ * call; `join` shares a running one (workspace switches, bootstrap).
1249
+ */
1250
+ export async function refreshGit(mode: 'join' | 'fresh' = 'fresh'): Promise<void> {
1085
1251
  const { workspace, key } = activeBinding()
1086
1252
  if (workspace === undefined) return
1087
- const gen = ++gitGeneration
1088
- // Discard a response that is no longer about the binding on screen (the
1089
- // user switched) or is superseded by a newer refresh. Without this the
1090
- // slower request wins the last write and the panel shows one workspace's
1091
- // repository while the selector and every action use another's.
1092
- const stale = (): boolean => gen !== gitGeneration || activeBinding().key !== key
1093
- // A local stage/unstage that happens while this read is in flight cannot be
1094
- // in its answer: remember where the local timeline stood when it started.
1253
+ const subdir = state.repoSubdir
1254
+ const cacheKey = gitCacheKey(key, subdir)
1255
+ const settleMs = gitReadSettleMs()
1256
+ try {
1257
+ await gitReads.run(cacheKey, () => readGit(key, subdir, cacheKey), mode, settleMs)
1258
+ } catch (error) {
1259
+ // Only the settle deadline (or a throw before the read's own try) lands
1260
+ // here: `readGit` handles read failures itself. The failure belongs to the
1261
+ // key this read STARTED with — the repo picker may have moved the panel to
1262
+ // another directory of the same workspace while this read was stuck, and
1263
+ // reporting it there would put a bogus "stuck" error and the cached badge on
1264
+ // top of that directory's successful answer.
1265
+ if (activeBinding().key !== key || currentGitKey() !== cacheKey) return
1266
+ patch({
1267
+ gitError: error instanceof Error ? error.message : String(error),
1268
+ gitCached: state.repoRoot !== undefined,
1269
+ })
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * One real read: probe the repository root, then status + branch header together,
1275
+ * then cache the answer and paint it when it still owns the screen.
1276
+ */
1277
+ async function readGit(bindingId: string, subdir: string, cacheKey: string): Promise<void> {
1278
+ // The binding can disappear from the table while this read waits in the queue.
1279
+ const workspace = state.workspaces.find(item => item.id === bindingId)
1280
+ if (workspace === undefined) return
1281
+ const ticket = ++gitReadTicket
1282
+ latestGitRead.set(bindingId, ticket)
1283
+ // A local stage/unstage that happens while this read is in flight cannot be in
1284
+ // its answer: remember where the local timeline stood when it started.
1095
1285
  const stageAt = stageSeq
1096
- patch({ gitBusy: true })
1097
1286
  try {
1098
- const workdir = repoWorkdir(workspace.remotePath, state.repoSubdir)
1099
- const root = await resolveRepoRoot(workspace.alias, workdir)
1100
- if (stale()) return
1101
- if (root === undefined) {
1102
- patch({ repoRoot: undefined, repoMissing: true, status: EMPTY_STATUS, head: undefined, gitBusy: false, gitError: undefined })
1287
+ const workdir = repoWorkdir(workspace.remotePath, subdir)
1288
+ // The strict probe (see `probeRepoRoot`): "not a repository" is a fact,
1289
+ // while a failed probe must NOT be mistaken for one — writing an empty
1290
+ // snapshot there would destroy the cached list and show the "no repository"
1291
+ // hint, which is the very blank state the cache exists to prevent.
1292
+ // `withDeadline` covers the host that never answers at all; the probe itself
1293
+ // reports transport failures.
1294
+ const probe = await withDeadline(probeRepoRoot(workspace.alias, workdir), gitDeadlineMs(), t('editor.readTimeout'))
1295
+ if (probe.kind === 'failed') throw new Error(probe.error)
1296
+ if (probe.kind === 'none') {
1297
+ const snapshot: GitSnapshot = {
1298
+ repoRoot: undefined,
1299
+ repoSubdir: subdir,
1300
+ status: EMPTY_STATUS,
1301
+ head: undefined,
1302
+ repoMissing: true,
1303
+ untrackedDirs: new Map(),
1304
+ at: Date.now(),
1305
+ }
1306
+ writeGitSnapshot(cacheKey, snapshot)
1307
+ paintGit(bindingId, ticket, snapshot)
1103
1308
  return
1104
1309
  }
1310
+ const root = probe.root
1105
1311
  const [detail, head] = await Promise.all([
1106
1312
  withDeadline(loadStatusDetail(workspace.alias, root), gitDeadlineMs(), t('editor.readTimeout')),
1107
1313
  withDeadline(loadHead(workspace.alias, root), gitDeadlineMs(), t('editor.readTimeout')),
1108
1314
  ])
1109
- if (stale()) return
1110
1315
  // A directory INSIDE a repository settles on that repository's root, so the
1111
- // stored choice is what git actually uses (and what the picker shows).
1112
- const settled = repoSubdirOf(workspace.remotePath, root) ?? state.repoSubdir
1113
- if (settled !== state.repoSubdir) writeRepoSubdir(key, settled)
1114
- // Keep the local stage results this read could not have seen, and retire
1115
- // the ones it did see (they are in `detail` now, for better or worse).
1316
+ // stored choice is what git actually uses (and what the picker shows). Only
1317
+ // the binding's LATEST read may rewrite that persisted choice: a read
1318
+ // superseded by a repo-picker switch must not put the OLD directory back
1319
+ // (its answer is still cached under the key it started with, which is what a
1320
+ // later switch to that directory needs).
1321
+ const settled = repoSubdirOf(workspace.remotePath, root) ?? subdir
1322
+ if (settled !== readRepoSubdir(bindingId) && latestGitRead.get(bindingId) === ticket) {
1323
+ writeRepoSubdir(bindingId, settled)
1324
+ }
1325
+ // Keep the local stage results this read could not have seen, and retire the
1326
+ // ones it did see (they are in `detail` now, for better or worse).
1116
1327
  const target = stageTargetKey({ alias: workspace.alias, root })
1117
1328
  const status = stageOverrideTarget === target ? applyStageOverrides(detail, stageOverrides, stageAt) : detail
1118
1329
  if (stageOverrideTarget === target) {
@@ -1120,36 +1331,63 @@ export async function refreshGit(): Promise<void> {
1120
1331
  if (override.at <= stageAt) stageOverrides.delete(path)
1121
1332
  }
1122
1333
  }
1123
- // An expanded untracked directory survives this refresh only while git still
1124
- // reports the same directory row (see `pruneDirExpansions`).
1125
- patch({
1334
+ // An expanded untracked directory survives a read only while git still
1335
+ // reports the same directory row (see `pruneDirExpansions`). The live set
1336
+ // belongs to the binding on screen; a read of a workspace in the background
1337
+ // must prune ITS OWN cached set instead of borrowing another binding's.
1338
+ const onScreen = bindingId === activeBinding().key
1339
+ const priorDirs: DirExpansions = onScreen
1340
+ ? state.untrackedDirs
1341
+ : (readGitSnapshot(cacheKey)?.untrackedDirs ?? new Map())
1342
+ const dirs = pruneDirExpansions(priorDirs, status)
1343
+ const snapshot: GitSnapshot = {
1126
1344
  repoRoot: root,
1127
1345
  repoSubdir: settled,
1128
- repoMissing: false,
1129
1346
  status,
1130
1347
  head,
1131
- gitBusy: false,
1132
- gitError: undefined,
1133
- untrackedDirs: pruneDirExpansions(state.untrackedDirs, status),
1134
- })
1348
+ repoMissing: false,
1349
+ untrackedDirs: dirs,
1350
+ at: Date.now(),
1351
+ }
1352
+ writeGitSnapshot(cacheKey, snapshot)
1353
+ // The settled directory is a DIFFERENT cache key, and a switch back looks the
1354
+ // snapshot up under the PERSISTED choice — so both keys have to answer.
1355
+ if (settled !== subdir) writeGitSnapshot(gitCacheKey(bindingId, settled), snapshot)
1356
+ paintGit(bindingId, ticket, snapshot)
1135
1357
  } catch (error) {
1136
- if (stale()) return
1137
- // Clear the repo identity too. Leaving a previous binding's `repoRoot` in
1138
- // place kept its branch, change list and sync buttons painted while the
1139
- // selector said something else — and ScmPanel keys "Loading…" off
1140
- // repoRoot === undefined, so NOT clearing it showed a stale repo while
1141
- // clearing it without an error message showed a spinner forever. The
1142
- // message is now rendered by the panel (see ScmPanel's empty state).
1358
+ if (!gitReadIsCurrent(bindingId, ticket)) return
1359
+ // A failed read discards nothing that was already known: the panel keeps the
1360
+ // last good (or cached) slice and prints the message above it, so a transient
1361
+ // host error cannot blank a change list the user is working in. The slice is
1362
+ // marked cached because it now predates the failure.
1143
1363
  patch({
1144
- repoRoot: undefined,
1145
- head: undefined,
1146
- status: EMPTY_STATUS,
1147
- gitBusy: false,
1148
1364
  gitError: error instanceof Error ? error.message : String(error),
1365
+ gitCached: state.repoRoot !== undefined,
1149
1366
  })
1150
1367
  }
1151
1368
  }
1152
1369
 
1370
+ /**
1371
+ * Adopt one read's answer when it still owns the screen.
1372
+ *
1373
+ * The ticket is what keeps two workspaces apart: a slow read of the workspace
1374
+ * the user just left still refreshes THAT workspace's cache — so switching back
1375
+ * is instant — but it never paints over the repository now on screen.
1376
+ */
1377
+ function paintGit(bindingId: string, ticket: number, snapshot: GitSnapshot): void {
1378
+ if (!gitReadIsCurrent(bindingId, ticket)) return
1379
+ patch({
1380
+ repoRoot: snapshot.repoRoot,
1381
+ repoSubdir: snapshot.repoSubdir,
1382
+ repoMissing: snapshot.repoMissing,
1383
+ status: snapshot.status,
1384
+ head: snapshot.head,
1385
+ untrackedDirs: snapshot.untrackedDirs,
1386
+ gitError: undefined,
1387
+ gitCached: false,
1388
+ })
1389
+ }
1390
+
1153
1391
  /** Refresh everything a working-tree change can invalidate. */
1154
1392
  export async function afterWrite(): Promise<void> {
1155
1393
  await refreshGit()
@@ -1163,6 +1401,37 @@ export function scmTarget(): { alias: string; root: string } | undefined {
1163
1401
  return { alias: workspace.alias, root: state.repoRoot }
1164
1402
  }
1165
1403
 
1404
+ /**
1405
+ * Read-only view of the git slice, for callers that do not render.
1406
+ *
1407
+ * The UI reads the same values through `useExplorer()`; this getter exists for
1408
+ * the local gates, which drive this module in node with nothing mounted (they
1409
+ * need to observe the switch, the cached flag and what a failed read left
1410
+ * behind). It hands out primitives only, so no caller can mutate the store
1411
+ * through it.
1412
+ */
1413
+ export function gitState(): {
1414
+ workspaceId: string
1415
+ repoRoot: string | undefined
1416
+ repoSubdir: string
1417
+ changes: number
1418
+ gitBusy: boolean
1419
+ gitCached: boolean
1420
+ gitError: string | undefined
1421
+ repoMissing: boolean
1422
+ } {
1423
+ return {
1424
+ workspaceId: activeBinding().key,
1425
+ repoRoot: state.repoRoot,
1426
+ repoSubdir: state.repoSubdir,
1427
+ changes: state.status?.entries.length ?? 0,
1428
+ gitBusy: state.gitBusy,
1429
+ gitCached: state.gitCached,
1430
+ gitError: state.gitError,
1431
+ repoMissing: state.repoMissing,
1432
+ }
1433
+ }
1434
+
1166
1435
  /**
1167
1436
  * Move paths between the index and the worktree halves of the LOCAL status
1168
1437
  * model — the optimistic update, its rollback, and the correction the probe
@@ -1198,7 +1467,17 @@ export function applyLocalStage(paths: readonly string[], staged: boolean): void
1198
1467
  // directory row that stopped being untracked takes its whole expansion with
1199
1468
  // it (see `pruneDirExpansions`).
1200
1469
  const dirs = staged ? dropExpandedRows(state.untrackedDirs, paths) : state.untrackedDirs
1201
- patch({ status: next, untrackedDirs: pruneDirExpansions(dirs, next) })
1470
+ const nextDirs = pruneDirExpansions(dirs, next)
1471
+ patch({ status: next, untrackedDirs: nextDirs })
1472
+ // Mirror the local move into the per-workspace cache: switching away and back
1473
+ // must not resurrect the list as it stood before the click. The paths are
1474
+ // repository-relative, so the guard is the snapshot's own repoRoot.
1475
+ const { workspace, key: bindingId } = activeBinding()
1476
+ if (workspace !== undefined) {
1477
+ const cacheKey = gitCacheKey(bindingId, state.repoSubdir)
1478
+ patchCachedGitStatus(cacheKey, state.repoRoot, next)
1479
+ patchCachedUntrackedDirs(cacheKey, state.repoRoot, nextDirs)
1480
+ }
1202
1481
  }
1203
1482
 
1204
1483
  /**
@@ -1240,6 +1519,7 @@ export async function toggleUntrackedDir(dirPath: string): Promise<void> {
1240
1519
  if (dirPath === '' || !dirPath.endsWith('/')) return
1241
1520
  if (state.untrackedDirs.has(dirPath)) {
1242
1521
  patch({ untrackedDirs: collapseDir(state.untrackedDirs, dirPath) })
1522
+ mirrorUntrackedDirs()
1243
1523
  return
1244
1524
  }
1245
1525
  // A double click must not buy two probes: the second click only arrives while
@@ -1253,6 +1533,7 @@ export async function toggleUntrackedDir(dirPath: string): Promise<void> {
1253
1533
  // Read the state AFTER the round trip: another directory may have been
1254
1534
  // expanded (or this one collapsed) while the probe was in flight.
1255
1535
  patch({ untrackedDirs: expandDir(state.untrackedDirs, dirPath, stdout) })
1536
+ mirrorUntrackedDirs()
1256
1537
  } finally {
1257
1538
  dirProbes.delete(dirPath)
1258
1539
  }
@@ -1389,6 +1670,25 @@ function gitDeadlineMs(): number {
1389
1670
  return clientDeadlineFor(gitTimeoutMs())
1390
1671
  }
1391
1672
 
1673
+ /**
1674
+ * Hard settle deadline for ONE queued git read (`scm/read-queue.ts`).
1675
+ *
1676
+ * A read that never settles would keep its key reserved forever: later `join`
1677
+ * calls reuse the dead promise, `fresh` callers wait for it, and the busy flag
1678
+ * stays up — one hung host call would freeze that key for the rest of the page
1679
+ * (the pre-cache code could always re-read, so this is the one regression a
1680
+ * shared flight introduces). The bound is generous on purpose: the status read
1681
+ * issues its probe and then its two loaders SEQUENTIALLY, so twice the configured
1682
+ * git budget plus slack is "stuck", not "slow".
1683
+ *
1684
+ * Exported because the panels own their own queues (SCM side lists, history log)
1685
+ * and must pass the same bound — a queue without one is a permanent lock waiting
1686
+ * for a host hiccup.
1687
+ */
1688
+ export function gitReadSettleMs(): number {
1689
+ return gitDeadlineMs() * 2 + 30_000
1690
+ }
1691
+
1392
1692
  /** Deadline for one SFTP file read (editor, diff sides, preview). */
1393
1693
  function fileReadDeadlineMs(): number {
1394
1694
  return clientDeadlineFor(fileReadTimeoutMs())
@@ -684,6 +684,16 @@ body[data-ds-dark-theme] .rvs-root {
684
684
  color: var(--rvs-fg);
685
685
  }
686
686
 
687
+ /* "cached · refreshing" badge: quieter than a pill that carries information the
688
+ user asked for (a branch), because it is a qualifier on the list beside it. */
689
+ .rvs-pill-cached {
690
+ flex: none;
691
+ font-weight: 500;
692
+ color: var(--rvs-fg3);
693
+ border: 1px solid var(--rvs-border2);
694
+ background: transparent;
695
+ }
696
+
687
697
  .rvs-subpanel {
688
698
  margin: 6px 8px;
689
699
  padding: 8px;
package/src/git-ops.ts CHANGED
@@ -121,6 +121,32 @@ export function repoProbeCommand(workdir: string): string {
121
121
  return `${git(workdir)} rev-parse --show-toplevel 2>/dev/null`
122
122
  }
123
123
 
124
+ /**
125
+ * Repository probe that keeps the ANSWER (exit code + both streams) on stdout.
126
+ *
127
+ * `repoProbeCommand` throws the exit code and stderr away — everything that is
128
+ * not a root line reads as "nothing". That is fine for the workspace wizard
129
+ * ("nothing to offer") but not for the SCM panel, where "this is not a
130
+ * repository" and "git could not answer" are DIFFERENT states: the first is a
131
+ * fact that may be cached, the second must keep the last known change list and
132
+ * report the reason. So this command always exits 0 and prints
133
+ *
134
+ * RVS_RC=<git exit code>
135
+ * <everything git wrote, stderr merged>
136
+ *
137
+ * `LC_ALL=C` pins git's wording (the client matches `not a git repository`; a
138
+ * localized host would otherwise read as a failure), and merging the streams is
139
+ * what makes that message survive — git writes it to stderr, which the plain
140
+ * probe discards.
141
+ */
142
+ export function repoProbeDetailCommand(workdir: string): string {
143
+ // `printf` is last in the pipeline, so the exec route sees success and the
144
+ // caller can read the REAL git exit code out of the first line.
145
+ return capped(
146
+ `out=$(LC_ALL=C LANG=C ${git(workdir)} rev-parse --show-toplevel 2>&1); rc=$?; printf 'RVS_RC=%s\\n%s\\n' "$rc" "$out"`,
147
+ )
148
+ }
149
+
124
150
  /** Discover repositories under a workspace root (depth-capped). */
125
151
  export function repoDiscoveryCommand(root: string): string {
126
152
  const dir = shQuote(assertRemoteDir(root))
@@ -399,6 +425,40 @@ export function parseRepoRoot(stdout: string): string | undefined {
399
425
  return line.startsWith('/') ? line : undefined
400
426
  }
401
427
 
428
+ /** One strict probe's answer (see `repoProbeDetailCommand`). */
429
+ export type RepoProbeAnswer =
430
+ | { kind: 'repo'; root: string }
431
+ | { kind: 'none' }
432
+ | { kind: 'failed'; error: string }
433
+
434
+ /**
435
+ * Parse `repoProbeDetailCommand`'s output into the three states the panel needs.
436
+ *
437
+ * "not a git repository" is matched on git's own message rather than on the exit
438
+ * code: `rev-parse` exits 128 for that AND for a directory it cannot enter, so
439
+ * the code alone cannot separate the two. The message is guaranteed English by
440
+ * the command's `LC_ALL=C`.
441
+ */
442
+ export function parseRepoProbe(stdout: string): RepoProbeAnswer {
443
+ const marker = /^RVS_RC=(\d+)\n?/.exec(stdout)
444
+ if (marker === null) return { kind: 'failed', error: 'the repository probe returned no status line' }
445
+ const code = Number(marker[1])
446
+ const detail = stdout.slice(marker[0].length).trim()
447
+ if (code === 0) {
448
+ // Scan for the root line instead of trusting the first one: a warning
449
+ // ("ignoring broken ref") may precede it.
450
+ const root = detail.split('\n').map(line => line.trim()).find(line => line.startsWith('/'))
451
+ return root === undefined
452
+ ? { kind: 'failed', error: `git rev-parse printed no repository root: ${detail}` }
453
+ : { kind: 'repo', root }
454
+ }
455
+ if (/not a git repository/i.test(detail)) return { kind: 'none' }
456
+ return {
457
+ kind: 'failed',
458
+ error: detail !== '' ? detail : `git rev-parse --show-toplevel failed [exit code: ${code}]`,
459
+ }
460
+ }
461
+
402
462
  /** Repository dirs found under a workspace root. */
403
463
  export function parseRepoDiscovery(stdout: string): string[] {
404
464
  const seen = new Set<string>()