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.
@@ -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
+ }