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.
- package/README.en.md +15 -4
- package/README.md +13 -3
- package/cordis.patch.yml +10 -5
- package/lib/client.js +728 -94
- package/lib/index.js +2 -2
- package/lib/types/client/composer.d.ts +5 -1
- package/lib/types/client/scm/HistoryPanel.d.ts +5 -0
- package/lib/types/client/scm/actions.d.ts +25 -0
- package/lib/types/client/scm/git-cache.d.ts +93 -0
- package/lib/types/client/scm/panel-cache.d.ts +43 -0
- package/lib/types/client/scm/read-queue.d.ts +74 -0
- package/lib/types/client/store.d.ts +66 -3
- package/lib/types/git-ops.d.ts +38 -0
- package/lib/types/index.d.ts +5 -0
- package/package.json +37 -37
- package/src/client/SettingsHosts.tsx +2 -1
- package/src/client/composer.ts +11 -2
- package/src/client/i18n.ts +10 -2
- package/src/client/scm/HistoryPanel.tsx +61 -11
- package/src/client/scm/ScmPanel.tsx +123 -22
- package/src/client/scm/actions.ts +49 -6
- package/src/client/scm/git-cache.ts +159 -0
- package/src/client/scm/panel-cache.ts +93 -0
- package/src/client/scm/read-queue.ts +151 -0
- package/src/client/store.tsx +360 -60
- package/src/client/vsc/vsc.css +10 -0
- package/src/git-ops.ts +60 -0
- package/src/index.ts +10 -2
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
218
|
+
if (filter.trim() === '') return rows
|
|
170
219
|
const needle = filter.trim().toLowerCase()
|
|
171
|
-
return
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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 &&
|
|
218
|
-
{!loading &&
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|