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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repository caches for the two views that live beside the change list:
|
|
3
|
+
* the source-control panel's side lists (stash / branches / remotes) and the
|
|
4
|
+
* history tab's commit log.
|
|
5
|
+
*
|
|
6
|
+
* Same reasoning as `git-cache.ts`, one level down: the git status snapshot
|
|
7
|
+
* already makes the change list paint instantly when a workspace is selected
|
|
8
|
+
* again, and these caches stop the second wave of blanks. The extras are three
|
|
9
|
+
* more git commands per repository switch; the log is up to 200 commits (`git
|
|
10
|
+
* log` on a deep monorepo is not free). Both are read-only views of state the
|
|
11
|
+
* host already answered — keeping the last answer costs nothing and is what the
|
|
12
|
+
* panels show while their background re-read runs.
|
|
13
|
+
*
|
|
14
|
+
* MEMORY ONLY, same contract as the git snapshots, and every entry is replaced
|
|
15
|
+
* by a real read; nothing is ever synthesised here.
|
|
16
|
+
*/
|
|
17
|
+
import type { GitBranch, GitCommit, GitRemote, GitStash } from '../../protocol.ts'
|
|
18
|
+
|
|
19
|
+
/** Stash / branch / remote lists of one repository (one panel refresh). */
|
|
20
|
+
export interface ScmExtrasSnapshot {
|
|
21
|
+
stashes: GitStash[]
|
|
22
|
+
branches: GitBranch[]
|
|
23
|
+
remotes: GitRemote[]
|
|
24
|
+
/** `Date.now()` when the read finished. */
|
|
25
|
+
at: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The history tab's commit list of one repository. */
|
|
29
|
+
export interface HistorySnapshot {
|
|
30
|
+
commits: GitCommit[]
|
|
31
|
+
/** `Date.now()` when the read finished. */
|
|
32
|
+
at: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Separator for the composite key (cannot appear in an alias or a POSIX path). */
|
|
36
|
+
const KEY_SEP = '\u0000'
|
|
37
|
+
|
|
38
|
+
/** How many repositories each cache keeps; the LRU trim is only a bound. */
|
|
39
|
+
const ENTRY_LIMIT = 12
|
|
40
|
+
|
|
41
|
+
/** Cache key of one repository, as the git panel addresses it: alias + root. */
|
|
42
|
+
export function repoCacheKey(alias: string, repoRoot: string): string {
|
|
43
|
+
return `${alias}${KEY_SEP}${repoRoot}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Insert-or-touch one entry, then trim the oldest beyond the bound. */
|
|
47
|
+
function remember<T>(cache: Map<string, T>, key: string, value: T): void {
|
|
48
|
+
cache.delete(key)
|
|
49
|
+
cache.set(key, value)
|
|
50
|
+
while (cache.size > ENTRY_LIMIT) {
|
|
51
|
+
const oldest = cache.keys().next()
|
|
52
|
+
if (oldest.done === true) break
|
|
53
|
+
cache.delete(oldest.value)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read-or-touch one entry (LRU order without changing the value). */
|
|
58
|
+
function recall<T>(cache: Map<string, T>, key: string): T | undefined {
|
|
59
|
+
const value = cache.get(key)
|
|
60
|
+
if (value === undefined) return undefined
|
|
61
|
+
cache.delete(key)
|
|
62
|
+
cache.set(key, value)
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const extrasCache = new Map<string, ScmExtrasSnapshot>()
|
|
67
|
+
const historyCache = new Map<string, HistorySnapshot>()
|
|
68
|
+
|
|
69
|
+
/** The cached side lists of one repository, if it was read before. */
|
|
70
|
+
export function readScmExtras(key: string): ScmExtrasSnapshot | undefined {
|
|
71
|
+
return recall(extrasCache, key)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Remember one repository's side lists. */
|
|
75
|
+
export function writeScmExtras(key: string, snapshot: ScmExtrasSnapshot): void {
|
|
76
|
+
remember(extrasCache, key, snapshot)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The cached commit log of one repository, if it was read before. */
|
|
80
|
+
export function readHistorySnapshot(key: string): HistorySnapshot | undefined {
|
|
81
|
+
return recall(historyCache, key)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remember one repository's commit log. */
|
|
85
|
+
export function writeHistorySnapshot(key: string, snapshot: HistorySnapshot): void {
|
|
86
|
+
remember(historyCache, key, snapshot)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Test hook: forget both caches (the panel caches carry no other state). */
|
|
90
|
+
export function clearPanelCaches(): void {
|
|
91
|
+
extrasCache.clear()
|
|
92
|
+
historyCache.clear()
|
|
93
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One remote read at a time, per key — with two honest contracts.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Git reads are expensive on a large repository: `loadStatusDetail` walks the
|
|
7
|
+
* whole index and worktree (minutes on the AOSP monorepo), and several surfaces
|
|
8
|
+
* can ask for one at the same moment — the workspace dropdown, the session
|
|
9
|
+
* follow, the panel's ⟳, and every write made through the plugin. Until now each
|
|
10
|
+
* caller simply started its own command and the newest one won the paint, which
|
|
11
|
+
* meant two concurrent walks of the same tree and a panel that could not tell
|
|
12
|
+
* which answer was current.
|
|
13
|
+
*
|
|
14
|
+
* The queue replaces that with:
|
|
15
|
+
*
|
|
16
|
+
* - `join` — "show me a read of this key". A read already in flight is shared:
|
|
17
|
+
* starting a second one would only duplicate the same walk, so a
|
|
18
|
+
* workspace switch costs nothing when the previous read of that
|
|
19
|
+
* workspace is still running.
|
|
20
|
+
* - `fresh` — "the answer must describe the state as of NOW" (the ⟳ button, and
|
|
21
|
+
* every write: a save, a commit, a stage verb). It never shares a
|
|
22
|
+
* read that started before the call: it waits for the running one to
|
|
23
|
+
* settle and then starts its own. Two `fresh` callers that arrive
|
|
24
|
+
* during the same read share the SINGLE follow-up read, because the
|
|
25
|
+
* second one can see that the follow-up started after its own call.
|
|
26
|
+
*
|
|
27
|
+
* Reads for DIFFERENT keys run in parallel: two workspaces are two repositories,
|
|
28
|
+
* and making one wait for the other would make a switch feel dead. Nothing here
|
|
29
|
+
* paints anything — the caller decides what its answer may overwrite — so the
|
|
30
|
+
* queue carries no store/React knowledge and is directly testable.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** How a caller relates to a read that is already in flight. */
|
|
34
|
+
export type ReadMode = 'join' | 'fresh'
|
|
35
|
+
|
|
36
|
+
/** One in-flight read. `id` orders it against the client's other calls. */
|
|
37
|
+
interface Flight {
|
|
38
|
+
id: number
|
|
39
|
+
promise: Promise<unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bound a read by a hard settle deadline.
|
|
44
|
+
*
|
|
45
|
+
* A remote call can hang without ever settling — a stuck SSH round trip, and the
|
|
46
|
+
* change ledger has measured exactly that on a monorepo. Without a bound, the
|
|
47
|
+
* flight would stay registered forever: every later `join` would reuse the dead
|
|
48
|
+
* promise, every `fresh` would wait for it, and the owner's busy flag would stay
|
|
49
|
+
* up, so ONE hung host call would freeze that key for the rest of the page (the
|
|
50
|
+
* pre-cache code never locked up: each refresh was independent and could fail on
|
|
51
|
+
* its own). The deadline rejects the FLIGHT — releasing the key — while the
|
|
52
|
+
* caller's failure handler owns the message; the underlying read may still
|
|
53
|
+
* complete later, and nothing it paints was waiting on it.
|
|
54
|
+
*/
|
|
55
|
+
function withSettleDeadline<T>(read: () => Promise<T>, ms: number): () => Promise<T> {
|
|
56
|
+
return () => {
|
|
57
|
+
const pending = read()
|
|
58
|
+
// `Promise.race` already attaches a handler to both racers, so this is not
|
|
59
|
+
// what makes the deadline safe; it only keeps the console quiet when the
|
|
60
|
+
// abandoned read rejects AFTER the deadline already won.
|
|
61
|
+
pending.catch(() => undefined)
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
63
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
64
|
+
timer = setTimeout(() => {
|
|
65
|
+
reject(new Error(`reading did not settle within ${Math.round(ms / 1000)}s — the host call may be stuck`))
|
|
66
|
+
}, ms)
|
|
67
|
+
timer.unref?.()
|
|
68
|
+
})
|
|
69
|
+
return Promise.race([pending, deadline]).finally(() => {
|
|
70
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ReadQueue {
|
|
76
|
+
/** In-flight reads by key (insertion order is irrelevant here). */
|
|
77
|
+
private readonly flights = new Map<string, Flight>()
|
|
78
|
+
private nextId = 0
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param onChange - called whenever a read starts or settles, so the owner can
|
|
82
|
+
* derive a "busy" flag from `inflightKeys()` without polling.
|
|
83
|
+
*/
|
|
84
|
+
constructor(private readonly onChange?: () => void) {}
|
|
85
|
+
|
|
86
|
+
/** The keys with a read in flight (a caller derives its busy state from this). */
|
|
87
|
+
inflightKeys(): string[] {
|
|
88
|
+
return [...this.flights.keys()]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True while ONE key has a read in flight. */
|
|
92
|
+
isBusy(key: string): boolean {
|
|
93
|
+
return this.flights.has(key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run `read` for `key`, honouring `mode`.
|
|
98
|
+
*
|
|
99
|
+
* The returned promise settles with this key's answer: a rejection comes from
|
|
100
|
+
* the read the caller ended up waiting on (its own, or the one it joined).
|
|
101
|
+
* `read` must be equivalent for every caller that passes the same key — the
|
|
102
|
+
* queue keeps only the FIRST caller's closure, because a joined read cannot
|
|
103
|
+
* carry a second closure.
|
|
104
|
+
*
|
|
105
|
+
* @param settleMs - hard settle deadline for a read THIS call starts (`0` =
|
|
106
|
+
* unbounded). A joined read keeps the deadline it was started with: its
|
|
107
|
+
* caller already decided how long that read may take.
|
|
108
|
+
*/
|
|
109
|
+
run<T>(key: string, read: () => Promise<T>, mode: ReadMode = 'join', settleMs = 0): Promise<T> {
|
|
110
|
+
const current = this.flights.get(key)
|
|
111
|
+
if (current === undefined) return this.start(key, read, settleMs)
|
|
112
|
+
if (mode === 'join') return current.promise as Promise<T>
|
|
113
|
+
return this.after(current, key, read, settleMs)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `fresh` while a read is in flight: wait for it, then read once more.
|
|
118
|
+
*
|
|
119
|
+
* The follow-up is NOT started from here unconditionally, or two `fresh`
|
|
120
|
+
* callers that arrived during the same read would each start one (two
|
|
121
|
+
* concurrent walks, exactly what this class exists to prevent). The second
|
|
122
|
+
* caller instead finds a flight whose id is newer than the one it waited on —
|
|
123
|
+
* a read that started after its own call — and shares it.
|
|
124
|
+
*/
|
|
125
|
+
private async after<T>(flight: Flight, key: string, read: () => Promise<T>, settleMs: number): Promise<T> {
|
|
126
|
+
await flight.promise.catch(() => undefined)
|
|
127
|
+
const current = this.flights.get(key)
|
|
128
|
+
if (current !== undefined && current.id > flight.id) return current.promise as Promise<T>
|
|
129
|
+
return this.start(key, read, settleMs)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Register a flight and run `read`; a synchronous throw becomes a rejection. */
|
|
133
|
+
private start<T>(key: string, read: () => Promise<T>, settleMs: number): Promise<T> {
|
|
134
|
+
const id = ++this.nextId
|
|
135
|
+
// A deferred callback keeps two guarantees: a synchronous throw from `read`
|
|
136
|
+
// is reported as a rejection (callers all await), and the flight is
|
|
137
|
+
// registered BEFORE the read runs, so a second call in the same tick joins
|
|
138
|
+
// it instead of starting a duplicate.
|
|
139
|
+
const body = settleMs > 0 ? withSettleDeadline(read, settleMs) : read
|
|
140
|
+
const promise: Promise<T> = Promise.resolve()
|
|
141
|
+
.then(body)
|
|
142
|
+
.finally(() => {
|
|
143
|
+
const current = this.flights.get(key)
|
|
144
|
+
if (current !== undefined && current.id === id) this.flights.delete(key)
|
|
145
|
+
this.onChange?.()
|
|
146
|
+
})
|
|
147
|
+
this.flights.set(key, { id, promise })
|
|
148
|
+
this.onChange?.()
|
|
149
|
+
return promise
|
|
150
|
+
}
|
|
151
|
+
}
|