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.
@@ -115,6 +115,10 @@ const zh: Record<string, string> = {
115
115
  'tab.git': 'Git',
116
116
  'git.hint': '请选择远端路径为 git 仓库的工作区。',
117
117
  'scm.loadingSlow': '正在读取仓库状态…大仓库首次读取可能需要数分钟',
118
+ 'scm.cached': '缓存',
119
+ 'scm.cachedRefreshing': '缓存 · 后台刷新中',
120
+ 'scm.cachedHint': '显示的是该工作区上次读取的结果,正在后台刷新;更改列表与暂存/提交等操作仍作用于这个工作区。',
121
+ 'scm.refreshFailed': '刷新失败:',
118
122
  'common.close': '关闭',
119
123
  'common.loading': '加载中…',
120
124
  'flow.title': '添加工作区',
@@ -134,7 +138,7 @@ const zh: Record<string, string> = {
134
138
  'route.off': '关闭(直通本地)',
135
139
  'route.shadow': '影子(记日志仍走本地)',
136
140
  'route.enforce': '强制(真路由)',
137
- 'route.hint': '切换即时生效并写入 profile,无需重启',
141
+ 'route.hint': '切换即时生效并写入 profile,无需重启;默认「强制(真路由)」。',
138
142
  'route.saving': '保存中…',
139
143
  'route.tombstones': '墓碑',
140
144
  'route.aliasPath': '本地别名目录(原生工作区入口)',
@@ -467,6 +471,10 @@ const en: Record<string, string> = {
467
471
  'tab.git': 'Git',
468
472
  'git.hint': 'Pick a workspace whose remote path is a git repository.',
469
473
  'scm.loadingSlow': 'Reading repository state… a large repo can take minutes on first read',
474
+ 'scm.cached': 'cached',
475
+ 'scm.cachedRefreshing': 'cached · refreshing',
476
+ 'scm.cachedHint': 'Showing this workspace’s last read while it refreshes in the background; the change list and stage/commit actions still target this workspace.',
477
+ 'scm.refreshFailed': 'Refresh failed:',
470
478
  'common.close': 'Close',
471
479
  'common.loading': 'Loading…',
472
480
  'flow.title': 'Add workspace',
@@ -486,7 +494,7 @@ const en: Record<string, string> = {
486
494
  'route.off': 'Off (local passthrough)',
487
495
  'route.shadow': 'Shadow (log only, still local)',
488
496
  'route.enforce': 'Enforce (real routing)',
489
- 'route.hint': 'Applies immediately and persists to the profile; no restart needed.',
497
+ 'route.hint': 'Applies immediately and persists to the profile; no restart needed. Default: enforce (real routing).',
490
498
  'route.saving': 'Saving…',
491
499
  'route.tombstones': 'tombstones',
492
500
  'route.aliasPath': 'Local alias dir (native workspace entry)',
@@ -7,13 +7,20 @@
7
7
  * repository shows the newest GIT_LOG_MAX commits and is filtered client-side.
8
8
  * The docstring here used to promise scroll-triggered paging that does not
9
9
  * exist, which is worse than no comment: a reader trusts it.
10
+ *
11
+ * Each repository's last answer is kept in memory (`./panel-cache.ts`), so a
12
+ * switch back to a workspace repaints its commit list at once and the log is
13
+ * re-read behind it — the tab used to blank on every switch, and worse, kept the
14
+ * PREVIOUS repository's rows until the new log arrived.
10
15
  */
11
16
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
12
17
  import type { GitCommit, GitCommitFile } from '../../protocol.ts'
13
18
  import { GIT_LOG_MAX } from '../../git-format.ts'
14
19
  import { api } from '../api.ts'
15
- import { useExplorer } from '../store.tsx'
20
+ import { gitReadSettleMs, useExplorer } from '../store.tsx'
16
21
  import { checkoutCommit, loadCommitFiles } from './actions.ts'
22
+ import { ReadQueue } from './read-queue.ts'
23
+ import { readHistorySnapshot, repoCacheKey, writeHistorySnapshot } from './panel-cache.ts'
17
24
  import {
18
25
  IconButton,
19
26
  IconChevronDown,
@@ -32,6 +39,16 @@ const LANE_W = 13
32
39
  const PAD_X = 9
33
40
  const LANE_COLORS = ['#e2b714', '#4ec9b0', '#58a6ff', '#d2a8ff', '#ff7b72', '#79c0ff', '#7ee787', '#ffa657']
34
41
 
42
+ /**
43
+ * One log read per repository at a time.
44
+ *
45
+ * Same contracts as the git status queue (`./read-queue.ts`): the automatic
46
+ * read on entering the tab `join`s one in flight, and the ⟳ button asks for a
47
+ * `fresh` one. Without it, flipping tabs while a log read runs could start a
48
+ * second `git log` of the same repository and let the slower answer repaint.
49
+ */
50
+ const logReads = new ReadQueue()
51
+
35
52
  /**
36
53
  * Ref badge decoration for one `%D` entry. Extracted from a pair of nested
37
54
  * ternaries (forbidden by this project's review checklist) that were doing two
@@ -120,10 +137,22 @@ function laneColor(lane: number): string {
120
137
  return LANE_COLORS[lane % LANE_COLORS.length] as string
121
138
  }
122
139
 
140
+ /** A stable empty list: the "this repository was never read" fallback. */
141
+ const EMPTY_COMMITS: GitCommit[] = []
142
+
123
143
  export function HistoryPanel(): ReactNode {
124
144
  const store = useExplorer()
125
145
  const { t, lang, alias, repoRoot } = store
126
146
  const [commits, setCommits] = useState<GitCommit[]>([])
147
+ /**
148
+ * The repository `commits` belongs to.
149
+ *
150
+ * A state update lands one paint AFTER the repository changes, so without this
151
+ * key the first frame after a switch would still show the PREVIOUS repository's
152
+ * rows — whose checkout buttons would then point those hashes at the new
153
+ * repository. The render-time arbitration below (`rows`) closes that frame.
154
+ */
155
+ const [commitsKey, setCommitsKey] = useState('')
127
156
  const [loading, setLoading] = useState(false)
128
157
  const [error, setError] = useState<string | undefined>(undefined)
129
158
  const [expanded, setExpanded] = useState<string | undefined>(undefined)
@@ -138,23 +167,43 @@ export function HistoryPanel(): ReactNode {
138
167
  */
139
168
  const loadSeq = useRef(0)
140
169
 
141
- const load = useCallback(async (): Promise<void> => {
170
+ const repoKey = alias === '' || repoRoot === undefined ? '' : repoCacheKey(alias, repoRoot)
171
+ /** The cached log of the repository being shown, when it was read before. */
172
+ const cachedCommits = useMemo(
173
+ () => (repoKey === '' ? undefined : readHistorySnapshot(repoKey)?.commits),
174
+ [repoKey],
175
+ )
176
+ /** What THIS frame may show: rows of this repository, or none. */
177
+ const rows = commitsKey === repoKey ? commits : (cachedCommits ?? EMPTY_COMMITS)
178
+
179
+ const load = useCallback(async (mode: 'join' | 'fresh' = 'join'): Promise<void> => {
142
180
  if (alias === '' || repoRoot === undefined) {
143
181
  loadSeq.current += 1
144
- setCommits([])
182
+ setCommitsKey('')
183
+ setCommits(EMPTY_COMMITS)
145
184
  setLoading(false)
146
185
  return
147
186
  }
187
+ const key = repoCacheKey(alias, repoRoot)
148
188
  const ticket = ++loadSeq.current
189
+ // The cached log of THIS repository (when it was read before) replaces the
190
+ // rows at once, so a switch back is instant instead of a blank tab; a
191
+ // repository never read gets an empty list, never the previous one's.
192
+ const cached = readHistorySnapshot(key)
193
+ setCommitsKey(key)
194
+ setCommits(cached?.commits ?? EMPTY_COMMITS)
149
195
  setLoading(true)
150
196
  setError(undefined)
151
197
  try {
152
- const list = await api.git.log(alias, repoRoot, GIT_LOG_MAX)
198
+ const list = await logReads.run(key, () => api.git.log(alias, repoRoot, GIT_LOG_MAX), mode, gitReadSettleMs())
199
+ writeHistorySnapshot(key, { commits: list, at: Date.now() })
153
200
  if (loadSeq.current !== ticket) return
201
+ setCommitsKey(key)
154
202
  setCommits(list)
155
203
  } catch (caught) {
156
204
  if (loadSeq.current !== ticket) return
157
- setCommits([])
205
+ // A failed re-read keeps what is on screen (it was a real read) and says
206
+ // why: blanking the tab would hide that anything went wrong.
158
207
  setError(caught instanceof Error ? caught.message : String(caught))
159
208
  } finally {
160
209
  if (loadSeq.current === ticket) setLoading(false)
@@ -166,13 +215,13 @@ export function HistoryPanel(): ReactNode {
166
215
  }, [load])
167
216
 
168
217
  const visible = useMemo(() => {
169
- if (filter.trim() === '') return commits
218
+ if (filter.trim() === '') return rows
170
219
  const needle = filter.trim().toLowerCase()
171
- return commits.filter(commit =>
220
+ return rows.filter(commit =>
172
221
  commit.subject.toLowerCase().includes(needle)
173
222
  || commit.author.toLowerCase().includes(needle)
174
223
  || commit.hash.startsWith(needle))
175
- }, [commits, filter])
224
+ }, [rows, filter])
176
225
 
177
226
  const graph = useMemo(() => layoutLanes(visible), [visible])
178
227
 
@@ -199,7 +248,8 @@ export function HistoryPanel(): ReactNode {
199
248
  <div className="rvs-header">
200
249
  <span className="rvs-title">{t('tab.history')}</span>
201
250
  {head !== '' && <span className="rvs-pill">⑂ {store.head?.detached === true ? t('scm.detached') : head}</span>}
202
- <IconButton title={t('scm.refresh')} onClick={() => { void load() }} disabled={loading}>
251
+ {loading && <MiniSpinner />}
252
+ <IconButton title={t('scm.refresh')} onClick={() => { void load('fresh') }} disabled={loading}>
203
253
  <IconRefresh size={14} />
204
254
  </IconButton>
205
255
  </div>
@@ -214,8 +264,8 @@ export function HistoryPanel(): ReactNode {
214
264
  </div>
215
265
  {error !== undefined && <div className="rvs-banner error">{error}</div>}
216
266
  <div className="rvs-scroll">
217
- {loading && commits.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
218
- {!loading && commits.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
267
+ {loading && rows.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
268
+ {!loading && rows.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
219
269
  {graph.rows.map((row, index) => {
220
270
  const commit = row.commit
221
271
  const isOpen = expanded === commit.hash
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
12
12
  import type { GitBranch, GitRemote, GitStash } from '../../protocol.ts'
13
- import { useExplorer } from '../store.tsx'
13
+ import { gitReadSettleMs, useExplorer } from '../store.tsx'
14
14
  import {
15
15
  branchOp,
16
16
  commitAll,
@@ -26,6 +26,8 @@ import {
26
26
  } from './actions.ts'
27
27
  import type { ChangeEntry } from './status-map.ts'
28
28
  import { rowVerb } from './status-map.ts'
29
+ import { ReadQueue } from './read-queue.ts'
30
+ import { readScmExtras, repoCacheKey, writeScmExtras, type ScmExtrasSnapshot } from './panel-cache.ts'
29
31
  import { RepoPicker } from './RepoPicker.tsx'
30
32
  import {
31
33
  IconBranch,
@@ -55,6 +57,22 @@ import {
55
57
 
56
58
  const MSG_KEY = 'dsh-remote-vs.commitMsg'
57
59
 
60
+ /**
61
+ * One extras read per repository at a time.
62
+ *
63
+ * The panel can ask for the same three lists twice at once — the visibility
64
+ * effect when it comes back on screen and the ⟳ refresh — and two identical
65
+ * `for-each-ref` / `stash list` walks of a monorepo are pure waste. Same
66
+ * contracts as the git reads (`scm/read-queue.ts`): `join` for the automatic
67
+ * path, `fresh` for a manual refresh.
68
+ */
69
+ const extrasReads = new ReadQueue()
70
+
71
+ /** Stable empty lists: "this repository's side lists are not loaded (yet)". */
72
+ const NO_STASHES: GitStash[] = []
73
+ const NO_BRANCHES: GitBranch[] = []
74
+ const NO_REMOTES: GitRemote[] = []
75
+
58
76
  function messageOf(error: unknown): string {
59
77
  return error instanceof GitOpError || error instanceof Error ? error.message : String(error)
60
78
  }
@@ -74,6 +92,15 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
74
92
  const [stashes, setStashes] = useState<GitStash[]>([])
75
93
  const [branches, setBranches] = useState<GitBranch[]>([])
76
94
  const [remotes, setRemotes] = useState<GitRemote[]>([])
95
+ /**
96
+ * The repository the three lists above belong to (`repoCacheKey`).
97
+ *
98
+ * The setState that adopts a repository's lists lands one paint AFTER the
99
+ * switch, so a render-time merge (see `extras` below) is what keeps the
100
+ * PREVIOUS repository's stash rows off the screen — their buttons act through
101
+ * the CURRENT `repoRoot`, i.e. on the wrong repository.
102
+ */
103
+ const [listedRepoKey, setListedRepoKey] = useState('')
77
104
  const [branchOpen, setBranchOpen] = useState(false)
78
105
  const [remoteOpen, setRemoteOpen] = useState(false)
79
106
  const [newBranch, setNewBranch] = useState('')
@@ -92,22 +119,45 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
92
119
  }
93
120
  }, [message])
94
121
 
95
- const loadExtras = useCallback(async (): Promise<void> => {
122
+ /**
123
+ * The repository the extras on screen belong to (`repoCacheKey`).
124
+ *
125
+ * A read that is still in flight when the workspace changes must not paint its
126
+ * answer over the new repository's lists: the panel is kept alive across
127
+ * switches, so the response needs to be checked against where the panel is
128
+ * NOW, not where it was when the request went out.
129
+ */
130
+ const extrasKeyRef = useRef('')
131
+
132
+ const loadExtras = useCallback(async (mode: 'join' | 'fresh' = 'join'): Promise<void> => {
96
133
  if (alias === '' || repoRoot === undefined) {
97
- setStashes([])
98
- setBranches([])
99
- setRemotes([])
134
+ extrasKeyRef.current = ''
135
+ setListedRepoKey('')
136
+ setStashes(NO_STASHES)
137
+ setBranches(NO_BRANCHES)
138
+ setRemotes(NO_REMOTES)
100
139
  return
101
140
  }
141
+ const repoKey = repoCacheKey(alias, repoRoot)
142
+ extrasKeyRef.current = repoKey
102
143
  try {
103
- const [stashList, branchList, remoteList] = await Promise.all([
104
- loadStashes(alias, repoRoot),
105
- loadBranches(alias, repoRoot, head?.branch ?? ''),
106
- loadRemotes(alias, repoRoot),
107
- ])
108
- setStashes(stashList)
109
- setBranches(branchList)
110
- setRemotes(remoteList)
144
+ const snapshot = await extrasReads.run(repoKey, async (): Promise<ScmExtrasSnapshot> => {
145
+ const [stashList, branchList, remoteList] = await Promise.all([
146
+ loadStashes(alias, repoRoot),
147
+ loadBranches(alias, repoRoot, head?.branch ?? ''),
148
+ loadRemotes(alias, repoRoot),
149
+ ])
150
+ const answer: ScmExtrasSnapshot = { stashes: stashList, branches: branchList, remotes: remoteList, at: Date.now() }
151
+ // Remember the answer: coming back to this repository repaints these
152
+ // lists immediately instead of blanking three sections again.
153
+ writeScmExtras(repoKey, answer)
154
+ return answer
155
+ }, mode, gitReadSettleMs())
156
+ if (extrasKeyRef.current !== repoKey) return
157
+ setListedRepoKey(repoKey)
158
+ setStashes(snapshot.stashes)
159
+ setBranches(snapshot.branches)
160
+ setRemotes(snapshot.remotes)
111
161
  } catch {
112
162
  // The panel degrades: status stays usable without the extras.
113
163
  }
@@ -130,12 +180,46 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
130
180
  // change made while parked still reads as pending.
131
181
  if (props.visible === false || listedExtras.current === extrasKey) return
132
182
  listedExtras.current = extrasKey
133
- void loadExtras()
134
- }, [extrasKey, loadExtras, props.visible])
183
+ const repoKey = repoRoot === undefined ? '' : repoCacheKey(alias, repoRoot)
184
+ extrasKeyRef.current = repoKey
185
+ // Repaint the cached lists of this repository BEFORE the read: a switch back
186
+ // must not blank the stash / branch / remote sections while their own three
187
+ // git commands run (see `scm/panel-cache.ts`).
188
+ const cached = repoKey === '' ? undefined : readScmExtras(repoKey)
189
+ if (cached !== undefined) {
190
+ setListedRepoKey(repoKey)
191
+ setStashes(cached.stashes)
192
+ setBranches(cached.branches)
193
+ setRemotes(cached.remotes)
194
+ }
195
+ void loadExtras('join')
196
+ }, [extrasKey, loadExtras, props.visible, alias, repoRoot])
197
+
198
+ /** The repository the panel is showing the side lists of. */
199
+ const extrasRepoKey = repoRoot === undefined ? '' : repoCacheKey(alias, repoRoot)
200
+ /** Its cached lists, when it was read before (stable across paints). */
201
+ const cachedExtras = useMemo(
202
+ () => (extrasRepoKey === '' ? undefined : readScmExtras(extrasRepoKey)),
203
+ [extrasRepoKey],
204
+ )
205
+ /**
206
+ * What THIS frame may show.
207
+ *
208
+ * A setState adopting a repository's lists lands one paint after the switch, so
209
+ * the merge is what stops the PREVIOUS repository's stash/branch rows (whose
210
+ * buttons run against the CURRENT `repoRoot`) from ever appearing under the
211
+ * new repository. This repository's cache, when present, fills that frame;
212
+ * otherwise the sections are simply empty until its own read adopts them.
213
+ */
214
+ const extras = listedRepoKey === extrasRepoKey
215
+ ? { stashes, branches, remotes }
216
+ : (cachedExtras ?? { stashes: NO_STASHES, branches: NO_BRANCHES, remotes: NO_REMOTES })
135
217
 
136
218
  const refreshAll = useCallback(async (): Promise<void> => {
219
+ // The ⟳ / post-verb refresh must describe the state as of NOW: `afterWrite`
220
+ // runs a `fresh` status read and the extras are forced to re-read as well.
137
221
  await store.afterWrite()
138
- await loadExtras()
222
+ await loadExtras('fresh')
139
223
  }, [store, loadExtras])
140
224
 
141
225
  /**
@@ -459,6 +543,17 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
459
543
  <div className="rvs-header">
460
544
  <span className="rvs-title">{t('scm.title')}</span>
461
545
  {store.gitBusy && <MiniSpinner />}
546
+ {/*
547
+ A cached slice is shown so the switch is instant, and the background
548
+ read can take minutes on a monorepo: saying so is what stops an old
549
+ change list from being read as the current one. The actions stay
550
+ enabled — they target this binding's repository, cache or not.
551
+ */}
552
+ {store.gitCached && (
553
+ <span className="rvs-pill rvs-pill-cached" title={t('scm.cachedHint')}>
554
+ {store.gitBusy ? t('scm.cachedRefreshing') : t('scm.cached')}
555
+ </span>
556
+ )}
462
557
  <IconButton title={t('scm.refresh')} onClick={() => { void refreshAll() }} disabled={store.gitBusy}>
463
558
  <IconRefresh size={14} />
464
559
  </IconButton>
@@ -503,6 +598,12 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
503
598
  }}
504
599
  />
505
600
  )}
601
+ {/* A failed background read keeps the last good / cached list on screen
602
+ (see `paintGit`); the reason has to be visible, or the panel would look
603
+ current while nothing is being refreshed. */}
604
+ {repoRoot !== undefined && store.gitError !== undefined && (
605
+ <div className="rvs-banner error">{t('scm.refreshFailed')} {store.gitError}</div>
606
+ )}
506
607
  <div className="rvs-scroll">
507
608
  {repoRoot === undefined && (
508
609
  <div className="rvs-empty">
@@ -642,20 +743,20 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
642
743
  {section(
643
744
  'stash',
644
745
  t('scm.stashes'),
645
- stashes.length,
746
+ extras.stashes.length,
646
747
  <>
647
748
  <IconButton title={t('scm.stashSave')} disabled={busy !== undefined} onClick={() => { void saveStash() }}>
648
749
  <IconPlus size={13} />
649
750
  </IconButton>
650
- <IconButton title={t('scm.refresh')} disabled={busy !== undefined} onClick={() => { void loadExtras() }}>
751
+ <IconButton title={t('scm.refresh')} disabled={busy !== undefined} onClick={() => { void loadExtras('fresh') }}>
651
752
  <IconRefresh size={13} />
652
753
  </IconButton>
653
754
  </>,
654
- () => (stashes.length === 0
755
+ () => (extras.stashes.length === 0
655
756
  ? <div className="rvs-empty">{t('scm.noStash')}</div>
656
757
  : (
657
758
  <div>
658
- {stashes.map(item => (
759
+ {extras.stashes.map(item => (
659
760
  <div key={item.ref} className="rvs-scm-item" style={{ cursor: 'default' }} title={`${item.ref} ${fullTime(item.date)}`}>
660
761
  <span className="rvs-node-icon"><IconCheck size={13} /></span>
661
762
  <span className="rvs-scm-path">{item.message === '' ? item.ref : item.message}</span>
@@ -745,7 +846,7 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
745
846
  </button>
746
847
  </div>
747
848
  <div style={{ marginTop: 6, maxHeight: 200, overflow: 'auto' }}>
748
- {branches.map(branch => (
849
+ {extras.branches.map(branch => (
749
850
  <div key={branch.name} className="rvs-scm-item" style={{ cursor: 'default', paddingLeft: 4 }} title={branch.short}>
750
851
  <span className="rvs-node-icon">{branch.head ? '➤' : ''}</span>
751
852
  <span className="rvs-scm-path">
@@ -830,7 +931,7 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
830
931
  <span className="rvs-einfo-spacer" />
831
932
  <IconButton title={t('common.close')} onClick={() => setRemoteOpen(false)}><IconClose size={11} /></IconButton>
832
933
  </div>
833
- {remotes.map(remote => (
934
+ {extras.remotes.map(remote => (
834
935
  <div key={`${remote.name}:${remote.kind}`} className="rvs-scm-item" style={{ cursor: 'default', paddingLeft: 4 }}>
835
936
  <span className="rvs-scm-path" title={remote.url}>{remote.name} <span className="rvs-scm-dir">{remote.kind}</span></span>
836
937
  {remote.kind === 'fetch' && (
@@ -42,6 +42,8 @@ import {
42
42
  renameBranchCommand,
43
43
  repoDiscoveryCommand,
44
44
  repoProbeCommand,
45
+ repoProbeDetailCommand,
46
+ parseRepoProbe,
45
47
  restoreWorktreeCommand,
46
48
  setRemoteUrlCommand,
47
49
  stageCommand,
@@ -81,16 +83,21 @@ export function joinRemote(dir: string, rel: string): string {
81
83
  const normalTimeoutMs = (): number => gitTimeoutMs()
82
84
  const longTimeoutMs = (): number => Math.min(MAX_TIMEOUT_MS, Math.max(DEFAULT_GIT_TIMEOUT_MS, gitTimeoutMs() * 2))
83
85
 
84
- /** Run one command, raising a readable error on a non-zero exit. */
85
- async function run(alias: string, command: string, timeoutMs = normalTimeoutMs()): Promise<RemoteExecResult> {
86
- const result = await api.exec(alias, command, timeoutMs)
87
- if (result.success) return result
86
+ /** The readable error for a failed exec result (`run` and the strict probe share it). */
87
+ function failureOf(result: RemoteExecResult, timeoutMs: number): GitOpError {
88
88
  if (result.timedOut) {
89
- throw new GitOpError(`git timed out after ${timeoutSeconds(timeoutMs)} — the repository may be large or the host busy; raise gitTimeoutMs in the plugin settings`)
89
+ return new GitOpError(`git timed out after ${timeoutSeconds(timeoutMs)} — the repository may be large or the host busy; raise gitTimeoutMs in the plugin settings`)
90
90
  }
91
91
  const hint = gitErrorHint(result.stderr) ?? gitErrorHint(result.stdout)
92
92
  const detail = result.stderr.trim() !== '' ? result.stderr.trim() : (result.stdout.trim() !== '' ? result.stdout.trim() : '')
93
- throw new GitOpError(hint ?? detail ?? result.error ?? `git failed [exit code: ${result.exitCode ?? '?'}]`)
93
+ return new GitOpError(hint ?? detail ?? result.error ?? `git failed [exit code: ${result.exitCode ?? '?'}]`)
94
+ }
95
+
96
+ /** Run one command, raising a readable error on a non-zero exit. */
97
+ async function run(alias: string, command: string, timeoutMs = normalTimeoutMs()): Promise<RemoteExecResult> {
98
+ const result = await api.exec(alias, command, timeoutMs)
99
+ if (result.success) return result
100
+ throw failureOf(result, timeoutMs)
94
101
  }
95
102
 
96
103
  /** Run one command tolerating failure (returns '' instead). */
@@ -111,6 +118,42 @@ export async function resolveRepoRoot(alias: string, workspaceRoot: string): Pro
111
118
  return parseRepoRoot(out)
112
119
  }
113
120
 
121
+ /**
122
+ * The same probe, telling "not a repository" apart from "the probe failed".
123
+ *
124
+ * `git rev-parse --show-toplevel` exits non-zero BOTH when the directory is not
125
+ * inside a work tree and for every way a remote command can fail (host gone,
126
+ * budget blown, sshd refusing, no answer at all). `resolveRepoRoot` folds the two
127
+ * into `undefined`, which is right for the workspace wizard — there "unknown" and
128
+ * "none" both mean "nothing to offer". The SCM panel cannot take that shortcut:
129
+ * treating a failed probe as "no repository" writes an EMPTY snapshot over the
130
+ * cached change list and tells the user their workspace has no git repository.
131
+ * That is exactly the blank state the snapshot cache exists to prevent, so this
132
+ * variant asks a command that KEEPS the answer (`repoProbeDetailCommand`: exit
133
+ * code + merged streams, `LC_ALL=C`) and reports the failure to the caller.
134
+ */
135
+ export type RepoRootProbe =
136
+ | { kind: 'repo'; root: string }
137
+ | { kind: 'none' }
138
+ | { kind: 'failed'; error: string }
139
+
140
+ /** The repository root, or WHY the probe could not answer. */
141
+ export async function probeRepoRoot(alias: string, workspaceRoot: string): Promise<RepoRootProbe> {
142
+ let result: RemoteExecResult
143
+ try {
144
+ result = await api.exec(alias, repoProbeDetailCommand(workspaceRoot), normalTimeoutMs())
145
+ } catch (error) {
146
+ // The transport/route failed before any git exit code existed.
147
+ return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }
148
+ }
149
+ if (!result.success) {
150
+ // The exec route itself failed (timeout, transport, no answer): the command
151
+ // never produced its status line, so nothing about the repository is known.
152
+ return { kind: 'failed', error: failureOf(result, normalTimeoutMs()).message }
153
+ }
154
+ return parseRepoProbe(result.stdout)
155
+ }
156
+
114
157
  /** Repositories under a workspace root (the workspace itself sorts first). */
115
158
  export async function findRepos(alias: string, workspaceRoot: string): Promise<string[]> {
116
159
  const direct = await resolveRepoRoot(alias, workspaceRoot)
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Last-read git state, remembered per workspace AND per repository directory.
3
+ *
4
+ * The complaint this module answers: switching between two remote workspaces
5
+ * blanked the source-control panel. The store dropped `status` / `head` /
6
+ * `repoRoot` on the switch and started a full `git status` again — minutes of
7
+ * "Loading…" on a large repository — even though BOTH workspaces had just been
8
+ * read, so the very data the panel needed was already in memory. It simply had
9
+ * no home: the store keeps one git slice, describing whatever is on screen.
10
+ *
11
+ * A snapshot is that home. It holds everything the panel paints from, keyed by
12
+ * the binding id AND the workspace-relative directory git ran in, because one
13
+ * workspace can carry several repositories and their states must never
14
+ * overwrite each other (the repo picker switches between them). The store
15
+ * hydrates a snapshot the moment a workspace is selected — in the same frame as
16
+ * the dropdown change — and refreshes it in the background; the read replaces
17
+ * the snapshot and repaints only when it is still the current binding's answer.
18
+ *
19
+ * MEMORY ONLY, on purpose: a `-u normal -z --ignored=matching` status of a
20
+ * monorepo is hundreds of kilobytes, and after a page reload it would describe
21
+ * a tree that may have moved on. The cache is an accelerator for switching, not
22
+ * a second source of truth — every value in it was read by a real command.
23
+ */
24
+ import type { GitStatusDetail } from '../../protocol.ts'
25
+ import type { DirExpansions } from './untracked-dirs.ts'
26
+
27
+ /**
28
+ * Branch header, structurally identical to the store's `HeadInfo`.
29
+ *
30
+ * Declared here instead of imported from `store.tsx` so this module stays a
31
+ * standalone leaf: `store` imports it, and a type-only back-import would pull
32
+ * the whole store into the module graph of every gate that touches the cache.
33
+ */
34
+ export interface GitHead {
35
+ branch: string
36
+ detached: boolean
37
+ ahead: number
38
+ behind: number
39
+ }
40
+
41
+ /** Everything one git read answered, as the panels need it. */
42
+ export interface GitSnapshot {
43
+ /** Enclosing repository root, or undefined when the directory is no repo. */
44
+ repoRoot: string | undefined
45
+ /** Workspace-relative directory git ran in (the settled one). */
46
+ repoSubdir: string
47
+ status: GitStatusDetail
48
+ head: GitHead | undefined
49
+ /** git answered "not a repository" — a fact, not a failure. */
50
+ repoMissing: boolean
51
+ /** Expanded untracked directories, repository-relative. */
52
+ untrackedDirs: DirExpansions
53
+ /** `Date.now()` when the read that produced this snapshot finished. */
54
+ at: number
55
+ }
56
+
57
+ /**
58
+ * Separator between the binding id and the repository directory.
59
+ *
60
+ * NUL cannot appear in either half: a binding id comes from the host's table
61
+ * and a workspace-relative sub-directory is a path. The key must be reversible
62
+ * because `retainGitSnapshots` deletes everything whose binding is gone.
63
+ */
64
+ const KEY_SEP = '\u0000'
65
+
66
+ /** Cache key for one (workspace, repository directory) pair. */
67
+ export function gitCacheKey(bindingId: string, repoSubdir: string): string {
68
+ return `${bindingId}${KEY_SEP}${repoSubdir}`
69
+ }
70
+
71
+ /** Split a cache key back into its two halves (undefined when it is not one). */
72
+ export function parseGitCacheKey(key: string): { bindingId: string; repoSubdir: string } | undefined {
73
+ const at = key.indexOf(KEY_SEP)
74
+ if (at < 0) return undefined
75
+ return { bindingId: key.slice(0, at), repoSubdir: key.slice(at + 1) }
76
+ }
77
+
78
+ /**
79
+ * How many (workspace, repository) pairs stay cached.
80
+ *
81
+ * Bindings are few (the workspace table is hand-managed) and each snapshot is
82
+ * the panel's whole change list, so a small bound is plenty; the LRU trim only
83
+ * exists so a long-lived page cannot grow without limit.
84
+ */
85
+ const SNAPSHOT_LIMIT = 12
86
+
87
+ /** Snapshots by cache key, in recency order (oldest insertion first). */
88
+ const snapshots = new Map<string, GitSnapshot>()
89
+
90
+ /** The cached read for one (workspace, repository directory), if any. */
91
+ export function readGitSnapshot(key: string): GitSnapshot | undefined {
92
+ const snapshot = snapshots.get(key)
93
+ if (snapshot === undefined) return undefined
94
+ // Touch: keep the LRU honest without changing the snapshot's own `at`.
95
+ snapshots.delete(key)
96
+ snapshots.set(key, snapshot)
97
+ return snapshot
98
+ }
99
+
100
+ /**
101
+ * Remember one read.
102
+ *
103
+ * Called even when the read is no longer the binding on screen: the walk has
104
+ * already been paid for, and caching it is what makes the NEXT switch back
105
+ * instant instead of another minutes-long wait.
106
+ *
107
+ * An answer OLDER than the one already cached is dropped. Two reads of one
108
+ * workspace can overlap when the repo picker changes the directory mid-read (the
109
+ * settled directory is a second key, see the store's `readGit`), and the older
110
+ * walk may finish last — letting it win would hand the user a change list that
111
+ * predates the one already shown.
112
+ */
113
+ export function writeGitSnapshot(key: string, snapshot: GitSnapshot): void {
114
+ const previous = snapshots.get(key)
115
+ if (previous !== undefined && previous.at > snapshot.at) return
116
+ snapshots.delete(key)
117
+ snapshots.set(key, snapshot)
118
+ while (snapshots.size > SNAPSHOT_LIMIT) {
119
+ const oldest = snapshots.keys().next()
120
+ if (oldest.done === true) break
121
+ snapshots.delete(oldest.value)
122
+ }
123
+ }
124
+
125
+ /** Drop every snapshot whose workspace left the binding table. */
126
+ export function retainGitSnapshots(bindingIds: ReadonlySet<string>): void {
127
+ for (const key of [...snapshots.keys()]) {
128
+ const parsed = parseGitCacheKey(key)
129
+ if (parsed === undefined || !bindingIds.has(parsed.bindingId)) snapshots.delete(key)
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Write a locally-known status into a cached snapshot.
135
+ *
136
+ * The plotted stage/unstage move is applied to the store's status WITHOUT a
137
+ * repository-wide read (see `applyLocalStage`). Without this write-through the
138
+ * cache would keep the pre-click list, so switching away and back would flip
139
+ * the row back — the panel would show a change the user already staged.
140
+ * A different `repoRoot` means the snapshot is not that repository: refusing is
141
+ * what keeps one workspace's rows out of another's cache.
142
+ */
143
+ export function patchCachedGitStatus(key: string, repoRoot: string | undefined, status: GitStatusDetail): void {
144
+ const snapshot = snapshots.get(key)
145
+ if (snapshot === undefined || snapshot.repoRoot !== repoRoot) return
146
+ snapshots.set(key, { ...snapshot, status })
147
+ }
148
+
149
+ /** Same write-through for the expanded untracked directories. */
150
+ export function patchCachedUntrackedDirs(key: string, repoRoot: string | undefined, dirs: DirExpansions): void {
151
+ const snapshot = snapshots.get(key)
152
+ if (snapshot === undefined || snapshot.repoRoot !== repoRoot) return
153
+ snapshots.set(key, { ...snapshot, untrackedDirs: dirs })
154
+ }
155
+
156
+ /** Test hook: forget every snapshot (the cache gate drives this module alone). */
157
+ export function clearGitSnapshots(): void {
158
+ snapshots.clear()
159
+ }