dsh-git-badge 0.5.4 → 0.6.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/lib/client.js +52 -9
- package/lib/index.js +212 -58
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* dsh-git-badge — client half.
|
|
3
3
|
*
|
|
4
4
|
* Surfaces:
|
|
5
|
-
* - sidebar.workspaces.row (seam):
|
|
6
|
-
* - conversation.input.left (upstream):
|
|
5
|
+
* - sidebar.workspaces.row (seam): row badge — dot + branch
|
|
6
|
+
* - conversation.input.left (upstream): chip — dot + branch + in-progress
|
|
7
|
+
* operation token + sync/dirty suffix
|
|
7
8
|
* The hover card is intentionally untouched.
|
|
8
9
|
*/
|
|
9
10
|
window.__ModuleLoader__.load({
|
|
@@ -22,6 +23,19 @@ window.__ModuleLoader__.load({
|
|
|
22
23
|
/** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
|
|
23
24
|
const FALLBACK_POLL_MS = 60000;
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Path equality for SSE notifications. The node half notifies with the
|
|
28
|
+
* registered workspace path, while a surface may hold a variant spelling
|
|
29
|
+
* (a trailing slash). A bare === there would silently kill that row's
|
|
30
|
+
* freshness, so normalise the one difference we can cheaply normalise.
|
|
31
|
+
*/
|
|
32
|
+
function samePath(a, b) {
|
|
33
|
+
if (a === b) return true;
|
|
34
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
35
|
+
const trim = (value) => value.length > 1 ? value.replace(/\/+$/, "") : value;
|
|
36
|
+
return trim(a) === trim(b);
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
//#region SSE change feed (one EventSource per page, ref-counted)
|
|
26
40
|
const eventListeners = new Set();
|
|
27
41
|
let eventSource = null;
|
|
@@ -32,7 +46,9 @@ window.__ModuleLoader__.load({
|
|
|
32
46
|
eventRefs += 1;
|
|
33
47
|
if (eventSource === null) {
|
|
34
48
|
eventSource = new EventSource("/api/git-badge/events");
|
|
35
|
-
|
|
49
|
+
// NAMED frames: the node half writes `event: change`, and onmessage
|
|
50
|
+
// never fires for a named event
|
|
51
|
+
eventSource.addEventListener("change", (message) => {
|
|
36
52
|
let path;
|
|
37
53
|
try {
|
|
38
54
|
path = JSON.parse(message.data).path;
|
|
@@ -41,7 +57,7 @@ window.__ModuleLoader__.load({
|
|
|
41
57
|
}
|
|
42
58
|
// invalidate every cache entry for this workspace, then refetch
|
|
43
59
|
for (const key of [...GIT_CACHE.keys()]) {
|
|
44
|
-
if (key
|
|
60
|
+
if (samePath(key, path)) GIT_CACHE.delete(key);
|
|
45
61
|
}
|
|
46
62
|
for (const fn of [...eventListeners]) {
|
|
47
63
|
try {
|
|
@@ -50,7 +66,7 @@ window.__ModuleLoader__.load({
|
|
|
50
66
|
/* one bad subscriber must not starve the rest */
|
|
51
67
|
}
|
|
52
68
|
}
|
|
53
|
-
};
|
|
69
|
+
});
|
|
54
70
|
}
|
|
55
71
|
return () => {
|
|
56
72
|
eventListeners.delete(onChange);
|
|
@@ -98,7 +114,7 @@ window.__ModuleLoader__.load({
|
|
|
98
114
|
load();
|
|
99
115
|
// this workspace's watcher events → refetch
|
|
100
116
|
const unsubscribe = subscribeGitEvents((path) => {
|
|
101
|
-
if (path
|
|
117
|
+
if (samePath(path, cwd)) load();
|
|
102
118
|
});
|
|
103
119
|
const fallback = setInterval(load, FALLBACK_POLL_MS);
|
|
104
120
|
return () => {
|
|
@@ -154,6 +170,29 @@ window.__ModuleLoader__.load({
|
|
|
154
170
|
return parts.length > 0 ? " " + parts.join(" ") : "";
|
|
155
171
|
}
|
|
156
172
|
|
|
173
|
+
/**
|
|
174
|
+
* In-progress operation token — INPUT CHIP ONLY (the row badge stays
|
|
175
|
+
* dot + branch). A paused rebase or cherry-pick whose conflicts are all
|
|
176
|
+
* already staged has no unmerged files, so the three-state dot cannot
|
|
177
|
+
* distinguish it from ordinary dirty work; this says which
|
|
178
|
+
* history-rewriting operation git is waiting on.
|
|
179
|
+
*/
|
|
180
|
+
const OPERATION_LABELS = {
|
|
181
|
+
merge: "\u2694merge",
|
|
182
|
+
squash: "\u2694squash",
|
|
183
|
+
"cherry-pick": "\u2694cherry-pick",
|
|
184
|
+
revert: "\u2694revert",
|
|
185
|
+
bisect: "\u2694bisect",
|
|
186
|
+
rebase: "\u2694rebase",
|
|
187
|
+
sequencer: "\u2694sequencer"
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** ` ⚔rebase` when an operation is paused, else "". */
|
|
191
|
+
function formatOperationToken(info) {
|
|
192
|
+
const label = OPERATION_LABELS[info.operation];
|
|
193
|
+
return label === void 0 ? "" : " " + label;
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
const META_STYLE = {
|
|
158
197
|
color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
|
|
159
198
|
fontSize: "12px",
|
|
@@ -172,7 +211,9 @@ window.__ModuleLoader__.load({
|
|
|
172
211
|
* appends the muted `| emoji branch` part only for git workspaces.
|
|
173
212
|
*/
|
|
174
213
|
function WorkspaceGitBadge({ label, cwd }) {
|
|
175
|
-
|
|
214
|
+
// no detail=1: the row renders dot + branch only, and the extra log /
|
|
215
|
+
// stash calls have no consumer yet
|
|
216
|
+
const info = useGitStatus(cwd);
|
|
176
217
|
const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
|
|
177
218
|
if (cwd !== void 0 && info !== void 0 && info.git === true) {
|
|
178
219
|
children.push(
|
|
@@ -220,9 +261,11 @@ window.__ModuleLoader__.load({
|
|
|
220
261
|
*/
|
|
221
262
|
function ComposerGitChip({ sessionId }) {
|
|
222
263
|
const cwd = useSessionWorkspaceCwd(sessionId);
|
|
223
|
-
|
|
264
|
+
// no detail=1 either: nothing renders lastCommits / stashCount yet, and
|
|
265
|
+
// asking for them would add a log -3 plus a stash list to every refresh
|
|
266
|
+
const info = useGitStatus(cwd);
|
|
224
267
|
if (cwd === void 0 || info === void 0 || info.git !== true) return null;
|
|
225
|
-
const text = badgeDot(info) + " " + info.branch + formatGitSuffix(info);
|
|
268
|
+
const text = badgeDot(info) + " " + info.branch + formatOperationToken(info) + formatGitSuffix(info);
|
|
226
269
|
return react_jsx_runtime.jsx("span", {
|
|
227
270
|
style: {
|
|
228
271
|
display: "inline-flex",
|
package/lib/index.js
CHANGED
|
@@ -8,17 +8,28 @@
|
|
|
8
8
|
* (WorkspaceRegistry records) are ever queried — no filesystem probing
|
|
9
9
|
* of arbitrary paths.
|
|
10
10
|
* - one git invocation per sample: `git --no-optional-locks status
|
|
11
|
-
* --porcelain=v2 --branch` yields branch,
|
|
12
|
-
* changed / untracked counts in a single call
|
|
13
|
-
* guarantees the read never contends with the
|
|
14
|
-
* operations on the index).
|
|
11
|
+
* --porcelain=v2 --branch --untracked-files=all` yields branch,
|
|
12
|
+
* ahead/behind, and the changed / untracked counts in a single call
|
|
13
|
+
* (`--no-optional-locks` guarantees the read never contends with the
|
|
14
|
+
* user's own git operations on the index). `--untracked-files=all`
|
|
15
|
+
* counts untracked FILES; git's default collapses a new directory into
|
|
16
|
+
* one entry, which under-reported `✎n` for anyone who adds a folder.
|
|
17
|
+
* If that walk blows the budget the sample is retried collapsed and the
|
|
18
|
+
* response reports which mode answered (`untrackedMode`).
|
|
19
|
+
* - one `git rev-parse --show-toplevel --absolute-git-dir` resolves both
|
|
20
|
+
* the repository root (a subdirectory workspace reports the whole repo)
|
|
21
|
+
* and the per-worktree git dir, where the eight in-progress operation
|
|
22
|
+
* markers (`MERGE_HEAD`, `rebase-merge`, …) are stat'd — surfaced as
|
|
23
|
+
* `operation`, so a paused rebase/cherry-pick is distinguishable from
|
|
24
|
+
* ordinary dirty work even when its conflicts are already staged.
|
|
15
25
|
* - ahead/behind compare against the LOCAL remote-tracking ref, which
|
|
16
26
|
* only moves on fetch — so a TTL-bounded background fetch (60s per
|
|
17
27
|
* toplevel) runs before status when the ref can be stale. `GIT_TERMINAL_PROMPT=0`
|
|
18
28
|
* and the shared timeout keep an offline/slow remote from stalling the
|
|
19
29
|
* route; on fetch failure the stale-ref answer is served as-is.
|
|
20
|
-
* - `detail=1` adds one `log -
|
|
21
|
-
*
|
|
30
|
+
* - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
|
|
31
|
+
* consumes this yet (see the annotation on gitStatus) — it is kept for
|
|
32
|
+
* the planned hover card, and never paid for by row refresh.
|
|
22
33
|
*
|
|
23
34
|
* GET /api/git-badge/events (text/event-stream)
|
|
24
35
|
* - pushes `{ path }` dirty notifications the moment a workspace's git
|
|
@@ -39,6 +50,35 @@ import { join } from "node:path";
|
|
|
39
50
|
const inject = ["webServer", "workspaceRegistry"];
|
|
40
51
|
const name = "dsh-git-badge";
|
|
41
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Runtime tunables. Production code never writes these; the test suite mutates
|
|
55
|
+
* them to collapse the debounce / backoff / heartbeat windows and to force the
|
|
56
|
+
* untracked-walk timeout deterministically instead of waiting on a pathological
|
|
57
|
+
* repository. One exported object rather than scattered consts so tests have a
|
|
58
|
+
* single documented seam.
|
|
59
|
+
*/
|
|
60
|
+
const config = {
|
|
61
|
+
/** budget for every git invocation; an expired call is killed */
|
|
62
|
+
gitTimeoutMs: 3000,
|
|
63
|
+
/** network fetch budget; worst-case route latency, paid once per TTL */
|
|
64
|
+
fetchTimeoutMs: 8000,
|
|
65
|
+
/** minimum interval between background fetches per toplevel */
|
|
66
|
+
fetchTtlMs: 60000,
|
|
67
|
+
/** fs-event burst collapse window */
|
|
68
|
+
debounceMs: 200,
|
|
69
|
+
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
70
|
+
watchRetryMs: 60000,
|
|
71
|
+
/** SSE comment heartbeat interval */
|
|
72
|
+
heartbeatMs: 25000,
|
|
73
|
+
/**
|
|
74
|
+
* Invoker for the status sample; null means the real runGit. The suite
|
|
75
|
+
* injects one that reports the `-uall` call as timed out, which exercises the
|
|
76
|
+
* collapsed-fallback branch deterministically — a real sub-millisecond budget
|
|
77
|
+
* would just race git's own startup.
|
|
78
|
+
*/
|
|
79
|
+
gitRunner: null
|
|
80
|
+
};
|
|
81
|
+
|
|
42
82
|
/**
|
|
43
83
|
* Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
|
|
44
84
|
* plus exactly one of: timeout (execFile timeout kill), missing (no git
|
|
@@ -47,7 +87,7 @@ const name = "dsh-git-badge";
|
|
|
47
87
|
* from a transient one (degraded).
|
|
48
88
|
*/
|
|
49
89
|
function runGit(dir, args, opts) {
|
|
50
|
-
const { env, timeout =
|
|
90
|
+
const { env, timeout = config.gitTimeoutMs } = opts ?? {};
|
|
51
91
|
// env is a full replacement for execFile; overlay onto process.env so
|
|
52
92
|
// PATH/HOME (credential helpers, global config) survive
|
|
53
93
|
const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
|
|
@@ -70,12 +110,11 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
|
|
|
70
110
|
* `git fetch` — without one, a remote update (push from elsewhere, GitHub
|
|
71
111
|
* edit) is invisible to the badge until the user happens to fetch. A
|
|
72
112
|
* TTL-bounded fetch closes that gap: at most one network round trip per
|
|
73
|
-
*
|
|
113
|
+
* `config.fetchTtlMs` per repository, regardless of how often the route is hit
|
|
74
114
|
* (watcher bursts included). Mirrors what IDEs do (VS Code's throttled
|
|
75
115
|
* auto-fetch), with a TTL rather than a timer: fetch only happens when
|
|
76
116
|
* someone is actually looking at the badge.
|
|
77
117
|
*/
|
|
78
|
-
const FETCH_TTL_MS = 60000;
|
|
79
118
|
/** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
|
|
80
119
|
const fetchState = new Map();
|
|
81
120
|
|
|
@@ -91,7 +130,7 @@ async function maybeFetch(toplevel) {
|
|
|
91
130
|
const state = fetchState.get(toplevel);
|
|
92
131
|
if (state !== void 0) {
|
|
93
132
|
if (state.inFlight !== null) return state.inFlight;
|
|
94
|
-
if (now - state.lastAttemptAt <
|
|
133
|
+
if (now - state.lastAttemptAt < config.fetchTtlMs) return false;
|
|
95
134
|
}
|
|
96
135
|
const inFlight = (async () => {
|
|
97
136
|
// --no-tags --prune: refs-only refresh, cheapest correct form.
|
|
@@ -102,8 +141,8 @@ async function maybeFetch(toplevel) {
|
|
|
102
141
|
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
103
142
|
// measured real-world fetch is ~3s on SSH; the status-call 3s budget
|
|
104
143
|
// would kill healthy fetches on a slow link. 8s is the worst-case
|
|
105
|
-
// route latency, paid at most once per
|
|
106
|
-
timeout:
|
|
144
|
+
// route latency, paid at most once per fetchTtlMs per workspace.
|
|
145
|
+
timeout: config.fetchTimeoutMs
|
|
107
146
|
});
|
|
108
147
|
const ok = out.stdout !== null;
|
|
109
148
|
fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
|
|
@@ -151,6 +190,37 @@ function parseStatusV2(out) {
|
|
|
151
190
|
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
|
|
152
191
|
}
|
|
153
192
|
|
|
193
|
+
/**
|
|
194
|
+
* In-progress operation markers, in precedence order. Git drops one of these in
|
|
195
|
+
* the per-worktree git dir while an operation is paused; the first match names
|
|
196
|
+
* the operation. `SQUASH_MSG` is listed in its own right because a squash merge
|
|
197
|
+
* records no `MERGE_HEAD`.
|
|
198
|
+
*/
|
|
199
|
+
const OPERATION_MARKERS = [
|
|
200
|
+
["MERGE_HEAD", "merge"],
|
|
201
|
+
["SQUASH_MSG", "squash"],
|
|
202
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
203
|
+
["REVERT_HEAD", "revert"],
|
|
204
|
+
["BISECT_LOG", "bisect"],
|
|
205
|
+
["rebase-merge", "rebase"],
|
|
206
|
+
["rebase-apply", "rebase"],
|
|
207
|
+
["sequencer", "sequencer"]
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Name of the git operation currently paused in gitDir, or null. Markers live in
|
|
212
|
+
* the per-worktree git dir (not the shared one), so the caller passes
|
|
213
|
+
* `rev-parse --absolute-git-dir`. Stat'ing the paths beats eight
|
|
214
|
+
* `rev-parse --git-path` invocations — these are plain existence checks.
|
|
215
|
+
*/
|
|
216
|
+
function operationMarker(gitDir) {
|
|
217
|
+
if (gitDir === "") return null;
|
|
218
|
+
for (const [marker, operation] of OPERATION_MARKERS) {
|
|
219
|
+
if (existsSync(join(gitDir, marker))) return operation;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
/**
|
|
155
225
|
* Git status for dir; { git: false } when dir is not a repository,
|
|
156
226
|
* GIT_DEGRADED on transient git failure.
|
|
@@ -159,18 +229,45 @@ function parseStatusV2(out) {
|
|
|
159
229
|
* workspace pointing INSIDE a larger repository reports that repository's
|
|
160
230
|
* branch, dirty state, and ahead/behind counts (what git itself considers
|
|
161
231
|
* dirty), not the subdirectory in isolation. Intentional.
|
|
232
|
+
*
|
|
233
|
+
* `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
|
|
234
|
+
* The branch is retained for the planned hover card — nothing renders
|
|
235
|
+
* lastCommits / stashCount today.
|
|
162
236
|
*/
|
|
163
237
|
async function gitStatus(dir, wantDetail) {
|
|
164
|
-
|
|
238
|
+
// one invocation answers both questions: the repository root (so a
|
|
239
|
+
// subdirectory workspace reports the whole repo) and the per-worktree git
|
|
240
|
+
// dir (where the operation markers above live)
|
|
241
|
+
const top = await runGit(dir, ["rev-parse", "--show-toplevel", "--absolute-git-dir"]);
|
|
165
242
|
if (top.stdout === null) {
|
|
166
243
|
// git ran and rejected ("not a repository") is a definitive answer;
|
|
167
244
|
// timeout / missing binary is transient
|
|
168
245
|
return top.exitCode !== void 0 ? { git: false } : GIT_DEGRADED;
|
|
169
246
|
}
|
|
170
|
-
const
|
|
247
|
+
const topLines = top.stdout.trim().split("\n");
|
|
248
|
+
const toplevel = (topLines[0] ?? "").trim();
|
|
171
249
|
if (toplevel === "") return { git: false };
|
|
172
|
-
const
|
|
173
|
-
|
|
250
|
+
const gitDir = (topLines[1] ?? "").trim();
|
|
251
|
+
// -uall counts untracked FILES; git's default collapses a new directory into
|
|
252
|
+
// a single entry, which is why `✎n` under-reported anyone who added a folder.
|
|
253
|
+
// Routed through config.gitRunner so the suite can force the timeout branch.
|
|
254
|
+
const sample = (untrackedAll) => (config.gitRunner ?? runGit)(toplevel, [
|
|
255
|
+
"--no-optional-locks",
|
|
256
|
+
"status",
|
|
257
|
+
"--porcelain=v2",
|
|
258
|
+
"--branch",
|
|
259
|
+
untrackedAll ? "--untracked-files=all" : "--untracked-files=normal"
|
|
260
|
+
]);
|
|
261
|
+
let untrackedMode = "all";
|
|
262
|
+
let statusOut = await sample(true);
|
|
263
|
+
if (statusOut.timeout) {
|
|
264
|
+
// A pathological tree (a huge unignored directory) must not cost the whole
|
|
265
|
+
// badge: retry collapsed and report which answer was served, rather than
|
|
266
|
+
// degrading — or worse, silently reporting a count the client cannot
|
|
267
|
+
// interpret.
|
|
268
|
+
untrackedMode = "collapsed";
|
|
269
|
+
statusOut = await sample(false);
|
|
270
|
+
}
|
|
174
271
|
if (statusOut.stdout === null) return GIT_DEGRADED;
|
|
175
272
|
let parsed = parseStatusV2(statusOut.stdout);
|
|
176
273
|
// The upstream header is the cheap gate: no upstream configured → the
|
|
@@ -180,7 +277,7 @@ async function gitStatus(dir, wantDetail) {
|
|
|
180
277
|
if (parsed.upstream !== void 0) {
|
|
181
278
|
const fetched = await maybeFetch(toplevel);
|
|
182
279
|
if (fetched) {
|
|
183
|
-
statusOut = await sample();
|
|
280
|
+
statusOut = await sample(untrackedMode === "all");
|
|
184
281
|
if (statusOut.stdout === null) return GIT_DEGRADED;
|
|
185
282
|
parsed = parseStatusV2(statusOut.stdout);
|
|
186
283
|
}
|
|
@@ -196,11 +293,13 @@ async function gitStatus(dir, wantDetail) {
|
|
|
196
293
|
unstagedFiles: parsed.unstaged,
|
|
197
294
|
unmergedFiles: parsed.unmerged,
|
|
198
295
|
untrackedFiles: parsed.untracked,
|
|
296
|
+
untrackedMode,
|
|
297
|
+
operation: operationMarker(gitDir),
|
|
199
298
|
ahead: parsed.ahead,
|
|
200
299
|
behind: parsed.behind
|
|
201
300
|
};
|
|
202
301
|
if (wantDetail) {
|
|
203
|
-
// last 3 commits (subject + relative age) for the hover card
|
|
302
|
+
// last 3 commits (subject + relative age) for the planned hover card
|
|
204
303
|
const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
|
|
205
304
|
if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
|
|
206
305
|
info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
|
|
@@ -227,8 +326,8 @@ async function gitStatus(dir, wantDetail) {
|
|
|
227
326
|
*/
|
|
228
327
|
const watchers = new Map();
|
|
229
328
|
const changeListeners = new Set();
|
|
230
|
-
|
|
231
|
-
const
|
|
329
|
+
/** Open SSE responses, so plugin unload can end them instead of leaking them. */
|
|
330
|
+
const openStreams = new Set();
|
|
232
331
|
|
|
233
332
|
function notifyChange(key) {
|
|
234
333
|
for (const fn of changeListeners) {
|
|
@@ -240,6 +339,22 @@ function notifyChange(key) {
|
|
|
240
339
|
}
|
|
241
340
|
}
|
|
242
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Tear down one SSE stream. Idempotent: `req` and `res` both fire `close` and
|
|
344
|
+
* the plugin disposer can race them, so membership in openStreams is the single
|
|
345
|
+
* source of truth for "still open".
|
|
346
|
+
*/
|
|
347
|
+
function closeStream(stream) {
|
|
348
|
+
if (!openStreams.delete(stream)) return;
|
|
349
|
+
changeListeners.delete(stream.send);
|
|
350
|
+
clearInterval(stream.heartbeat);
|
|
351
|
+
try {
|
|
352
|
+
stream.res.end();
|
|
353
|
+
} catch {
|
|
354
|
+
/* already gone */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
243
358
|
function unwatchWorkspace(key) {
|
|
244
359
|
const record = watchers.get(key);
|
|
245
360
|
if (record === void 0) return;
|
|
@@ -252,7 +367,7 @@ function watchWorkspace(root, key) {
|
|
|
252
367
|
const existing = watchers.get(key);
|
|
253
368
|
if (existing !== void 0) {
|
|
254
369
|
// live watcher, or a failed attempt still inside its retry backoff
|
|
255
|
-
if (existing.watcher !== null || Date.now() - existing.failedAt <
|
|
370
|
+
if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) return;
|
|
256
371
|
watchers.delete(key);
|
|
257
372
|
}
|
|
258
373
|
// only git workspaces need a badge; a missing .git skips the watcher but
|
|
@@ -272,7 +387,7 @@ function watchWorkspace(root, key) {
|
|
|
272
387
|
const record = watchers.get(key);
|
|
273
388
|
if (record === void 0) return;
|
|
274
389
|
clearTimeout(record.timer);
|
|
275
|
-
record.timer = setTimeout(() => notifyChange(key),
|
|
390
|
+
record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
|
|
276
391
|
});
|
|
277
392
|
watcher.on("error", () => unwatchWorkspace(key));
|
|
278
393
|
watchers.set(key, { watcher, timer: void 0 });
|
|
@@ -328,42 +443,81 @@ function apply(ctx) {
|
|
|
328
443
|
}
|
|
329
444
|
}
|
|
330
445
|
}));
|
|
331
|
-
ctx.effect(() =>
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
446
|
+
ctx.effect(() => {
|
|
447
|
+
const dispose = ctx.webServer.register({
|
|
448
|
+
kind: "exact",
|
|
449
|
+
path: "/api/git-badge/events",
|
|
450
|
+
handler: async (req, res) => {
|
|
451
|
+
// keep the watcher set aligned with the live registry on every connect
|
|
452
|
+
syncWatchers(ctx);
|
|
453
|
+
res.writeHead(200, {
|
|
454
|
+
"content-type": "text/event-stream",
|
|
455
|
+
"cache-control": "no-cache",
|
|
456
|
+
connection: "keep-alive",
|
|
457
|
+
// proxies (nginx et al) buffer responses by default, which holds
|
|
458
|
+
// event frames back until the buffer fills
|
|
459
|
+
"x-accel-buffering": "no"
|
|
460
|
+
});
|
|
461
|
+
// flush immediately: staged headers only hit the wire on first write,
|
|
462
|
+
// and the first real event may be minutes away
|
|
463
|
+
res.write(": connected\n\n");
|
|
464
|
+
const stream = {
|
|
465
|
+
res,
|
|
466
|
+
heartbeat: null,
|
|
467
|
+
send: (key) => {
|
|
468
|
+
try {
|
|
469
|
+
// NAMED frame: the client subscribes with
|
|
470
|
+
// addEventListener("change", …), which never sees an
|
|
471
|
+
// unnamed message
|
|
472
|
+
res.write(`event: change\ndata: ${JSON.stringify({ path: key })}\n\n`);
|
|
473
|
+
} catch {
|
|
474
|
+
/* socket gone; the close handler cleans up */
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
changeListeners.add(stream.send);
|
|
479
|
+
// comment-only heartbeat keeps proxies from idling the stream out
|
|
480
|
+
stream.heartbeat = setInterval(() => {
|
|
481
|
+
try {
|
|
482
|
+
res.write(": hb\n\n");
|
|
483
|
+
} catch {
|
|
484
|
+
/* ignore */
|
|
485
|
+
}
|
|
486
|
+
}, config.heartbeatMs);
|
|
487
|
+
openStreams.add(stream);
|
|
488
|
+
// both fire on a client disconnect; closeStream is idempotent
|
|
489
|
+
req.on("close", () => closeStream(stream));
|
|
490
|
+
res.on("close", () => closeStream(stream));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
// unloading the plugin must not leave subscribers holding dead streams,
|
|
494
|
+
// nor keep process-wide watchers alive
|
|
495
|
+
return () => {
|
|
496
|
+
dispose();
|
|
497
|
+
for (const stream of [...openStreams]) closeStream(stream);
|
|
498
|
+
for (const key of [...watchers.keys()]) unwatchWorkspace(key);
|
|
499
|
+
};
|
|
500
|
+
});
|
|
367
501
|
}
|
|
368
502
|
|
|
369
503
|
export { apply, inject, name };
|
|
504
|
+
|
|
505
|
+
// ---------- test-only exports ----------
|
|
506
|
+
// Additive named exports; cordis reads apply/inject/name and ignores the rest.
|
|
507
|
+
// The suite in ./test drives these directly instead of booting DSH, which is
|
|
508
|
+
// what lets a node-half change be verified without a restart.
|
|
509
|
+
export {
|
|
510
|
+
config,
|
|
511
|
+
OPERATION_MARKERS,
|
|
512
|
+
operationMarker,
|
|
513
|
+
parseStatusV2,
|
|
514
|
+
runGit,
|
|
515
|
+
gitStatus,
|
|
516
|
+
watchWorkspace,
|
|
517
|
+
unwatchWorkspace,
|
|
518
|
+
syncWatchers,
|
|
519
|
+
watchers,
|
|
520
|
+
changeListeners,
|
|
521
|
+
openStreams,
|
|
522
|
+
closeStream
|
|
523
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"./client": "./lib/client.js",
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
12
15
|
"dsh": {
|
|
13
16
|
"bundle": {
|
|
14
17
|
"patch": "./cordis.patch.yml"
|
|
@@ -34,7 +37,7 @@
|
|
|
34
37
|
"repository": {
|
|
35
38
|
"type": "git",
|
|
36
39
|
"url": "git+https://github.com/Kevin-McIsaac/dsh-workspace-git-badge.git",
|
|
37
|
-
"directory": "
|
|
40
|
+
"directory": "dsh-git-badge"
|
|
38
41
|
},
|
|
39
42
|
"engines": {
|
|
40
43
|
"node": ">=20"
|