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/store.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
-
/**
|
|
1084
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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,
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
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
|
|
1124
|
-
// reports the same directory row (see `pruneDirExpansions`).
|
|
1125
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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 (
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
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
|
-
|
|
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())
|
package/src/client/vsc/vsc.css
CHANGED
|
@@ -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>()
|