dsh-git-badge 0.6.0 β 0.7.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/CHANGELOG.md +141 -0
- package/README.md +9 -3
- package/lib/client.js +297 -136
- package/lib/index.js +321 -74
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
6
|
+
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
Versions before 0.6.0 predate this file; their history is in `git log` and on
|
|
9
|
+
npm. 0.6.0 is the first release recorded here.
|
|
10
|
+
|
|
11
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## [0.7.0] - 2026-09-12
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Both surfaces draw the same status mark.** The input chip no longer leads with
|
|
18
|
+
a π΄/π‘/π’ emoji dot: it renders the SAME 12px SVG mark as the sidebar row β a
|
|
19
|
+
filled **circle**, or a **tree** for a linked `git worktree` β so the shape says
|
|
20
|
+
*worktree or not* and the fill says *status*, identically wherever the badge
|
|
21
|
+
appears. The `β` worktree glyph is gone with the emoji: the tree shape already
|
|
22
|
+
says "worktree". The mark is drawn rather than typed because π³ is a **colour
|
|
23
|
+
emoji** β CSS cannot tint its leaves β and the fill comes from the app's own
|
|
24
|
+
`--dsw-alias-state-{success,warn,error}-primary` tokens, so the mark follows
|
|
25
|
+
light/dark and custom themes, which the hardcoded emoji could not. Neither colour
|
|
26
|
+
nor shape is the only channel: the mark carries an accessible name.
|
|
27
|
+
- **The sidebar row shows status, not the branch.** The row keeps the workspace
|
|
28
|
+
name on the left and floats the status mark to the right, with a worktree's
|
|
29
|
+
directory name beside it; the `|` separator is gone. The branch, sync counts and
|
|
30
|
+
operation token stay on the input chip, the surface scoped to the current
|
|
31
|
+
conversation.
|
|
32
|
+
- **The status endpoint no longer accepts a filesystem path.** The caller now says
|
|
33
|
+
*who it is* and the server resolves the workspace itself: `?session=<id>` for the
|
|
34
|
+
input chip, `?workspace=<id>` for a sidebar row. `?path=` is refused with
|
|
35
|
+
`target-required`; unresolvable ids return `session-not-found` or
|
|
36
|
+
`workspace-not-found`. The path was previously caller-chosen (allowlist-bounded
|
|
37
|
+
and read-only, but still chosen by the caller) on a route that answers without
|
|
38
|
+
GUI authentication.
|
|
39
|
+
- A session is resolved through the workspace registry first, so a conversation
|
|
40
|
+
that is no longer live still renders a badge. The live session's directory is
|
|
41
|
+
consulted only for the window before a new session is attached to its workspace,
|
|
42
|
+
and the registry still validates it.
|
|
43
|
+
- The client half no longer requires the `workspaces` service β declared services
|
|
44
|
+
are now just `["slots"]` β and change notifications carry the workspace id, so
|
|
45
|
+
subscribers match on identity rather than on a path.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **A client-half test suite, plus submodule coverage.** `test/client.test.mjs`
|
|
50
|
+
loads the real `lib/client.js` behind a stub module loader and renders both
|
|
51
|
+
registered surfaces, so the mark's shape/colour rules, the row's "never show the
|
|
52
|
+
branch" rule, the right-float and the worktree-name suppression are asserted in
|
|
53
|
+
CI rather than eyeballed. The node half gains a real-submodule fixture: a
|
|
54
|
+
submodule is asserted **not** to be a worktree (its gitfile alone must not decide
|
|
55
|
+
it β worktree-ness is the `commondir` marker) while its out-of-tree git dir is
|
|
56
|
+
still watched, like a linked worktree's.
|
|
57
|
+
- **The input chip now says which working tree a conversation is in.** A linked
|
|
58
|
+
`git worktree`'s directory name is appended after the branch β
|
|
59
|
+
`π³ main hotfix-tree β0 β2 β3` β because several worktrees of one repository
|
|
60
|
+
otherwise render identical chips and the chip carries no other workspace
|
|
61
|
+
identity. The name is dropped when the branch already implies it (`repo-feat-x`
|
|
62
|
+
on `feat-x` shows just the tree), a main checkout appends nothing, and the
|
|
63
|
+
response reports `isWorktree` / `worktreeName` from one stat of the git dir the
|
|
64
|
+
existing `rev-parse` already returned β no extra git invocation. Naming the
|
|
65
|
+
worktree is chip-only, like the operation token. The value is a directory NAME,
|
|
66
|
+
never a path.
|
|
67
|
+
- **A server-side poll fallback for workspaces whose file watcher failed.** A
|
|
68
|
+
workspace whose recursive `fs.watch` could not be established, or which later
|
|
69
|
+
errored, is polled every 5s on a state key (status + refs fingerprint +
|
|
70
|
+
in-progress operation) instead of depending only on the client's 60s fallback.
|
|
71
|
+
- `seam/apply.sh status` β reports patched / pristine / upstream-landed / drifted
|
|
72
|
+
by hash, exit 1 on drift with the exact rebuild commands.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **The input chip now refreshes on change events.** Change events identify the
|
|
77
|
+
*workspace*, and the client matched them against its own target id. A sidebar row
|
|
78
|
+
targets by workspace id so rows kept working, but the chip targets by session id,
|
|
79
|
+
which can never equal a workspace id β so its handler never fired and the chip
|
|
80
|
+
only updated when it remounted (switching conversations) or on the 60s fallback
|
|
81
|
+
poll. The status response now echoes the resolved workspace id and the client
|
|
82
|
+
matches on that. This was a regression in the server-side resolution change.
|
|
83
|
+
- **A linked worktree's badge only refreshed on a file edit.** The watcher watched
|
|
84
|
+
the workspace root, but a linked worktree's git dir lives OUTSIDE it
|
|
85
|
+
(`<main>/.git/worktrees/<name>`), so `git add`, `git commit` and `git checkout`
|
|
86
|
+
there wrote nothing the root watch could see: the badge stayed stale until a
|
|
87
|
+
worktree file changed or the client's 60s poll fired. The per-worktree git dir
|
|
88
|
+
now gets its own watcher, resolved from the `.git` gitfile (which covers
|
|
89
|
+
submodules too), and either watcher erroring drops both and hands over to the
|
|
90
|
+
degraded poll. A regression test that stages a file written before the watch
|
|
91
|
+
existed fails without the second watcher.
|
|
92
|
+
- **The TTL fetch no longer delays the status response.** `ahead`/`behind` are
|
|
93
|
+
read from the local remote-tracking ref, and the fetch that refreshes it was
|
|
94
|
+
awaited before answering, so any request arriving after the 60s TTL had lapsed
|
|
95
|
+
paid a full network fetch first (~3.3s measured) β which is the first request
|
|
96
|
+
after every TTL window, i.e. constantly while editing. Badge updates triggered
|
|
97
|
+
by an edit took seconds for a repo with a remote. The response is now served
|
|
98
|
+
from the refs at hand and the fetch runs out of band; when it succeeds it
|
|
99
|
+
notifies subscribers, which reconverge in ~40ms. Same call, expired TTL: 27ms.
|
|
100
|
+
The trade is that `ahead`/`behind` can lag by up to one fetch β branch, dirty
|
|
101
|
+
state and file counts are never delayed.
|
|
102
|
+
- The plugin's `surfaces:` console line reported the boot-time race instead of the
|
|
103
|
+
outcome: it printed `sidebar rows = off (seam absent)` while row badges were
|
|
104
|
+
visibly rendering, because it sampled the seam declaration before the workspace
|
|
105
|
+
browser published it. It now reports from the registration callback, and the
|
|
106
|
+
boot-time line says it is awaiting the seam rather than declaring it missing.
|
|
107
|
+
|
|
108
|
+
## [0.6.0] - 2026-09-12
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
|
|
112
|
+
- **`βn` under-counted new files.** Git collapses a new directory into a single
|
|
113
|
+
status entry, so adding a folder of 3 files displayed `β1`. Untracked files are
|
|
114
|
+
now counted individually. If walking a very large untracked tree exceeds the time
|
|
115
|
+
budget, the badge falls back to the collapsed count and reports which mode
|
|
116
|
+
answered (`untrackedMode`) instead of silently showing a wrong number.
|
|
117
|
+
|
|
118
|
+
### Added
|
|
119
|
+
|
|
120
|
+
- **An in-progress operation token.** A paused merge, rebase, cherry-pick, revert,
|
|
121
|
+
bisect or sequencer run now appears after the branch name β
|
|
122
|
+
`π‘ main βrebase β0 β2 β3`. A rebase whose conflicts are already staged has no
|
|
123
|
+
unmerged files, so the three-state dot could not express it. The dot contract
|
|
124
|
+
itself is unchanged, and the token is chip-only.
|
|
125
|
+
- **A 41-test node-half suite** (`npm test`) covering the status parser, git
|
|
126
|
+
plumbing against real repositories, request routing, SSE framing and the
|
|
127
|
+
filesystem watcher β runnable without booting DSH.
|
|
128
|
+
- **A CI test workflow** running the suite on Node 20/22/24.
|
|
129
|
+
|
|
130
|
+
### Changed
|
|
131
|
+
|
|
132
|
+
- Publishing runs through **npm trusted publishing** (OIDC) on a published
|
|
133
|
+
release, gated on the suite, with SLSA provenance β no long-lived token.
|
|
134
|
+
- Event stream hardening: `x-accel-buffering: no`, named `change` frames, cleanup
|
|
135
|
+
on disconnect, and an unload disposer that also releases the filesystem watchers.
|
|
136
|
+
- `detail=1` is annotated as having no consumer yet (the hover card remains
|
|
137
|
+
pending). Its comment previously claimed `log -1` while the code ran `log -3`.
|
|
138
|
+
|
|
139
|
+
[Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.7.0...HEAD
|
|
140
|
+
[0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
|
|
141
|
+
[0.6.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.6.0
|
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
|
|
4
4
|
|
|
5
|
-
- **Input-row chip** β
|
|
6
|
-
- **Sidebar row badges** β
|
|
5
|
+
- **Input-row chip** β `β main β0 β2 β3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
|
|
6
|
+
- **Sidebar row badges** β the same status mark floated to the right of each workspace row (`Project 1 β`), with a linked worktree's name beside it (`Project 2 hotfix-tree π³`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The leading **mark** is identical on both surfaces: a circle filled with the status colour, or a **tree** when the workspace is a linked `git worktree`. (`β`/`π³` stand in for it here β it is a drawn SVG so that it can take the theme's status colours.)
|
|
9
|
+
|
|
10
|
+
Event-driven freshness: working trees are watched and updates push over SSE β badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample. The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
|
|
9
11
|
|
|
10
12
|
## Install
|
|
11
13
|
|
|
@@ -17,6 +19,10 @@ Restart the web process, refresh the browser. Requires Node β₯ 20 and `git` on
|
|
|
17
19
|
|
|
18
20
|
Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
|
|
19
21
|
|
|
22
|
+
## Changelog
|
|
23
|
+
|
|
24
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
25
|
+
|
|
20
26
|
## License
|
|
21
27
|
|
|
22
28
|
MIT
|
package/lib/client.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-git-badge β client half.
|
|
3
3
|
*
|
|
4
|
+
* Both surfaces show the SAME status mark β a circle, or a tree for a linked
|
|
5
|
+
* worktree, filled with the status colour β so status and worktree-ness read
|
|
6
|
+
* identically wherever they appear.
|
|
7
|
+
*
|
|
4
8
|
* Surfaces:
|
|
5
|
-
* - sidebar.workspaces.row (seam): row badge β
|
|
6
|
-
*
|
|
7
|
-
*
|
|
9
|
+
* - sidebar.workspaces.row (seam): row badge β workspace name, the status mark
|
|
10
|
+
* floated right, and a worktree's name beside it. Deliberately shows NO
|
|
11
|
+
* branch: the chip is the surface that names it.
|
|
12
|
+
* - conversation.input.left (upstream): chip β status mark + branch + a
|
|
13
|
+
* worktree's name + in-progress operation token + sync/dirty suffix
|
|
8
14
|
* The hover card is intentionally untouched.
|
|
9
15
|
*/
|
|
10
16
|
window.__ModuleLoader__.load({
|
|
@@ -24,16 +30,16 @@ window.__ModuleLoader__.load({
|
|
|
24
30
|
const FALLBACK_POLL_MS = 60000;
|
|
25
31
|
|
|
26
32
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* A request target names WHO is asking, never a filesystem path β the node
|
|
34
|
+
* half resolves the directory itself, so a surface never needs to know
|
|
35
|
+
* one. The chip targets its conversation (`session`); a sidebar row
|
|
36
|
+
* targets the workspace id the seam hands it. The query string doubles as
|
|
37
|
+
* the cache key.
|
|
31
38
|
*/
|
|
32
|
-
function
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return trim(a) === trim(b);
|
|
39
|
+
function targetQuery(target) {
|
|
40
|
+
if (target === void 0 || target.id === void 0) return void 0;
|
|
41
|
+
const param = target.kind === "workspace" ? "workspace" : "session";
|
|
42
|
+
return param + "=" + encodeURIComponent(target.id);
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
//#region SSE change feed (one EventSource per page, ref-counted)
|
|
@@ -49,19 +55,19 @@ window.__ModuleLoader__.load({
|
|
|
49
55
|
// NAMED frames: the node half writes `event: change`, and onmessage
|
|
50
56
|
// never fires for a named event
|
|
51
57
|
eventSource.addEventListener("change", (message) => {
|
|
52
|
-
let
|
|
58
|
+
let payload;
|
|
53
59
|
try {
|
|
54
|
-
|
|
60
|
+
payload = JSON.parse(message.data);
|
|
55
61
|
} catch {
|
|
56
62
|
return;
|
|
57
63
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// The server names the workspace it watched, so a subscriber that
|
|
65
|
+
// asked by id matches without ever knowing a path. Each
|
|
66
|
+
// subscriber refetches itself, and the per-key in-flight map
|
|
67
|
+
// collapses the resulting burst into one request.
|
|
62
68
|
for (const fn of [...eventListeners]) {
|
|
63
69
|
try {
|
|
64
|
-
fn(
|
|
70
|
+
fn(payload);
|
|
65
71
|
} catch {
|
|
66
72
|
/* one bad subscriber must not starve the rest */
|
|
67
73
|
}
|
|
@@ -81,76 +87,122 @@ window.__ModuleLoader__.load({
|
|
|
81
87
|
|
|
82
88
|
/**
|
|
83
89
|
* Shared git-status hook. Fetches once on mount and then only when the
|
|
84
|
-
* node half's watcher reports a change for this
|
|
85
|
-
*
|
|
86
|
-
*
|
|
90
|
+
* node half's watcher reports a change for this target (SSE), plus a slow
|
|
91
|
+
* safety-net poll in case the stream dies silently. Returns undefined
|
|
92
|
+
* while loading and for non-git workspaces.
|
|
87
93
|
*/
|
|
88
|
-
function useGitStatus(
|
|
89
|
-
const cacheKey =
|
|
94
|
+
function useGitStatus(target) {
|
|
95
|
+
const cacheKey = targetQuery(target);
|
|
90
96
|
const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
|
|
91
|
-
|
|
97
|
+
// state carries the key it belongs to: switching conversations changes
|
|
98
|
+
// the key, and showing the previous session's badge until the new fetch
|
|
99
|
+
// lands would be wrong
|
|
100
|
+
const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
|
|
92
101
|
react.useEffect(() => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
|
|
108
|
-
.then((r) => r.json())
|
|
109
|
-
.finally(() => GIT_INFLIGHT.delete(cacheKey));
|
|
110
|
-
GIT_INFLIGHT.set(cacheKey, pending);
|
|
102
|
+
if (cacheKey === void 0) return;
|
|
103
|
+
let alive = true;
|
|
104
|
+
// The workspace this target resolved to, taken from the response (the
|
|
105
|
+
// server echoes it). THIS is what events are matched against: an event
|
|
106
|
+
// carries a workspace id, and a session-targeted chip has only a
|
|
107
|
+
// session id, so comparing the two directly never matches.
|
|
108
|
+
let resolvedWorkspace;
|
|
109
|
+
// Re-seed from cache when the key changes (switching conversations):
|
|
110
|
+
// render the last-known badge immediately instead of blanking until the
|
|
111
|
+
// fetch lands, and take the workspace id from it so events still match.
|
|
112
|
+
const cached = GIT_CACHE.get(cacheKey);
|
|
113
|
+
if (cached !== void 0) {
|
|
114
|
+
if (typeof cached.data?.workspace === "string") resolvedWorkspace = cached.data.workspace;
|
|
115
|
+
setState({ key: cacheKey, data: cached.data });
|
|
111
116
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
117
|
+
const apply = (data) => {
|
|
118
|
+
// A degraded response (git timeout/failure) must never clobber a
|
|
119
|
+
// good cached badge β keep the last-known state until a real
|
|
120
|
+
// event or the fallback poll succeeds.
|
|
121
|
+
if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
|
|
122
|
+
if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
|
|
123
|
+
GIT_CACHE.set(cacheKey, { at: Date.now(), data });
|
|
124
|
+
if (alive) setState({ key: cacheKey, data });
|
|
125
|
+
};
|
|
126
|
+
const load = () => {
|
|
127
|
+
// dedupe: an in-flight fetch for this key serves all callers
|
|
128
|
+
let pending = GIT_INFLIGHT.get(cacheKey);
|
|
129
|
+
if (pending === void 0) {
|
|
130
|
+
pending = fetch("/api/git-badge?" + cacheKey)
|
|
131
|
+
.then((r) => r.json())
|
|
132
|
+
.finally(() => GIT_INFLIGHT.delete(cacheKey));
|
|
133
|
+
GIT_INFLIGHT.set(cacheKey, pending);
|
|
134
|
+
}
|
|
135
|
+
pending.then(apply).catch(() => {});
|
|
136
|
+
};
|
|
137
|
+
load();
|
|
138
|
+
const unsubscribe = subscribeGitEvents((payload) => {
|
|
139
|
+
// An event with no workspace id (no watcher record for that path)
|
|
140
|
+
// cannot be attributed, and neither can one arriving before we have
|
|
141
|
+
// resolved our own target β refetch rather than guess, since a
|
|
142
|
+
// missed refresh is the bug this guards and an extra request is cheap
|
|
143
|
+
// (the in-flight map collapses bursts).
|
|
144
|
+
if (payload.workspace === void 0 || resolvedWorkspace === void 0 || payload.workspace === resolvedWorkspace) load();
|
|
145
|
+
});
|
|
146
|
+
const fallback = setInterval(load, FALLBACK_POLL_MS);
|
|
147
|
+
return () => {
|
|
148
|
+
alive = false;
|
|
149
|
+
unsubscribe();
|
|
150
|
+
clearInterval(fallback);
|
|
151
|
+
};
|
|
152
|
+
}, [cacheKey]);
|
|
153
|
+
return state.key === cacheKey ? state.data : void 0;
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
/**
|
|
130
|
-
* Three-state
|
|
131
|
-
*
|
|
132
|
-
* - RED: unmerged files (mid-conflict, git is blocked) β or a dirty
|
|
157
|
+
* Three-state status, checked top-down, first match wins:
|
|
158
|
+
* - "error": unmerged files (mid-conflict, git is blocked) β or a dirty
|
|
133
159
|
* tree that is ALSO behind upstream (unsaved edits on an outdated
|
|
134
160
|
* base: commit-then-pull friction ahead).
|
|
135
|
-
* -
|
|
161
|
+
* - "warn": dirty files, or any ahead/behind (routine work or sync
|
|
136
162
|
* pending β including the previously "quiet" green-but-β1 case).
|
|
137
|
-
* -
|
|
163
|
+
* - "ok": clean and in sync.
|
|
138
164
|
* Red never means merely "behind": a clean tree one commit behind is
|
|
139
165
|
* normal between pulls, not an alarm.
|
|
166
|
+
*
|
|
167
|
+
* The single source of truth for that summary. BOTH surfaces render it the
|
|
168
|
+
* same way (see StatusMark): a circle, or a tree in a linked worktree,
|
|
169
|
+
* filled with the colour below.
|
|
140
170
|
*/
|
|
141
|
-
function
|
|
171
|
+
function badgeStatus(info) {
|
|
142
172
|
const dirty = info.dirty === true;
|
|
143
173
|
const behind = (info.behind || 0) > 0;
|
|
144
174
|
const ahead = (info.ahead || 0) > 0;
|
|
145
175
|
const conflict = (info.unmergedFiles || 0) > 0;
|
|
146
|
-
if (conflict || (dirty && behind)) return "
|
|
147
|
-
if (dirty || ahead || behind) return "
|
|
148
|
-
return "
|
|
176
|
+
if (conflict || (dirty && behind)) return "error";
|
|
177
|
+
if (dirty || ahead || behind) return "warn";
|
|
178
|
+
return "ok";
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
182
|
+
* Mark fill per status, from the app's own semantic tokens so BOTH surfaces
|
|
183
|
+
* follow light/dark and custom themes β which a hardcoded emoji cannot do.
|
|
184
|
+
* The literals are only fallbacks for use outside DSH.
|
|
185
|
+
*/
|
|
186
|
+
const MARK_FILL = {
|
|
187
|
+
error: "var(--dsw-alias-state-error-primary, #e5484d)",
|
|
188
|
+
warn: "var(--dsw-alias-state-warn-primary, #d29922)",
|
|
189
|
+
ok: "var(--dsw-alias-state-success-primary, #30a46c)"
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Accessible name per status, WITHOUT the "git" prefix β the mark adds
|
|
194
|
+
* "git: " or "git worktree: ", so shape and colour are never the only
|
|
195
|
+
* channel for either fact.
|
|
196
|
+
*/
|
|
197
|
+
const STATUS_LABEL = {
|
|
198
|
+
error: "conflict, or uncommitted changes on an outdated base",
|
|
199
|
+
warn: "uncommitted changes, or out of sync with upstream",
|
|
200
|
+
ok: "clean and in sync"
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sync/dirty suffix β INPUT CHIP ONLY. The sidebar row shows status alone;
|
|
205
|
+
* the chip is the surface that carries the numbers. Rule set:
|
|
154
206
|
* - dirty files render as βn (pencil = worktree files, distinct from
|
|
155
207
|
* the β/β commit-sync axis)
|
|
156
208
|
* - while dirty, BOTH sync counts render including zeros (β0 β2 β3),
|
|
@@ -171,9 +223,9 @@ window.__ModuleLoader__.load({
|
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
/**
|
|
174
|
-
* In-progress operation token β INPUT CHIP ONLY (the row
|
|
175
|
-
*
|
|
176
|
-
* already staged has no unmerged files, so the three-state
|
|
226
|
+
* In-progress operation token β INPUT CHIP ONLY (the row shows the status
|
|
227
|
+
* tree alone). A paused rebase or cherry-pick whose conflicts are all
|
|
228
|
+
* already staged has no unmerged files, so the three-state summary cannot
|
|
177
229
|
* distinguish it from ordinary dirty work; this says which
|
|
178
230
|
* history-rewriting operation git is waiting on.
|
|
179
231
|
*/
|
|
@@ -193,6 +245,45 @@ window.__ModuleLoader__.load({
|
|
|
193
245
|
return label === void 0 ? "" : " " + label;
|
|
194
246
|
}
|
|
195
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Linked-worktree NAME token β INPUT CHIP ONLY, and only the name: worktree-
|
|
250
|
+
* ness itself is carried by the status mark's shape (a tree instead of a
|
|
251
|
+
* circle) on both surfaces. The chip has no other workspace identity, so
|
|
252
|
+
* several worktrees of one repository would otherwise render identical chips
|
|
253
|
+
* with no way to tell which checkout a conversation is in. The node half
|
|
254
|
+
* reports whether this is a linked worktree plus its directory NAME (never a
|
|
255
|
+
* path).
|
|
256
|
+
*
|
|
257
|
+
* The name is appended only when it says something the branch does not: the
|
|
258
|
+
* usual `repo-feat-x` directory sitting on branch `feat-x` would otherwise
|
|
259
|
+
* read as stutter. (The ROW needs no such rule β it shows the name instead
|
|
260
|
+
* of the branch, so there is nothing to stutter against.)
|
|
261
|
+
*/
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* True when the worktree name is already implied by the branch name, so
|
|
265
|
+
* showing both is pure repetition. Compared on a normalized form β
|
|
266
|
+
* case-folded, with `/` and `_` folded to `-` β because directory and
|
|
267
|
+
* branch conventions differ without changing the meaning (`feat/x` in a
|
|
268
|
+
* `repo-feat-x` directory).
|
|
269
|
+
*/
|
|
270
|
+
function worktreeNameIsRedundant(name, branch) {
|
|
271
|
+
if (typeof name !== "string" || name === "" || typeof branch !== "string") return true;
|
|
272
|
+
const normalize = (value) => value.toLowerCase().replace(/[/_]/g, "-");
|
|
273
|
+
const normalizedName = normalize(name);
|
|
274
|
+
const normalizedBranch = normalize(branch);
|
|
275
|
+
return normalizedName === normalizedBranch
|
|
276
|
+
|| normalizedName.endsWith("-" + normalizedBranch)
|
|
277
|
+
|| normalizedBranch.endsWith("-" + normalizedName);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** ` name` for a worktree whose name the branch does not already say, else "". */
|
|
281
|
+
function formatWorktreeToken(info) {
|
|
282
|
+
if (info.isWorktree !== true) return "";
|
|
283
|
+
const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
|
|
284
|
+
return worktreeNameIsRedundant(name, info.branch) ? "" : " " + name;
|
|
285
|
+
}
|
|
286
|
+
|
|
196
287
|
const META_STYLE = {
|
|
197
288
|
color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
|
|
198
289
|
fontSize: "12px",
|
|
@@ -202,70 +293,122 @@ window.__ModuleLoader__.load({
|
|
|
202
293
|
};
|
|
203
294
|
|
|
204
295
|
/**
|
|
205
|
-
*
|
|
206
|
-
* the input chip
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
296
|
+
* Status mark, SHARED BY BOTH SURFACES: the same 12px shape in the same fill
|
|
297
|
+
* wherever it appears, so the sidebar row and the input chip read
|
|
298
|
+
* identically. The FILL is the status colour; the SHAPE carries
|
|
299
|
+
* worktree-ness β a circle for a main checkout, a tree for a linked
|
|
300
|
+
* worktree β so the two facts never compete for the same channel.
|
|
301
|
+
*
|
|
302
|
+
* Drawn rather than typed because π³ is a COLOUR EMOJI: CSS cannot tint
|
|
303
|
+
* its leaves, and a mark that carries the status has to be paintable.
|
|
304
|
+
* Three overlapping crown lobes plus a trunk stay legible at this size.
|
|
305
|
+
*
|
|
306
|
+
* Neither colour nor shape is the only channel: the accessible name
|
|
307
|
+
* states both.
|
|
212
308
|
*/
|
|
213
|
-
function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
309
|
+
function StatusMark({ info }) {
|
|
310
|
+
const status = badgeStatus(info);
|
|
311
|
+
const fill = MARK_FILL[status];
|
|
312
|
+
const isWorktree = info.isWorktree === true;
|
|
313
|
+
const svg = {
|
|
314
|
+
// 14px, NOT 12px: a colour emoji draws well above its nominal size β the
|
|
315
|
+
// π΄/π‘/π’ dot this replaces put down ~13-14px of ink at the surfaces'
|
|
316
|
+
// 12px font-size, so a 12px box read as a shrunken dot.
|
|
317
|
+
width: 14,
|
|
318
|
+
height: 14,
|
|
319
|
+
viewBox: "0 0 12 12",
|
|
320
|
+
role: "img",
|
|
321
|
+
"aria-label": (isWorktree ? "git worktree: " : "git: ") + STATUS_LABEL[status],
|
|
322
|
+
focusable: "false",
|
|
323
|
+
style: { flex: "none", display: "block", color: META_STYLE.color }
|
|
324
|
+
};
|
|
325
|
+
if (!isWorktree) {
|
|
326
|
+
// a plain filled disc: this is a main checkout. It fills its box so the
|
|
327
|
+
// ink lands at the size the emoji dot used to.
|
|
328
|
+
return react_jsx_runtime.jsx("svg", { ...svg, children: react_jsx_runtime.jsx("circle", { cx: 6, cy: 6, r: 5.5, fill }) });
|
|
224
329
|
}
|
|
225
|
-
return react_jsx_runtime.
|
|
330
|
+
return react_jsx_runtime.jsxs("svg", {
|
|
331
|
+
...svg,
|
|
332
|
+
children: [
|
|
333
|
+
// trunk first, so the crown's lobes overlap and join it. These are the
|
|
334
|
+
// 12px-era coordinates scaled 1.15 about the tree's centre, so its ink
|
|
335
|
+
// stays comparable to the disc's instead of reading small beside it.
|
|
336
|
+
react_jsx_runtime.jsx("rect", { key: "trunk", x: 5.25, y: 7.3, width: 1.5, height: 4.14, rx: 0.63, fill: "currentColor" }),
|
|
337
|
+
react_jsx_runtime.jsx("circle", { key: "crown-1", cx: 6, cy: 3.62, r: 3.57, fill }),
|
|
338
|
+
react_jsx_runtime.jsx("circle", { key: "crown-2", cx: 3.24, cy: 5.46, r: 2.53, fill }),
|
|
339
|
+
react_jsx_runtime.jsx("circle", { key: "crown-3", cx: 8.76, cy: 5.46, r: 2.53, fill })
|
|
340
|
+
]
|
|
341
|
+
});
|
|
226
342
|
}
|
|
227
343
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
344
|
+
/**
|
|
345
|
+
* A linked worktree's directory name, or null when there is nothing to
|
|
346
|
+
* show. A main checkout shows NO text: the row surveys status and identity,
|
|
347
|
+
* and the branch lives on the input chip β which is why the branch is never
|
|
348
|
+
* rendered here. The name is the one thing the row's own label (the
|
|
349
|
+
* worktree directory by default) cannot be trusted to say once a workspace
|
|
350
|
+
* has been renamed.
|
|
351
|
+
*/
|
|
352
|
+
function rowName(info) {
|
|
353
|
+
if (info.isWorktree !== true) return null;
|
|
354
|
+
const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
|
|
355
|
+
return name === "" ? null : name;
|
|
356
|
+
}
|
|
231
357
|
|
|
232
358
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
359
|
+
* Row badge β workspace name on the left, the status mark floated right:
|
|
360
|
+
* the_paragliding_app β
|
|
361
|
+
* worktree_thing hotfix-tree π³
|
|
362
|
+
* Fill colour via badgeStatus(): green clean+synced, amber dirty or
|
|
363
|
+
* out-of-sync, red conflict or dirty-and-behind. Shape via the node half's
|
|
364
|
+
* isWorktree: circle = main checkout, tree = linked worktree.
|
|
365
|
+
* Always renders the workspace name (so the row keeps its identity) and
|
|
366
|
+
* appends the right-hand mark only for git workspaces. No branch, and no
|
|
367
|
+
* `|` separator.
|
|
368
|
+
*
|
|
369
|
+
* Targets the row's `workspaceId`. The seam also passes `cwd`, which is
|
|
370
|
+
* deliberately ignored: the node half resolves the directory itself, so
|
|
371
|
+
* the client never has to name one. A row with no workspaceId (the
|
|
372
|
+
* ungrouped bucket) has no workspace to report on, so it renders
|
|
373
|
+
* name-only.
|
|
236
374
|
*/
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
return
|
|
375
|
+
function WorkspaceGitBadge({ label, workspaceId }) {
|
|
376
|
+
// no detail=1: the row needs status and identity only, and the extra
|
|
377
|
+
// log / stash calls have no consumer yet
|
|
378
|
+
const info = useGitStatus(workspaceId === void 0 ? void 0 : { kind: "workspace", id: workspaceId });
|
|
379
|
+
const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
|
|
380
|
+
if (workspaceId !== void 0 && info !== void 0 && info.git === true) {
|
|
381
|
+
// marginLeft:auto floats the mark to the right edge, so the status
|
|
382
|
+
// shape holds a fixed right-hand column and a worktree's name grows
|
|
383
|
+
// leftwards from it instead of shoving the shape around.
|
|
384
|
+
const meta = [];
|
|
385
|
+
const name = rowName(info);
|
|
386
|
+
if (name !== null) meta.push(react_jsx_runtime.jsx("span", { key: "name", style: META_STYLE, children: name }));
|
|
387
|
+
meta.push(react_jsx_runtime.jsx(StatusMark, { key: "mark", info }));
|
|
388
|
+
children.push(react_jsx_runtime.jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", marginLeft: "auto", paddingLeft: "8px" }, children: meta }));
|
|
389
|
+
}
|
|
390
|
+
return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
|
|
253
391
|
}
|
|
254
392
|
|
|
393
|
+
//#region composer chip (upstream additive surface: conversation.input.left)
|
|
255
394
|
/**
|
|
256
|
-
* Chip line
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
395
|
+
* Chip line in the input row: git state of the workspace the CURRENT
|
|
396
|
+
* conversation is attached to. It targets the session id and lets the node
|
|
397
|
+
* half resolve the workspace, so this surface needs no `workspaces`
|
|
398
|
+
* service, no cwd and no path plumbing at all. Works on unpatched installs
|
|
399
|
+
* (conversation.input.left is an upstream additive slot), and stays useful
|
|
400
|
+
* next to the sidebar rows because it is context-anchored ("where am I")
|
|
401
|
+
* rather than surveying.
|
|
261
402
|
*/
|
|
262
403
|
function ComposerGitChip({ sessionId }) {
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
404
|
+
// no detail=1: nothing renders lastCommits / stashCount yet, and asking
|
|
405
|
+
// for them would add a log -3 plus a stash list to every refresh
|
|
406
|
+
const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId });
|
|
407
|
+
if (info === void 0 || info.git !== true) return null;
|
|
408
|
+
// the mark is an element now rather than a leading glyph in the string, so
|
|
409
|
+
// the SAME StatusMark the sidebar row draws carries the status here too;
|
|
410
|
+
// the container's 4px gap supplies the space the emoji's own did
|
|
411
|
+
const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
|
|
269
412
|
return react_jsx_runtime.jsx("span", {
|
|
270
413
|
style: {
|
|
271
414
|
display: "inline-flex",
|
|
@@ -278,28 +421,46 @@ window.__ModuleLoader__.load({
|
|
|
278
421
|
whiteSpace: "nowrap",
|
|
279
422
|
cursor: "default"
|
|
280
423
|
},
|
|
281
|
-
children:
|
|
424
|
+
children: [
|
|
425
|
+
react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
|
|
426
|
+
react_jsx_runtime.jsx("span", { key: "text", children: text })
|
|
427
|
+
]
|
|
282
428
|
});
|
|
283
429
|
}
|
|
284
430
|
//#endregion
|
|
285
431
|
|
|
286
|
-
|
|
432
|
+
// `workspaces` is no longer required: both surfaces target an id and the
|
|
433
|
+
// node half resolves the workspace, so the client never needs a service
|
|
434
|
+
// lookup. Fewer declared services also means fewer ways to fail to load.
|
|
435
|
+
const inject = ["slots"];
|
|
287
436
|
|
|
288
437
|
/**
|
|
289
438
|
* Register the badge into both seams. The seam owner hands each entry the
|
|
290
|
-
* row owner share as props; the badge destructures { workspaceId,
|
|
291
|
-
*
|
|
439
|
+
* row owner share as props; the badge destructures { workspaceId, label }
|
|
440
|
+
* and deliberately ignores the cwd it is also given.
|
|
292
441
|
*/
|
|
293
442
|
function apply(ctx) {
|
|
294
|
-
clientCtx = ctx;
|
|
295
443
|
// inject() re-evaluates when a seam's declaration appears, so boot
|
|
296
444
|
// order relative to the workspace browser does not matter. On an
|
|
297
|
-
// unpatched install the row
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
445
|
+
// unpatched install the row seam is never declared, so this callback
|
|
446
|
+
// never fires and the row badge simply never renders.
|
|
447
|
+
//
|
|
448
|
+
// The registration is reported from INSIDE the callback on purpose: a
|
|
449
|
+
// `spec()` check here at apply() time runs before the workspace browser
|
|
450
|
+
// declares the seam, so it reports the boot race rather than the
|
|
451
|
+
// outcome. That mistake made the old one-shot line claim "sidebar rows
|
|
452
|
+
// = off" while five row badges were rendering.
|
|
453
|
+
let rowsReported = false;
|
|
454
|
+
ctx.slots.inject("sidebar.workspaces.row", () => {
|
|
455
|
+
if (!rowsReported) {
|
|
456
|
+
rowsReported = true;
|
|
457
|
+
console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
|
|
458
|
+
}
|
|
459
|
+
return ctx.slots.register({
|
|
460
|
+
name: "sidebar.workspaces.row",
|
|
461
|
+
id: "git-badge"
|
|
462
|
+
}, WorkspaceGitBadge);
|
|
463
|
+
});
|
|
303
464
|
// Input-row chip: upstream additive slot rendered in the input bar's
|
|
304
465
|
// leading cluster, right after the access picker β the git state sits
|
|
305
466
|
// with the controls that govern the conversation. Present on every
|
|
@@ -312,7 +473,7 @@ window.__ModuleLoader__.load({
|
|
|
312
473
|
inject: (sessionId) => ({ sessionId })
|
|
313
474
|
}, ComposerGitChip));
|
|
314
475
|
const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
|
|
315
|
-
console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "
|
|
476
|
+
console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)." : "awaiting the sidebar.workspaces.row seam β the line above reports it if it appears."));
|
|
316
477
|
}
|
|
317
478
|
|
|
318
479
|
exports.apply = apply;
|
package/lib/index.js
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Hardened git-status service behind two routes:
|
|
5
5
|
*
|
|
6
|
-
* GET /api/git-badge?
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* GET /api/git-badge?workspace=<workspaceId> (sidebar rows)
|
|
7
|
+
* GET /api/git-badge?session=<sessionId> (the input chip)
|
|
8
|
+
* - NO client-supplied path is accepted. The caller names a workspace id or
|
|
9
|
+
* a session id; the server resolves the directory itself. `?path=` was
|
|
10
|
+
* removed precisely so a caller cannot aim the route at a directory of
|
|
11
|
+
* its choosing, even one inside the registry.
|
|
12
|
+
* - the route is intentionally unauthenticated (it is a local, read-only
|
|
13
|
+
* status feed), so resolution must never assume the caller is honest.
|
|
14
|
+
* Workspace ids are generated uuids and session ids are opaque, so the
|
|
15
|
+
* surface no longer offers anything to enumerate.
|
|
10
16
|
* - one git invocation per sample: `git --no-optional-locks status
|
|
11
17
|
* --porcelain=v2 --branch --untracked-files=all` yields branch,
|
|
12
18
|
* ahead/behind, and the changed / untracked counts in a single call
|
|
@@ -22,11 +28,22 @@
|
|
|
22
28
|
* markers (`MERGE_HEAD`, `rebase-merge`, β¦) are stat'd β surfaced as
|
|
23
29
|
* `operation`, so a paused rebase/cherry-pick is distinguishable from
|
|
24
30
|
* ordinary dirty work even when its conflicts are already staged.
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
31
|
+
* - the same invocation identifies a LINKED WORKTREE: its git dir is
|
|
32
|
+
* `<main>/.git/worktrees/<name>` instead of `<root>/.git`, and unlike the
|
|
33
|
+
* main worktree's it carries a `commondir` file. One stat of the git dir
|
|
34
|
+
* the call already returned yields `isWorktree`, plus the checkout's own
|
|
35
|
+
* directory name as `worktreeName` β so the chip can say WHICH working
|
|
36
|
+
* tree a conversation is in. Without it, several worktrees of one
|
|
37
|
+
* repository render identical `π‘ main` badges. The name is a NAME, never
|
|
38
|
+
* a path: the route takes no path and the client still needs to know none.
|
|
39
|
+
* - ahead/behind compare against the LOCAL remote-tracking ref, which only
|
|
40
|
+
* moves on fetch. A TTL-bounded fetch (60s per toplevel) keeps that ref
|
|
41
|
+
* fresh, but it runs OUT OF BAND: the answer is served from the refs at
|
|
42
|
+
* hand and a successful fetch notifies subscribers so the next refresh
|
|
43
|
+
* carries corrected ahead/behind. Awaiting it here cost ~3.3s per request
|
|
44
|
+
* on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
|
|
45
|
+
* shared timeout and the TTL bound keep an offline or slow remote harmless;
|
|
46
|
+
* on failure the stale-ref answer simply stands.
|
|
30
47
|
* - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
|
|
31
48
|
* consumes this yet (see the annotation on gitStatus) β it is kept for
|
|
32
49
|
* the planned hover card, and never paid for by row refresh.
|
|
@@ -34,18 +51,19 @@
|
|
|
34
51
|
* GET /api/git-badge/events (text/event-stream)
|
|
35
52
|
* - pushes `{ path }` dirty notifications the moment a workspace's git
|
|
36
53
|
* state can have changed. Freshness is EVENT-DRIVEN: each registered
|
|
37
|
-
* workspace
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
54
|
+
* workspace is watched recursively, so a commit / checkout / stage /
|
|
55
|
+
* worktree edit pushes one SSE message and every mounted row refetches
|
|
56
|
+
* immediately. There is no fixed polling interval anywhere in the
|
|
57
|
+
* pipeline. A MAIN worktree's git dir is `<root>/.git` and so is covered
|
|
58
|
+
* by the root watch; a LINKED worktree's is not, so its git dir gets a
|
|
59
|
+
* second watcher (see the watcher region).
|
|
41
60
|
*
|
|
42
61
|
* When upstream lands a workspace-metadata service, this half is deleted and
|
|
43
62
|
* the client half re-points at it β the seam contract does not change.
|
|
44
63
|
*/
|
|
45
64
|
import { execFile } from "node:child_process";
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import { join } from "node:path";
|
|
65
|
+
import { watch, existsSync, readFileSync } from "node:fs";
|
|
66
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
49
67
|
|
|
50
68
|
const inject = ["webServer", "workspaceRegistry"];
|
|
51
69
|
const name = "dsh-git-badge";
|
|
@@ -68,6 +86,12 @@ const config = {
|
|
|
68
86
|
debounceMs: 200,
|
|
69
87
|
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
70
88
|
watchRetryMs: 60000,
|
|
89
|
+
/**
|
|
90
|
+
* Degraded-mode poll interval for a workspace whose watcher failed. Only used
|
|
91
|
+
* where a real watcher could not be established or later errored β a healthy
|
|
92
|
+
* workspace is event-driven and never polls.
|
|
93
|
+
*/
|
|
94
|
+
pollFallbackMs: 5000,
|
|
71
95
|
/** SSE comment heartbeat interval */
|
|
72
96
|
heartbeatMs: 25000,
|
|
73
97
|
/**
|
|
@@ -230,6 +254,11 @@ function operationMarker(gitDir) {
|
|
|
230
254
|
* branch, dirty state, and ahead/behind counts (what git itself considers
|
|
231
255
|
* dirty), not the subdirectory in isolation. Intentional.
|
|
232
256
|
*
|
|
257
|
+
* Worktree identity is reported, not assumed: `isWorktree` marks a linked
|
|
258
|
+
* worktree and `worktreeName` names the checkout. A subdirectory of a linked
|
|
259
|
+
* worktree reports that worktree (the toplevel walk starts above it), which is
|
|
260
|
+
* the same whole-repository rule as above.
|
|
261
|
+
*
|
|
233
262
|
* `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
|
|
234
263
|
* The branch is retained for the planned hover card β nothing renders
|
|
235
264
|
* lastCommits / stashCount today.
|
|
@@ -248,6 +277,11 @@ async function gitStatus(dir, wantDetail) {
|
|
|
248
277
|
const toplevel = (topLines[0] ?? "").trim();
|
|
249
278
|
if (toplevel === "") return { git: false };
|
|
250
279
|
const gitDir = (topLines[1] ?? "").trim();
|
|
280
|
+
// A linked worktree's git dir carries a `commondir` file pointing back at
|
|
281
|
+
// the shared git dir; a main worktree's does not. Stat'ing it costs nothing
|
|
282
|
+
// extra β `gitDir` came from the invocation above. A submodule also has an
|
|
283
|
+
// out-of-tree git dir but no `commondir`, so it is correctly NOT a worktree.
|
|
284
|
+
const isWorktree = existsSync(join(gitDir, "commondir"));
|
|
251
285
|
// -uall counts untracked FILES; git's default collapses a new directory into
|
|
252
286
|
// a single entry, which is why `βn` under-reported anyone who added a folder.
|
|
253
287
|
// Routed through config.gitRunner so the suite can force the timeout branch.
|
|
@@ -269,18 +303,26 @@ async function gitStatus(dir, wantDetail) {
|
|
|
269
303
|
statusOut = await sample(false);
|
|
270
304
|
}
|
|
271
305
|
if (statusOut.stdout === null) return GIT_DEGRADED;
|
|
272
|
-
|
|
273
|
-
// The upstream header is the cheap gate: no upstream
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
306
|
+
const parsed = parseStatusV2(statusOut.stdout);
|
|
307
|
+
// The upstream header is the cheap gate: with no upstream the fetch would be a
|
|
308
|
+
// no-op probe, so skip it. With one, refresh the remote-tracking refs OUT OF
|
|
309
|
+
// BAND rather than before the answer.
|
|
310
|
+
//
|
|
311
|
+
// Blocking here cost ~3.3s per request (measured: `git fetch` over SSH) on every
|
|
312
|
+
// request whose TTL had lapsed β and that is the first refresh after each 60s
|
|
313
|
+
// window, i.e. constantly while someone is editing, which made an edit-triggered
|
|
314
|
+
// badge update take seconds. So: answer now from the refs we already have, and
|
|
315
|
+
// when the background fetch succeeds, notify subscribers β the client refetches,
|
|
316
|
+
// finds the TTL fresh, and picks up the corrected ahead/behind in ~50ms. The
|
|
317
|
+
// cost of the trade is that ahead/behind can lag by up to one fetch.
|
|
277
318
|
if (parsed.upstream !== void 0) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
319
|
+
void maybeFetch(toplevel)
|
|
320
|
+
.then((fetched) => {
|
|
321
|
+
if (fetched) notifyChange(toplevel);
|
|
322
|
+
})
|
|
323
|
+
.catch(() => {
|
|
324
|
+
/* a failed fetch just means the stale-ref answer stands */
|
|
325
|
+
});
|
|
284
326
|
}
|
|
285
327
|
const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
|
|
286
328
|
const info = {
|
|
@@ -295,6 +337,10 @@ async function gitStatus(dir, wantDetail) {
|
|
|
295
337
|
untrackedFiles: parsed.untracked,
|
|
296
338
|
untrackedMode,
|
|
297
339
|
operation: operationMarker(gitDir),
|
|
340
|
+
isWorktree,
|
|
341
|
+
// the checkout's own directory name (of the worktree root, so a
|
|
342
|
+
// subdirectory workspace still names the checkout it belongs to)
|
|
343
|
+
worktreeName: basename(toplevel),
|
|
298
344
|
ahead: parsed.ahead,
|
|
299
345
|
behind: parsed.behind
|
|
300
346
|
};
|
|
@@ -319,10 +365,11 @@ async function gitStatus(dir, wantDetail) {
|
|
|
319
365
|
|
|
320
366
|
//#region git-state watcher
|
|
321
367
|
/**
|
|
322
|
-
* One recursive watcher per workspace
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
368
|
+
* One recursive watcher per workspace, plus a second one when the git dir lives
|
|
369
|
+
* outside the workspace (a linked worktree). Any event under either (HEAD swap,
|
|
370
|
+
* index write, ref update, worktree save) marks the workspace dirty; a 200ms
|
|
371
|
+
* debounce collapses burst events (a single `git commit` touches index, refs,
|
|
372
|
+
* COMMIT_EDITMSG, objectsβ¦) into one notification.
|
|
326
373
|
*/
|
|
327
374
|
const watchers = new Map();
|
|
328
375
|
const changeListeners = new Set();
|
|
@@ -330,9 +377,12 @@ const changeListeners = new Set();
|
|
|
330
377
|
const openStreams = new Set();
|
|
331
378
|
|
|
332
379
|
function notifyChange(key) {
|
|
380
|
+
// `workspace` lets a client that asked by id match the event without ever
|
|
381
|
+
// knowing a filesystem path; `path` stays for debugging and other consumers.
|
|
382
|
+
const payload = { path: key, workspace: watchers.get(key)?.workspaceId };
|
|
333
383
|
for (const fn of changeListeners) {
|
|
334
384
|
try {
|
|
335
|
-
fn(
|
|
385
|
+
fn(payload);
|
|
336
386
|
} catch {
|
|
337
387
|
/* a dead SSE subscriber must never break the others */
|
|
338
388
|
}
|
|
@@ -355,45 +405,194 @@ function closeStream(stream) {
|
|
|
355
405
|
}
|
|
356
406
|
}
|
|
357
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Close every watcher attached to a record, leaving the record itself in place
|
|
410
|
+
* β the retry backoff and the degraded poll live on it. Tolerates a record
|
|
411
|
+
* whose watchers were never established, so every record shape is safe.
|
|
412
|
+
*/
|
|
413
|
+
function closeWatchers(record) {
|
|
414
|
+
for (const watcher of [record.watcher, record.extra]) {
|
|
415
|
+
if (watcher === null || watcher === void 0) continue;
|
|
416
|
+
try {
|
|
417
|
+
watcher.close();
|
|
418
|
+
} catch {
|
|
419
|
+
/* already closed */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
record.watcher = null;
|
|
423
|
+
if ("extra" in record) record.extra = null;
|
|
424
|
+
}
|
|
425
|
+
|
|
358
426
|
function unwatchWorkspace(key) {
|
|
359
427
|
const record = watchers.get(key);
|
|
360
428
|
if (record === void 0) return;
|
|
361
429
|
watchers.delete(key);
|
|
362
|
-
|
|
430
|
+
closeWatchers(record);
|
|
363
431
|
if (record.timer !== void 0) clearTimeout(record.timer);
|
|
432
|
+
if (record.poll !== void 0) clearInterval(record.poll);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Change-detection key for a workspace whose watcher failed. Deliberately cheap:
|
|
437
|
+
* it runs every `config.pollFallbackMs`, so the `-uall` walk is off the table.
|
|
438
|
+
* `status` covers worktree + index, the refs fingerprint covers what a fetch or
|
|
439
|
+
* an external checkout moves, and the marker stat covers an in-progress
|
|
440
|
+
* operation (which porcelain status does not report).
|
|
441
|
+
*
|
|
442
|
+
* The price of the cheap status: a new file inside an ALREADY untracked
|
|
443
|
+
* directory does not change the key, so on a broken-watcher workspace that case
|
|
444
|
+
* waits for the client's 60s poll. Recorded in TESTING.md rather than papered
|
|
445
|
+
* over with a `-uall` walk every few seconds. The git dir is resolved once and
|
|
446
|
+
* cached on the record.
|
|
447
|
+
*
|
|
448
|
+
* Resolves null when git could not run this tick; the caller keeps the previous
|
|
449
|
+
* key and tries again.
|
|
450
|
+
*/
|
|
451
|
+
async function fallbackStateKey(root, record) {
|
|
452
|
+
const status = await runGit(root, [
|
|
453
|
+
"--no-optional-locks",
|
|
454
|
+
"status",
|
|
455
|
+
"--porcelain=v2",
|
|
456
|
+
"--branch",
|
|
457
|
+
"--untracked-files=normal"
|
|
458
|
+
]);
|
|
459
|
+
if (status.stdout === null) return null;
|
|
460
|
+
if (record.gitDir === void 0) {
|
|
461
|
+
const dir = await runGit(root, ["rev-parse", "--absolute-git-dir"]);
|
|
462
|
+
record.gitDir = dir.stdout === null ? "" : dir.stdout.trim();
|
|
463
|
+
}
|
|
464
|
+
const refs = await runGit(root, ["for-each-ref", "--format=%(refname)%(objectname)", "refs/heads", "refs/remotes"]);
|
|
465
|
+
return [status.stdout, refs.stdout ?? "", operationMarker(record.gitDir) ?? ""].join("\u0000");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Start the degraded-mode poll for a workspace whose watcher could not be
|
|
470
|
+
* established, or later errored. Only ever called for a workspace that HAS a
|
|
471
|
+
* .git β a directory without one is not a repository, so polling it would be
|
|
472
|
+
* waste; the retry backoff keeps re-checking that case instead.
|
|
473
|
+
*
|
|
474
|
+
* The interval is unref'd so a forgotten one can never hold the process open
|
|
475
|
+
* (test runs included); `unwatchWorkspace` clears it properly.
|
|
476
|
+
*/
|
|
477
|
+
function startFallbackPoll(key, root) {
|
|
478
|
+
const record = watchers.get(key);
|
|
479
|
+
if (record === void 0 || record.poll !== void 0) return;
|
|
480
|
+
const tick = () => {
|
|
481
|
+
void (async () => {
|
|
482
|
+
const current = watchers.get(key);
|
|
483
|
+
if (current === void 0) return;
|
|
484
|
+
const next = await fallbackStateKey(root, current).catch(() => null);
|
|
485
|
+
if (next === null) return;
|
|
486
|
+
// the first tick only sets a baseline: a fresh subscriber fetches on
|
|
487
|
+
// mount, so there is nothing to announce yet
|
|
488
|
+
if (current.pollKey !== void 0 && current.pollKey !== next) notifyChange(key);
|
|
489
|
+
current.pollKey = next;
|
|
490
|
+
})();
|
|
491
|
+
};
|
|
492
|
+
record.poll = setInterval(tick, config.pollFallbackMs);
|
|
493
|
+
if (typeof record.poll.unref === "function") record.poll.unref();
|
|
494
|
+
// Baseline immediately rather than one interval from now, so a change landing
|
|
495
|
+
// right after the watcher died is still noticed on the following tick instead
|
|
496
|
+
// of being baked into the baseline and never announced.
|
|
497
|
+
tick();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The out-of-tree git dir for a workspace whose `.git` is a FILE rather than a
|
|
502
|
+
* directory β a linked worktree (`<main>/.git/worktrees/<name>`) or a submodule
|
|
503
|
+
* (`<parent>/.git/modules/<name>`). Git writes the index, HEAD and reflogs
|
|
504
|
+
* THERE, so a watcher on the worktree root alone never sees a stage, commit or
|
|
505
|
+
* checkout in a linked worktree; it only ever catches file edits. (Symptom
|
|
506
|
+
* before this fix: a worktree's badge moved only on a save or the client's 60s
|
|
507
|
+
* poll, never on commit.) Reading the gitfile mirrors git's own resolution,
|
|
508
|
+
* costs one small read at watch setup rather than another `rev-parse`, and stays
|
|
509
|
+
* correct for submodules. Returns null for a main worktree, whose `.git` is a
|
|
510
|
+
* directory already covered by the root watch.
|
|
511
|
+
*/
|
|
512
|
+
function outerGitDir(root) {
|
|
513
|
+
let content;
|
|
514
|
+
try {
|
|
515
|
+
// Reading the DIRECTORY `.git` of a main worktree raises EISDIR, which is
|
|
516
|
+
// the "no out-of-tree git dir" answer; a `.git` symlink to a gitfile
|
|
517
|
+
// still reads, where an isFile() stat would have followed it instead.
|
|
518
|
+
content = readFileSync(join(root, ".git"), "utf8");
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(content);
|
|
523
|
+
if (match === null) return null;
|
|
524
|
+
const target = match[1];
|
|
525
|
+
return isAbsolute(target) ? target : resolve(root, target);
|
|
364
526
|
}
|
|
365
527
|
|
|
366
|
-
function watchWorkspace(root, key) {
|
|
528
|
+
function watchWorkspace(root, key, workspaceId) {
|
|
367
529
|
const existing = watchers.get(key);
|
|
368
530
|
if (existing !== void 0) {
|
|
369
531
|
// live watcher, or a failed attempt still inside its retry backoff
|
|
370
|
-
if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs)
|
|
532
|
+
if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) {
|
|
533
|
+
existing.workspaceId = workspaceId;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// retrying: the degraded poll is replaced by a real watcher, or by a
|
|
537
|
+
// fresh one created below
|
|
538
|
+
if (existing.poll !== void 0) clearInterval(existing.poll);
|
|
371
539
|
watchers.delete(key);
|
|
372
540
|
}
|
|
373
541
|
// only git workspaces need a badge; a missing .git skips the watcher but
|
|
374
542
|
// retries on the normal backoff (cheap existsSync) so a later `git init`
|
|
375
|
-
// in the workspace is picked up
|
|
543
|
+
// in the workspace is picked up. No poll here: a non-repository has nothing
|
|
544
|
+
// to report.
|
|
376
545
|
if (!existsSync(join(root, ".git"))) {
|
|
377
|
-
watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
|
|
546
|
+
watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
|
|
378
547
|
return;
|
|
379
548
|
}
|
|
549
|
+
// Both watchers share one debounce and one failure path. Either dying means
|
|
550
|
+
// this workspace can no longer be trusted to be event-driven, so both are
|
|
551
|
+
// dropped and the degraded poll covers it until the retry backoff
|
|
552
|
+
// re-establishes them.
|
|
553
|
+
const onEvent = () => {
|
|
554
|
+
const record = watchers.get(key);
|
|
555
|
+
if (record === void 0) return;
|
|
556
|
+
clearTimeout(record.timer);
|
|
557
|
+
record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
|
|
558
|
+
};
|
|
559
|
+
const onError = () => {
|
|
560
|
+
// The watcher died after being established (inotify budget, tree
|
|
561
|
+
// replaced, permissions). Drop it, but KEEP the record so the retry
|
|
562
|
+
// backoff and the degraded poll both have somewhere to live β and
|
|
563
|
+
// start polling so this workspace stays fresh in the meantime.
|
|
564
|
+
const current = watchers.get(key);
|
|
565
|
+
if (current === void 0) return;
|
|
566
|
+
closeWatchers(current);
|
|
567
|
+
current.failedAt = Date.now();
|
|
568
|
+
startFallbackPoll(key, root);
|
|
569
|
+
};
|
|
380
570
|
try {
|
|
381
|
-
// Watch the whole worktree
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
571
|
+
// Watch the whole worktree: worktree edits (the most common dirty signal)
|
|
572
|
+
// surface here and the debounce collapses save bursts. In a MAIN worktree
|
|
573
|
+
// this covers git metadata too, because its git dir IS `<root>/.git`.
|
|
574
|
+
const watcher = watch(root, { recursive: true }, onEvent);
|
|
575
|
+
watcher.on("error", onError);
|
|
576
|
+
// A linked worktree's git dir is OUTSIDE root, so staging, committing and
|
|
577
|
+
// checking out there write nothing this watch can see. Watch it too; if it
|
|
578
|
+
// cannot be watched the root watch still covers file edits, and the
|
|
579
|
+
// degraded poll is not started for a merely partial loss.
|
|
580
|
+
const gitDir = outerGitDir(root);
|
|
581
|
+
let extra = null;
|
|
582
|
+
if (gitDir !== null) {
|
|
583
|
+
try {
|
|
584
|
+
extra = watch(gitDir, { recursive: true }, onEvent);
|
|
585
|
+
extra.on("error", onError);
|
|
586
|
+
} catch {
|
|
587
|
+
extra = null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
watchers.set(key, { watcher, extra, timer: void 0, workspaceId });
|
|
394
591
|
} catch {
|
|
395
|
-
// watch refused (permissions, watch budget) β
|
|
396
|
-
|
|
592
|
+
// watch refused (permissions, watch budget) β back off AND poll: this
|
|
593
|
+
// repository is real, just unwatchable
|
|
594
|
+
watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
|
|
595
|
+
startFallbackPoll(key, root);
|
|
397
596
|
}
|
|
398
597
|
}
|
|
399
598
|
|
|
@@ -401,15 +600,66 @@ function watchWorkspace(root, key) {
|
|
|
401
600
|
function syncWatchers(ctx) {
|
|
402
601
|
const wanted = new Set();
|
|
403
602
|
for (const entity of ctx.workspaceRegistry.list()) {
|
|
404
|
-
|
|
405
|
-
if (typeof
|
|
406
|
-
wanted.add(
|
|
407
|
-
watchWorkspace(
|
|
603
|
+
// `path` and `id` are the entity's public surface; its `record` is private
|
|
604
|
+
if (typeof entity?.path !== "string") continue;
|
|
605
|
+
wanted.add(entity.path);
|
|
606
|
+
watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
|
|
408
607
|
}
|
|
409
608
|
for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
|
|
410
609
|
}
|
|
411
610
|
//#endregion
|
|
412
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Resolve a request to a registered workspace directory. The caller says WHO it
|
|
614
|
+
* is, never WHERE to look β no client-supplied path is trusted or probed.
|
|
615
|
+
*
|
|
616
|
+
* 1. `?workspace=<id>` β the entity's own id, which is the same id the sidebar
|
|
617
|
+
* seam hands a row (the client builds `workspaceId` from `workspace.id`).
|
|
618
|
+
* 2. `?session=<id>` β registry membership first, because `sessionIds` is
|
|
619
|
+
* header-validated and durable, so a conversation that is no longer live
|
|
620
|
+
* still resolves; then the live session's cwd validated through the
|
|
621
|
+
* registry's `resolveByPath`, which covers the window where a brand-new
|
|
622
|
+
* session is not yet attached to its workspace.
|
|
623
|
+
*
|
|
624
|
+
* @returns `{ id, path }` when resolved, else `{ status, error: { code, message } }`.
|
|
625
|
+
* The id is returned because the route echoes it: a client that asked by
|
|
626
|
+
* SESSION needs the workspace id to attribute that workspace's SSE events,
|
|
627
|
+
* since an event carries a workspace id and a session id can never match it.
|
|
628
|
+
*/
|
|
629
|
+
async function resolveWorkspace(ctx, params) {
|
|
630
|
+
const workspaceId = params.get("workspace") ?? "";
|
|
631
|
+
const sessionId = params.get("session") ?? "";
|
|
632
|
+
if (workspaceId === "" && sessionId === "") {
|
|
633
|
+
return { status: 400, error: { code: "target-required", message: "pass workspace=<id> or session=<id>" } };
|
|
634
|
+
}
|
|
635
|
+
const entities = [];
|
|
636
|
+
for (const entity of ctx.workspaceRegistry.list()) {
|
|
637
|
+
if (typeof entity?.path === "string") entities.push(entity);
|
|
638
|
+
}
|
|
639
|
+
if (workspaceId !== "") {
|
|
640
|
+
const entity = entities.find((candidate) => String(candidate.id) === workspaceId);
|
|
641
|
+
if (entity === void 0) {
|
|
642
|
+
return { status: 404, error: { code: "workspace-not-found", message: "no workspace with that id" } };
|
|
643
|
+
}
|
|
644
|
+
return { id: String(entity.id), path: entity.path };
|
|
645
|
+
}
|
|
646
|
+
for (const entity of entities) {
|
|
647
|
+
if (Array.isArray(entity.sessionIds) && entity.sessionIds.includes(sessionId)) {
|
|
648
|
+
return { id: String(entity.id), path: entity.path };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// A brand-new session whose workspace attach has not landed yet. Trust the
|
|
652
|
+
// server-side session header (never the caller), then require the registry to
|
|
653
|
+
// recognise that directory β so the path is still registry-validated.
|
|
654
|
+
const cwd = ctx.get("sessions")?.get?.(sessionId)?.header?.cwd;
|
|
655
|
+
const resolveByPath = ctx.workspaceRegistry.resolveByPath;
|
|
656
|
+
if (typeof cwd === "string" && cwd !== "" && typeof resolveByPath === "function") {
|
|
657
|
+
const entity = await resolveByPath.call(ctx.workspaceRegistry, cwd).catch(() => void 0);
|
|
658
|
+
if (entity !== void 0 && typeof entity?.path === "string") return { id: String(entity.id), path: entity.path };
|
|
659
|
+
}
|
|
660
|
+
return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
|
|
661
|
+
}
|
|
662
|
+
|
|
413
663
|
/** Host plugin body β register the status route and the SSE change feed. */
|
|
414
664
|
function apply(ctx) {
|
|
415
665
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -418,28 +668,23 @@ function apply(ctx) {
|
|
|
418
668
|
handler: async (req, res) => {
|
|
419
669
|
try {
|
|
420
670
|
const url = new URL(req.url, "http://localhost");
|
|
421
|
-
const
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const registered = new Set();
|
|
426
|
-
for (const entity of ctx.workspaceRegistry.list()) {
|
|
427
|
-
const p = entity?.record?.path ?? entity?.path;
|
|
428
|
-
if (typeof p === "string") registered.add(p);
|
|
429
|
-
}
|
|
430
|
-
const resolved = await realpath(raw).catch(() => null);
|
|
431
|
-
if (resolved === null || !registered.has(raw) && !registered.has(resolved)) {
|
|
432
|
-
res.writeHead(403, { "content-type": "application/json" });
|
|
433
|
-
res.end(JSON.stringify({ git: false, error: "path is not a registered workspace" }));
|
|
671
|
+
const target = await resolveWorkspace(ctx, url.searchParams);
|
|
672
|
+
if (target.error !== void 0) {
|
|
673
|
+
res.writeHead(target.status, { "content-type": "application/json" });
|
|
674
|
+
res.end(JSON.stringify({ git: false, error: target.error }));
|
|
434
675
|
return;
|
|
435
676
|
}
|
|
436
677
|
const detail = url.searchParams.get("detail") === "1";
|
|
437
|
-
const
|
|
678
|
+
const info = await gitStatus(target.path, detail);
|
|
679
|
+
// Echo the resolved workspace id. SSE events identify the workspace,
|
|
680
|
+
// not the session, so a session-targeted client has no other way to
|
|
681
|
+
// tell whether an event belongs to it β without this the input chip
|
|
682
|
+
// only ever refreshed on remount or the 60s poll.
|
|
438
683
|
res.writeHead(200, { "content-type": "application/json" });
|
|
439
|
-
res.end(
|
|
684
|
+
res.end(JSON.stringify({ ...info, workspace: target.id }));
|
|
440
685
|
} catch (error) {
|
|
441
686
|
res.writeHead(400, { "content-type": "application/json" });
|
|
442
|
-
res.end(JSON.stringify({ git: false, error: String(error?.message ?? error) }));
|
|
687
|
+
res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
|
|
443
688
|
}
|
|
444
689
|
}
|
|
445
690
|
}));
|
|
@@ -464,12 +709,12 @@ function apply(ctx) {
|
|
|
464
709
|
const stream = {
|
|
465
710
|
res,
|
|
466
711
|
heartbeat: null,
|
|
467
|
-
send: (
|
|
712
|
+
send: (payload) => {
|
|
468
713
|
try {
|
|
469
714
|
// NAMED frame: the client subscribes with
|
|
470
715
|
// addEventListener("change", β¦), which never sees an
|
|
471
716
|
// unnamed message
|
|
472
|
-
res.write(`event: change\ndata: ${JSON.stringify(
|
|
717
|
+
res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
473
718
|
} catch {
|
|
474
719
|
/* socket gone; the close handler cleans up */
|
|
475
720
|
}
|
|
@@ -510,9 +755,11 @@ export {
|
|
|
510
755
|
config,
|
|
511
756
|
OPERATION_MARKERS,
|
|
512
757
|
operationMarker,
|
|
758
|
+
outerGitDir,
|
|
513
759
|
parseStatusV2,
|
|
514
760
|
runGit,
|
|
515
761
|
gitStatus,
|
|
762
|
+
resolveWorkspace,
|
|
516
763
|
watchWorkspace,
|
|
517
764
|
unwatchWorkspace,
|
|
518
765
|
syncWatchers,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"cordis.patch.yml",
|
|
49
49
|
"package.json",
|
|
50
50
|
"README.md",
|
|
51
|
+
"CHANGELOG.md",
|
|
51
52
|
"LICENSE"
|
|
52
53
|
],
|
|
53
54
|
"peerDependencies": {
|