dsh-graft-plugin 0.2.0 → 0.3.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/LICENSE +21 -21
- package/README.md +27 -1
- package/package.json +13 -8
- package/scripts/test-autosync.mjs +66 -1
- package/scripts/test-guidance.mjs +160 -0
- package/scripts/test-harness.mjs +219 -1
- package/src/auto-sync.js +34 -2
- package/src/client.js +438 -59
- package/src/graft-tools.js +11 -0
- package/src/index.js +190 -36
package/src/client.js
CHANGED
|
@@ -8,6 +8,21 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
const React = require("react");
|
|
9
9
|
const h = React.createElement;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* dsh's own themed modal chrome, the same package permission-presets
|
|
13
|
+
* renders its RiskConfirmation from — body-portaled, Escape + mask-click
|
|
14
|
+
* handled, themed through the --dsw-* variables for free.
|
|
15
|
+
*
|
|
16
|
+
* Guarded: if the package is ever unresolvable the confirm falls back to
|
|
17
|
+
* window.confirm, so the gate can never go missing silently.
|
|
18
|
+
*/
|
|
19
|
+
let Primitives = null;
|
|
20
|
+
try {
|
|
21
|
+
Primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
22
|
+
} catch {
|
|
23
|
+
Primitives = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
/* ================================================================== *
|
|
12
27
|
* graft-status — the browser half.
|
|
13
28
|
*
|
|
@@ -50,7 +65,23 @@ window.__ModuleLoader__.load({
|
|
|
50
65
|
function identity(value) {
|
|
51
66
|
return value;
|
|
52
67
|
}
|
|
53
|
-
|
|
68
|
+
/*
|
|
69
|
+
* Pass-through codec carrying both codec shapes: dsh 0.2's typert
|
|
70
|
+
* validator requires a strict codec to expose a `create()` factory
|
|
71
|
+
* returning the schema (it throws "strict codec has no create() factory"
|
|
72
|
+
* otherwise, failing `$mount` and with it the whole plugin), while dsh 0.1
|
|
73
|
+
* read `schema` directly; dsh 0.1.7-rc.2 already refused a codec with no
|
|
74
|
+
* factory on this fork. `create()` returns the same schema object every
|
|
75
|
+
* time. The host still runs assertJsonValue on every decoded value, so
|
|
76
|
+
* identity is safe.
|
|
77
|
+
*/
|
|
78
|
+
const IDENTITY_SCHEMA = { parse: identity };
|
|
79
|
+
const CODEC = {
|
|
80
|
+
mode: "strict",
|
|
81
|
+
typeSymbol: "graft-status/json",
|
|
82
|
+
schema: IDENTITY_SCHEMA,
|
|
83
|
+
create: () => IDENTITY_SCHEMA,
|
|
84
|
+
};
|
|
54
85
|
const descriptor = (method, names) => ({
|
|
55
86
|
id: "graft-status#graftStatus/" + method,
|
|
56
87
|
service: "graftStatus",
|
|
@@ -58,12 +89,12 @@ window.__ModuleLoader__.load({
|
|
|
58
89
|
method,
|
|
59
90
|
invocation: { kind: "direct" },
|
|
60
91
|
parameters: names.map((name) => ({ name, wire: name, source: "json", codec: CODEC })),
|
|
61
|
-
result:
|
|
92
|
+
result: CODEC,
|
|
62
93
|
sourceLocation: { file: "graft-status/client.js", line: 1, column: 1 },
|
|
63
94
|
});
|
|
64
95
|
const CONTRIBUTION = {
|
|
65
96
|
package: "graft-status",
|
|
66
|
-
descriptors: [descriptor("status", ["sessionId"]), descriptor("viz", ["sessionId"])],
|
|
97
|
+
descriptors: [descriptor("status", ["sessionId"]), descriptor("viz", ["sessionId"]), descriptor("build", ["sessionId"])],
|
|
67
98
|
};
|
|
68
99
|
|
|
69
100
|
/**
|
|
@@ -120,84 +151,114 @@ window.__ModuleLoader__.load({
|
|
|
120
151
|
return [
|
|
121
152
|
".gs-wrap{display:inline-flex;align-items:center;gap:4px}",
|
|
122
153
|
".gs-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;",
|
|
123
|
-
"border-radius:13px;border:1px solid var(--
|
|
124
|
-
"background:var(--
|
|
154
|
+
"border-radius:13px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.28));",
|
|
155
|
+
"background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));color:var(--dsw-alias-label-secondary,inherit);",
|
|
125
156
|
"font-size:12px;line-height:1;white-space:nowrap;cursor:default;max-width:200px}",
|
|
126
157
|
".gs-chip .gs-name{font-weight:600;overflow:hidden;text-overflow:ellipsis}",
|
|
158
|
+
// The repo name in the trailing-row chip. The chip caps at 200px but
|
|
159
|
+
// the name itself had no ellipsis of its own, so a long workspace name
|
|
160
|
+
// stretched the composer instead of collapsing to "name…". The brand
|
|
161
|
+
// ("graft") and separators never shrink; this span takes the cut.
|
|
162
|
+
".gs-chip .gs-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}",
|
|
127
163
|
".gs-chip .gs-sep{opacity:.45}",
|
|
128
164
|
".gs-chip .gs-dot{font-size:9px;line-height:1}",
|
|
129
|
-
".gs-ok .gs-dot{color
|
|
130
|
-
".gs-warn .gs-dot{color
|
|
131
|
-
".gs-busy .gs-dot{color
|
|
165
|
+
".gs-ok .gs-dot{color:var(--dsw-alias-state-success-primary,#3fa45b)}",
|
|
166
|
+
".gs-warn .gs-dot{color:var(--dsw-alias-state-warn-primary,#d08442)}",
|
|
167
|
+
".gs-busy .gs-dot{color:var(--dsw-alias-state-business-primary,#4b8bd6);display:inline-block;animation:gs-spin 900ms linear infinite}",
|
|
132
168
|
"@keyframes gs-spin{to{transform:rotate(360deg)}}",
|
|
133
|
-
/* A rebuild takes about a second, so the chip also breathes: the dot
|
|
134
|
-
alone is small enough to miss at a glance. */
|
|
135
169
|
".gs-chip.gs-busy{animation:gs-breathe 1.4s ease-in-out infinite}",
|
|
136
170
|
"@keyframes gs-breathe{0%,100%{opacity:1}50%{opacity:.62}}",
|
|
137
171
|
"@media (prefers-reduced-motion:reduce){.gs-busy .gs-dot{animation:none}.gs-chip.gs-busy{animation:none}}",
|
|
138
|
-
".gs-idle .gs-dot{color:var(--
|
|
172
|
+
".gs-idle .gs-dot{color:var(--dsw-alias-label-tertiary,#888)}",
|
|
173
|
+
// A graft tool is running: the dot blinks violet, on and off. The
|
|
174
|
+
// blinking itself is JS toggling `gs-live`; this only paints it.
|
|
175
|
+
// Violet is pinned, not an alias: dsw's state palette has no violet,
|
|
176
|
+
// and it must NOT borrow the busy blue — the two states would be
|
|
177
|
+
// indistinguishable. Against both green and orange it also pops.
|
|
178
|
+
".gs-chip.gs-live .gs-dot{color:#8b5cf6}",
|
|
139
179
|
".gs-chip.gs-muted{opacity:.6}",
|
|
140
180
|
".gs-chip.gs-clickable{cursor:pointer}",
|
|
141
|
-
".gs-chip.gs-clickable:hover{border-color:var(--
|
|
142
|
-
".gs-chip.gs-clickable:focus-visible{outline:2px solid var(--
|
|
181
|
+
".gs-chip.gs-clickable:hover{border-color:var(--dsw-alias-border-l3,rgba(128,128,128,.5))}",
|
|
182
|
+
".gs-chip.gs-clickable:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4b8bd6));outline-offset:2px}",
|
|
143
183
|
".gs-viz{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;",
|
|
144
|
-
"padding:0;border:0;border-radius:8px;background:transparent;color:var(--
|
|
145
|
-
"cursor:pointer;--accent:var(--
|
|
146
|
-
".gs-viz:hover{background:var(--
|
|
184
|
+
"padding:0;border:0;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:var(--dsw-alias-label-secondary,inherit);",
|
|
185
|
+
"cursor:pointer;--accent:var(--dsw-alias-brand-primary,#e5484d)}",
|
|
186
|
+
".gs-viz:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))}",
|
|
147
187
|
".gs-viz:disabled{opacity:.5;cursor:progress}",
|
|
148
|
-
".gs-viz.gs-err{color:var(--
|
|
149
|
-
/* The tab: a header row in the sidebar's own idiom (short row, hairline
|
|
150
|
-
rule, icon control on the right), then the visualiser filling the rest. */
|
|
188
|
+
".gs-viz.gs-err{color:var(--dsw-alias-state-error-primary,#e5484d)}",
|
|
151
189
|
".gs-tabwrap{display:flex;flex-direction:column;height:100%;min-height:0}",
|
|
152
190
|
".gs-tabbar{display:flex;align-items:center;gap:6px;padding:0 6px 0 10px;min-height:32px;",
|
|
153
|
-
"border-bottom:1px solid var(--
|
|
154
|
-
"white-space:nowrap;overflow:hidden;flex:0 0 auto}",
|
|
155
|
-
".gs-tb-brand{color:var(--
|
|
191
|
+
"border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));font-size:11.5px;",
|
|
192
|
+
"color:var(--dsw-alias-label-secondary,inherit);white-space:nowrap;overflow:hidden;flex:0 0 auto}",
|
|
193
|
+
".gs-tb-brand{color:var(--dsw-alias-brand-primary,#e5484d);font-weight:700}",
|
|
156
194
|
".gs-tb-sep{opacity:.38}",
|
|
157
|
-
".gs-tb-dim{opacity:.72;overflow:hidden;text-overflow:ellipsis}",
|
|
195
|
+
".gs-tb-dim{color:var(--dsw-alias-label-tertiary,currentColor);opacity:.72;overflow:hidden;text-overflow:ellipsis}",
|
|
158
196
|
".gs-tb-fresh{font-weight:600}",
|
|
159
|
-
".gs-tb-fresh.gs-ok{color
|
|
160
|
-
".gs-tb-fresh.gs-warn{color
|
|
161
|
-
".gs-tb-fresh.gs-busy{color
|
|
162
|
-
".gs-tb-fresh.gs-idle{color:var(--
|
|
163
|
-
".gs-tb-saved{color
|
|
197
|
+
".gs-tb-fresh.gs-ok{color:var(--dsw-alias-state-success-primary,#5cbe78)}",
|
|
198
|
+
".gs-tb-fresh.gs-warn{color:var(--dsw-alias-state-warn-primary,#e0a066)}",
|
|
199
|
+
".gs-tb-fresh.gs-busy{color:var(--dsw-alias-state-business-primary,#79aee8)}",
|
|
200
|
+
".gs-tb-fresh.gs-idle{color:var(--dsw-alias-label-tertiary,#9a9a9a)}",
|
|
201
|
+
".gs-tb-saved{color:var(--dsw-alias-link,#79aee8)}",
|
|
164
202
|
".gs-refresh{margin-left:auto;display:inline-flex;align-items:center;justify-content:center;",
|
|
165
|
-
"width:24px;height:24px;flex:0 0 auto;padding:0;border:0;border-radius:6px;background:transparent;",
|
|
166
|
-
"color:var(--
|
|
167
|
-
".gs-refresh:hover{background:var(--
|
|
203
|
+
"width:24px;height:24px;flex:0 0 auto;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;",
|
|
204
|
+
"color:var(--dsw-alias-label-secondary,inherit);cursor:pointer;opacity:.75}",
|
|
205
|
+
".gs-refresh:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));opacity:1}",
|
|
168
206
|
".gs-refresh.gs-spinning svg{animation:gs-spin 600ms linear}",
|
|
169
|
-
".gs-tabframe{width:100%;flex:1 1 auto;min-height:0;border:0;background
|
|
170
|
-
".gs-tabmsg{padding:14px;font-size:12px;line-height:1.6;opacity:.85}",
|
|
207
|
+
".gs-tabframe{width:100%;flex:1 1 auto;min-height:0;border:0;background:var(--dsw-alias-bg-base,transparent);display:block}",
|
|
208
|
+
".gs-tabmsg{padding:14px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,inherit);opacity:.85}",
|
|
171
209
|
".gs-tabreason{margin-top:6px;opacity:.7;font-family:ui-monospace,monospace;font-size:11px;word-break:break-word}",
|
|
172
|
-
/* The hover card. Anchored to the wrap, which is why the wrap is
|
|
173
|
-
positioned; `pointer-events:none` so it can never swallow a click
|
|
174
|
-
meant for the button underneath it. */
|
|
175
210
|
".gs-wrap{position:relative}",
|
|
176
211
|
".gs-pop{position:absolute;bottom:calc(100% + 10px);right:0;z-index:80;width:296px;",
|
|
177
212
|
"padding:12px 13px;border-radius:12px;pointer-events:none;text-align:left;",
|
|
178
|
-
"border:1px solid var(--
|
|
179
|
-
"background:var(--
|
|
213
|
+
"border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));",
|
|
214
|
+
"background:var(--dsw-alias-bg-layer-2,#1b1b1e);color:var(--dsw-alias-label-primary,inherit);",
|
|
180
215
|
"box-shadow:0 10px 30px rgba(0,0,0,.34);font-size:12px;line-height:1.5;",
|
|
181
216
|
"opacity:0;transform:translateY(4px);animation:gs-pop-in .13s ease-out forwards}",
|
|
182
217
|
"@keyframes gs-pop-in{to{opacity:1;transform:none}}",
|
|
183
218
|
".gs-pop-head{display:flex;align-items:center;gap:8px;margin-bottom:9px}",
|
|
184
219
|
".gs-pop-head .gs-pop-title{font-weight:700;letter-spacing:.02em}",
|
|
185
|
-
|
|
220
|
+
// Pill + Build travel as ONE group at the header's right: the wrapper
|
|
221
|
+
// carries the row's only auto margin, so the 8px flex gap is the only
|
|
222
|
+
// thing between the pair and nothing can split them — either apart or
|
|
223
|
+
// across the header.
|
|
224
|
+
".gs-pop-group{margin-left:auto;display:inline-flex;align-items:center;gap:8px}",
|
|
225
|
+
".gs-pill{font-size:9.5px;font-weight:700;text-transform:uppercase;",
|
|
186
226
|
"letter-spacing:.07em;padding:3px 8px;border-radius:999px;white-space:nowrap}",
|
|
187
|
-
".gs-pill.gs-p-ok{background:
|
|
188
|
-
".gs-pill.gs-p-warn{background:
|
|
189
|
-
".gs-pill.gs-p-busy{background:
|
|
190
|
-
".gs-pill.gs-p-idle{background:
|
|
227
|
+
".gs-pill.gs-p-ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#3fa45b) 18%,transparent);color:var(--dsw-alias-state-success-primary,#5cbe78)}",
|
|
228
|
+
".gs-pill.gs-p-warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#d08442) 20%,transparent);color:var(--dsw-alias-state-warn-primary,#e0a066)}",
|
|
229
|
+
".gs-pill.gs-p-busy{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4b8bd6) 20%,transparent);color:var(--dsw-alias-state-business-primary,#79aee8)}",
|
|
230
|
+
".gs-pill.gs-p-idle{background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 20%,transparent);color:var(--dsw-alias-label-tertiary,#9a9a9a)}",
|
|
191
231
|
".gs-pop-repo{font-weight:600;font-size:12.5px;margin-bottom:2px}",
|
|
192
232
|
".gs-pop-path{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
|
|
193
|
-
"opacity:.58;word-break:break-all}",
|
|
233
|
+
"color:var(--dsw-alias-label-tertiary,inherit);opacity:.58;word-break:break-all}",
|
|
194
234
|
".gs-pop-stats{display:flex;gap:18px;margin:11px 0 0;padding:10px 0 2px;",
|
|
195
|
-
"border-top:1px solid var(--
|
|
235
|
+
"border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}",
|
|
196
236
|
".gs-stat b{display:block;font-size:15px;font-weight:700;line-height:1.2;font-variant-numeric:tabular-nums}",
|
|
197
237
|
".gs-stat i{display:block;font-style:normal;font-size:9.5px;text-transform:uppercase;",
|
|
198
238
|
"letter-spacing:.07em;opacity:.55;margin-top:1px}",
|
|
199
239
|
".gs-pop-note{margin-top:10px;padding-top:9px;font-size:11px;opacity:.68;",
|
|
200
|
-
"border-top:1px solid var(--
|
|
240
|
+
"border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}",
|
|
241
|
+
// The popup's Build button, grouped with the status pill at the
|
|
242
|
+
// header's right (see .gs-pop-group). Quiet at header scale — an
|
|
243
|
+
// outline button, not the brand fill — because a 24px solid pill
|
|
244
|
+
// would dominate the 9.5px status pill it sits beside. Disabled (and
|
|
245
|
+
// labelled Building…) while the build runs, so a double click cannot
|
|
246
|
+
// queue two of them.
|
|
247
|
+
//
|
|
248
|
+
// pointer-events:auto is load-bearing, not cosmetic: the card itself
|
|
249
|
+
// is pointer-events:none (pure text needs no clicks and must not eat
|
|
250
|
+
// them), so without this the button renders but can never be pressed.
|
|
251
|
+
".gs-build{pointer-events:auto;display:inline-flex;align-items:center;height:20px;padding:0 10px;",
|
|
252
|
+
"border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:999px;",
|
|
253
|
+
"background:transparent;color:var(--dsw-alias-label-secondary,inherit);",
|
|
254
|
+
"font-size:10.5px;font-weight:600;cursor:pointer;white-space:nowrap}",
|
|
255
|
+
".gs-build:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))}",
|
|
256
|
+
".gs-build:disabled{opacity:.6;cursor:progress}",
|
|
257
|
+
// The confirm modal's footer: Cancel beside a brand-fill accept, in
|
|
258
|
+
// the theme's own button language. The Modal itself (mask, card,
|
|
259
|
+
// Escape, focus) is dsh's chrome and needs no styles here.
|
|
260
|
+
".gs-modal-foot{display:flex;gap:8px;justify-content:flex-end}",
|
|
261
|
+
".gs-build.gs-confirm{background:var(--dsw-alias-brand-primary,#e5484d);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
201
262
|
".gs-pop-reason{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
|
|
202
263
|
"opacity:.75;word-break:break-word;margin-top:4px}",
|
|
203
264
|
].join("");
|
|
@@ -230,7 +291,113 @@ window.__ModuleLoader__.load({
|
|
|
230
291
|
* under them as supporting detail.
|
|
231
292
|
*/
|
|
232
293
|
function StatusCard(props) {
|
|
233
|
-
const { status } = props;
|
|
294
|
+
const { status, runBuild, onSynced } = props;
|
|
295
|
+
const [building, setBuilding] = React.useState(false);
|
|
296
|
+
const [buildError, setBuildError] = React.useState(null);
|
|
297
|
+
// Whether the themed confirm modal is open (no index yet). Held here
|
|
298
|
+
// because only the no-graph path ever asks.
|
|
299
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
300
|
+
|
|
301
|
+
// The popup's Build button runs the same rebuild the automatic hooks
|
|
302
|
+
// run, for this session's repo — or the plain offline initialise when
|
|
303
|
+
// there is no index yet. It holds Building… until OBSERVED freshness
|
|
304
|
+
// flips to synced — not until the build call lands, which only means
|
|
305
|
+
// "started". A bare `graft build` was tried here first and shipped as
|
|
306
|
+
// a press that changed nothing: the child exited while stats.json still
|
|
307
|
+
// read dirty, the button re-enabled against a pill that still said
|
|
308
|
+
// STALE, and the pill only moved when some later rebuild rewrote stats.
|
|
309
|
+
// A timed-out wait re-enables with the last error rather than holding
|
|
310
|
+
// Building… forever.
|
|
311
|
+
const proceed = React.useCallback(() => {
|
|
312
|
+
if (building || typeof runBuild !== "function") return;
|
|
313
|
+
// Indexed: rebuild at once, no question. Unindexed: indexing a tree
|
|
314
|
+
// writes a graft/ directory into it, so the press asks first and a
|
|
315
|
+
// decline builds nothing. A blind press that silently indexed whatever
|
|
316
|
+
// happened to be open shipped once; this gate is the reason it will
|
|
317
|
+
// not ship again.
|
|
318
|
+
if (status !== null && status.ok === true) {
|
|
319
|
+
startBuild();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (Primitives !== null && typeof Primitives.Modal === "function") {
|
|
323
|
+
setConfirming(true);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Primitives unresolvable: the ugly box is better than no gate.
|
|
327
|
+
if (
|
|
328
|
+
window.confirm(
|
|
329
|
+
"Build a new graft index in this workspace?\n\n" +
|
|
330
|
+
String(status.from ?? status.root ?? "") +
|
|
331
|
+
"\n\nPlain structural build — offline, no API key needed.",
|
|
332
|
+
)
|
|
333
|
+
) {
|
|
334
|
+
startBuild();
|
|
335
|
+
}
|
|
336
|
+
}, [building, runBuild, status]);
|
|
337
|
+
const startBuild = React.useCallback(() => {
|
|
338
|
+
if (building || typeof runBuild !== "function") return;
|
|
339
|
+
setBuilding(true);
|
|
340
|
+
setBuildError(null);
|
|
341
|
+
setConfirming(false);
|
|
342
|
+
const finish = (ok, reason) => {
|
|
343
|
+
setBuilding(false);
|
|
344
|
+
if (!ok) setBuildError(String(reason || "unknown reason"));
|
|
345
|
+
};
|
|
346
|
+
Promise.resolve(runBuild()).then(
|
|
347
|
+
(answer) => {
|
|
348
|
+
let started;
|
|
349
|
+
try {
|
|
350
|
+
started = unwrap(answer);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
finish(false, String(error?.message ?? error));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!started || started.ok !== true) {
|
|
356
|
+
finish(false, String((started && started.reason) || "unknown reason"));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// A fresh initialise has no stats.json yet for a freshness poll to
|
|
360
|
+
// observe, so there is nothing to wait for — the next status poll
|
|
361
|
+
// picks the new graph up on its own.
|
|
362
|
+
if (started.initialising === true) {
|
|
363
|
+
finish(true);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (typeof onSynced === "function") {
|
|
367
|
+
Promise.resolve(onSynced()).then(
|
|
368
|
+
(synced) => finish(synced === true, synced === true ? null : "the rebuild did not report in sync — try again"),
|
|
369
|
+
(error) => finish(false, String(error?.message ?? error)),
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
finish(true);
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
(error) => {
|
|
376
|
+
finish(false, String(error?.message ?? error));
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
}, [building, runBuild, onSynced]);
|
|
380
|
+
|
|
381
|
+
// One pair, rendered in both pill-carrying heads: pill and button
|
|
382
|
+
// grouped at the header's right, travelling together.
|
|
383
|
+
const headGroup = (pill) =>
|
|
384
|
+
h(
|
|
385
|
+
"div",
|
|
386
|
+
{ className: "gs-pop-group" },
|
|
387
|
+
pill,
|
|
388
|
+
h(
|
|
389
|
+
"button",
|
|
390
|
+
{
|
|
391
|
+
type: "button",
|
|
392
|
+
className: "gs-build",
|
|
393
|
+
onClick: () => proceed(),
|
|
394
|
+
disabled: building,
|
|
395
|
+
},
|
|
396
|
+
building ? "Building…" : "Build",
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
const buildFailure =
|
|
400
|
+
buildError === null ? null : h("div", { className: "gs-pop-reason" }, buildError);
|
|
234
401
|
if (status === null || status === undefined) {
|
|
235
402
|
return h(
|
|
236
403
|
"div",
|
|
@@ -241,6 +408,54 @@ window.__ModuleLoader__.load({
|
|
|
241
408
|
}
|
|
242
409
|
|
|
243
410
|
if (status.ok !== true) {
|
|
411
|
+
// The themed confirm: dsh's own Modal, same chrome as every other
|
|
412
|
+
// dialog in the app. Rendered here (not in the head) because a modal
|
|
413
|
+
// portals to the body — it must not live inside the click-through
|
|
414
|
+
// card. Cancel/Escape/mask-click closes and builds nothing; the
|
|
415
|
+
// accept starts the initialise.
|
|
416
|
+
const confirmModal =
|
|
417
|
+
Primitives !== null && typeof Primitives.Modal === "function"
|
|
418
|
+
? h(
|
|
419
|
+
Primitives.Modal,
|
|
420
|
+
{
|
|
421
|
+
open: confirming,
|
|
422
|
+
onClose: () => {
|
|
423
|
+
if (!building) setConfirming(false);
|
|
424
|
+
},
|
|
425
|
+
title: "Build graft index?",
|
|
426
|
+
description:
|
|
427
|
+
"Index " +
|
|
428
|
+
basename(String(status.from ?? status.root ?? "")) +
|
|
429
|
+
" with a plain structural build — offline, no API key needed. Writes a graft/ directory there.",
|
|
430
|
+
closeLabel: "Close",
|
|
431
|
+
footer: h(
|
|
432
|
+
"div",
|
|
433
|
+
{ className: "gs-modal-foot" },
|
|
434
|
+
h(
|
|
435
|
+
"button",
|
|
436
|
+
{
|
|
437
|
+
type: "button",
|
|
438
|
+
className: "gs-build",
|
|
439
|
+
onClick: () => setConfirming(false),
|
|
440
|
+
disabled: building,
|
|
441
|
+
},
|
|
442
|
+
"Cancel",
|
|
443
|
+
),
|
|
444
|
+
h(
|
|
445
|
+
"button",
|
|
446
|
+
{
|
|
447
|
+
type: "button",
|
|
448
|
+
className: "gs-build gs-confirm",
|
|
449
|
+
onClick: () => startBuild(),
|
|
450
|
+
disabled: building,
|
|
451
|
+
"data-modal-autofocus": true,
|
|
452
|
+
},
|
|
453
|
+
building ? "Building…" : "Build index",
|
|
454
|
+
),
|
|
455
|
+
),
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
: null;
|
|
244
459
|
return h(
|
|
245
460
|
"div",
|
|
246
461
|
{ className: "gs-pop", role: "tooltip" },
|
|
@@ -248,11 +463,16 @@ window.__ModuleLoader__.load({
|
|
|
248
463
|
"div",
|
|
249
464
|
{ className: "gs-pop-head" },
|
|
250
465
|
h("span", { className: "gs-pop-title" }, "graft"),
|
|
251
|
-
h("span", { className: "gs-pill gs-p-idle" }, "no graph"),
|
|
466
|
+
headGroup(h("span", { className: "gs-pill gs-p-idle" }, "no graph")),
|
|
252
467
|
),
|
|
253
468
|
h("div", null, "No graft index for this workspace."),
|
|
254
469
|
h("div", { className: "gs-pop-reason" }, String(status.reason ?? "unknown reason")),
|
|
470
|
+
status.worktreeOf
|
|
471
|
+
? h("div", { className: "gs-pop-note" }, "git worktree of " + basename(status.worktreeOf) + " — /graft here seeds the first graph from it.")
|
|
472
|
+
: null,
|
|
255
473
|
h("div", { className: "gs-pop-note" }, "Run `graft build` in it once — no API key needed."),
|
|
474
|
+
buildFailure,
|
|
475
|
+
confirmModal,
|
|
256
476
|
);
|
|
257
477
|
}
|
|
258
478
|
|
|
@@ -267,10 +487,13 @@ window.__ModuleLoader__.load({
|
|
|
267
487
|
"div",
|
|
268
488
|
{ className: "gs-pop-head" },
|
|
269
489
|
h("span", { className: "gs-pop-title" }, "graft"),
|
|
270
|
-
h("span", { className: "gs-pill " + pill.className }, pill.label),
|
|
490
|
+
headGroup(h("span", { className: "gs-pill " + pill.className }, pill.label)),
|
|
271
491
|
),
|
|
272
492
|
h("div", { className: "gs-pop-repo" }, basename(status.root)),
|
|
273
493
|
h("div", { className: "gs-pop-path" }, status.root),
|
|
494
|
+
status.worktreeOf
|
|
495
|
+
? h("div", { className: "gs-pop-note" }, "git worktree of " + basename(status.worktreeOf))
|
|
496
|
+
: null,
|
|
274
497
|
h(
|
|
275
498
|
"div",
|
|
276
499
|
{ className: "gs-pop-stats" },
|
|
@@ -285,6 +508,7 @@ window.__ModuleLoader__.load({
|
|
|
285
508
|
// drift signal at all, so "in sync" is an absence of evidence.
|
|
286
509
|
? h("div", { className: "gs-pop-note" }, "Read from the graph itself; no live drift signal.")
|
|
287
510
|
: null,
|
|
511
|
+
buildFailure,
|
|
288
512
|
);
|
|
289
513
|
}
|
|
290
514
|
|
|
@@ -315,11 +539,16 @@ window.__ModuleLoader__.load({
|
|
|
315
539
|
* stares at a blank white tab and assumes it broke.
|
|
316
540
|
*/
|
|
317
541
|
function waitingDocument(repo) {
|
|
542
|
+
// A popped-out document cannot see the app's theme variables; the best
|
|
543
|
+
// it can do is follow the OS preference.
|
|
544
|
+
const dark = window.matchMedia !== undefined && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
545
|
+
const bg = dark ? "#161616" : "#f6f6f6";
|
|
546
|
+
const fg = dark ? "#ddd" : "#333";
|
|
318
547
|
return [
|
|
319
548
|
"<!doctype html><meta charset=utf-8>",
|
|
320
549
|
"<title>graft viz — ", repo, "</title>",
|
|
321
550
|
"<style>body{margin:0;display:grid;place-items:center;height:100vh;",
|
|
322
|
-
"font:14px system-ui,sans-serif;background
|
|
551
|
+
"font:14px system-ui,sans-serif;background:", bg, ";color:", fg, "}</style>",
|
|
323
552
|
"<div>Starting <b>graft viz</b> for ", repo, "…</div>",
|
|
324
553
|
].join("");
|
|
325
554
|
}
|
|
@@ -364,6 +593,15 @@ window.__ModuleLoader__.load({
|
|
|
364
593
|
|
|
365
594
|
const FRESH_MARK = { synced: "✓ synced", stale: "▲ stale", syncing: "◐ syncing", unknown: "○ unknown" };
|
|
366
595
|
|
|
596
|
+
/**
|
|
597
|
+
* How long the Build button waits for observed sync before giving up.
|
|
598
|
+
*
|
|
599
|
+
* Two minutes of 2s polls: a structural rebuild of a normal repo takes
|
|
600
|
+
* about a second, a large one can take minutes, and a wedged one must not
|
|
601
|
+
* hold Building… forever.
|
|
602
|
+
*/
|
|
603
|
+
const SYNC_WAIT_MS = 120000;
|
|
604
|
+
|
|
367
605
|
/** ⟳ — the one control adopted from the sidebar's own tab headers. */
|
|
368
606
|
function RefreshIcon() {
|
|
369
607
|
return h(
|
|
@@ -447,6 +685,7 @@ window.__ModuleLoader__.load({
|
|
|
447
685
|
const ok = status !== null && status.ok === true;
|
|
448
686
|
const freshness = ok ? status.freshness ?? "unknown" : "unknown";
|
|
449
687
|
const saved = ok ? tokensFull(status.savedTokens) : null;
|
|
688
|
+
const wt = status !== null && status.worktreeOf ? basename(status.worktreeOf) : null;
|
|
450
689
|
|
|
451
690
|
const bar = h(
|
|
452
691
|
"div",
|
|
@@ -462,8 +701,10 @@ window.__ModuleLoader__.load({
|
|
|
462
701
|
h("span", { className: "gs-tb-fresh " + (TONE[freshness] ?? TONE.unknown) }, FRESH_MARK[freshness] ?? FRESH_MARK.unknown),
|
|
463
702
|
saved === null ? null : h("span", { className: "gs-tb-sep" }, "·"),
|
|
464
703
|
saved === null ? null : h("span", { className: "gs-tb-saved" }, saved),
|
|
704
|
+
wt === null ? null : h("span", { className: "gs-tb-sep" }, "·"),
|
|
705
|
+
wt === null ? null : h("span", { className: "gs-tb-dim" }, "worktree of " + wt),
|
|
465
706
|
)
|
|
466
|
-
: h("span", { className: "gs-tb-dim" }, status === null ? "…" : "no graph"),
|
|
707
|
+
: h("span", { className: "gs-tb-dim" }, status === null ? "…" : wt === null ? "no graph" : "no graph — worktree of " + wt),
|
|
467
708
|
h(
|
|
468
709
|
"button",
|
|
469
710
|
{
|
|
@@ -506,6 +747,21 @@ window.__ModuleLoader__.load({
|
|
|
506
747
|
const dwell = React.useRef(null);
|
|
507
748
|
const wrap = React.useRef(null);
|
|
508
749
|
|
|
750
|
+
// Whether the dot should be blinking violet right now: a graft tool call
|
|
751
|
+
// was seen and its blink window has not run out yet.
|
|
752
|
+
const [flick, setFlick] = React.useState(false);
|
|
753
|
+
// Whether a graft tool was seen recently. Outlives the blink on
|
|
754
|
+
// purpose: it holds the fast poll for a tail after the last pulse, so
|
|
755
|
+
// the NEXT call in an active burst is caught within one fast tick
|
|
756
|
+
// instead of waiting out the slow settled cadence — exactly the lag a
|
|
757
|
+
// second tool call used to show.
|
|
758
|
+
const [recent, setRecent] = React.useState(false);
|
|
759
|
+
const recentTimer = React.useRef(null);
|
|
760
|
+
// The last pulse consumed, as { root, count }: the blink fires when the
|
|
761
|
+
// count MOVES for the repo this chip describes, never on first sight.
|
|
762
|
+
const pulseSeen = React.useRef(null);
|
|
763
|
+
const flickTimers = React.useRef([]);
|
|
764
|
+
|
|
509
765
|
// Whether the graph is at rest. Drives the poll cadence below.
|
|
510
766
|
const settled = status !== null && status.ok === true && status.freshness === "synced";
|
|
511
767
|
|
|
@@ -546,7 +802,14 @@ window.__ModuleLoader__.load({
|
|
|
546
802
|
const onDocument = (event) => {
|
|
547
803
|
// `contains` rather than a target comparison, so a click on the
|
|
548
804
|
// card's own text — or on the chip, which toggles — is not treated
|
|
549
|
-
// as "outside" and closed twice.
|
|
805
|
+
// as "outside" and closed twice. Clicks inside the confirm modal
|
|
806
|
+
// are ALSO inside: it portals to document.body, outside the wrap,
|
|
807
|
+
// so without this guard the capture-phase closer unmounts the card
|
|
808
|
+
// (modal included) before the accept's own click ever dispatches —
|
|
809
|
+
// an accept that hides the dialog and builds nothing.
|
|
810
|
+
const inModal =
|
|
811
|
+
event.target instanceof Element && event.target.closest('[role="dialog"], .gs-modal-foot') !== null;
|
|
812
|
+
if (inModal) return;
|
|
550
813
|
if (wrap.current !== null && wrap.current.contains(event.target)) return;
|
|
551
814
|
setCard(false);
|
|
552
815
|
};
|
|
@@ -559,6 +822,61 @@ window.__ModuleLoader__.load({
|
|
|
559
822
|
// component; one that fires after the session switched would show the
|
|
560
823
|
// previous workspace's card.
|
|
561
824
|
React.useEffect(() => () => window.clearTimeout(dwell.current), []);
|
|
825
|
+
// The blink must not cross a session boundary — this component stays
|
|
826
|
+
// mounted across one — and its timers must not outlive it. Effect
|
|
827
|
+
// cleanup runs for both, so this one block is the whole hygiene.
|
|
828
|
+
React.useEffect(
|
|
829
|
+
() => () => {
|
|
830
|
+
for (const t of flickTimers.current) window.clearTimeout(t);
|
|
831
|
+
flickTimers.current = [];
|
|
832
|
+
window.clearTimeout(recentTimer.current);
|
|
833
|
+
pulseSeen.current = null;
|
|
834
|
+
setFlick(false);
|
|
835
|
+
setRecent(false);
|
|
836
|
+
},
|
|
837
|
+
[sessionId],
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
// Blink the dot violet on and off — intermittent, never solid — for just
|
|
841
|
+
// under two seconds, toggling every 175ms. The mark list is EVEN on
|
|
842
|
+
// purpose: the final toggle is always "off", so the dot returns to its
|
|
843
|
+
// resting colour instead of sticking on violet. A pulse arriving
|
|
844
|
+
// mid-blink restarts the run, so a burst of graft calls reads as one
|
|
845
|
+
// flicker that ends when they do.
|
|
846
|
+
const blink = React.useCallback(() => {
|
|
847
|
+
for (const t of flickTimers.current) window.clearTimeout(t);
|
|
848
|
+
flickTimers.current = [];
|
|
849
|
+
for (const ms of [0, 175, 350, 525, 700, 875, 1050, 1225, 1400, 1575, 1750, 1925]) {
|
|
850
|
+
const on = flickTimers.current.length % 2 === 0;
|
|
851
|
+
flickTimers.current.push(window.setTimeout(() => setFlick(on), ms));
|
|
852
|
+
}
|
|
853
|
+
}, []);
|
|
854
|
+
|
|
855
|
+
// Consume one polled status: blink when its tool-use count has moved
|
|
856
|
+
// for THIS repo. A first look — or the first look after the chip
|
|
857
|
+
// switched repos — only sets the baseline, so history never blinks.
|
|
858
|
+
const pulseCheck = React.useCallback(
|
|
859
|
+
(next) => {
|
|
860
|
+
const count = next?.toolUse?.count;
|
|
861
|
+
if (typeof count !== "number") return;
|
|
862
|
+
const seen = pulseSeen.current;
|
|
863
|
+
if (seen === null || seen.root !== next.root) {
|
|
864
|
+
pulseSeen.current = { root: next.root, count };
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (count > seen.count) {
|
|
868
|
+
pulseSeen.current = { root: next.root, count };
|
|
869
|
+
blink();
|
|
870
|
+
// Hold the fast poll for a tail after the last use, so the next
|
|
871
|
+
// call in a burst is seen within one tick of the harness's own
|
|
872
|
+
// tool feedback.
|
|
873
|
+
setRecent(true);
|
|
874
|
+
window.clearTimeout(recentTimer.current);
|
|
875
|
+
recentTimer.current = window.setTimeout(() => setRecent(false), 20000);
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
[blink],
|
|
879
|
+
);
|
|
562
880
|
|
|
563
881
|
React.useEffect(() => {
|
|
564
882
|
let live = true;
|
|
@@ -570,7 +888,10 @@ window.__ModuleLoader__.load({
|
|
|
570
888
|
}
|
|
571
889
|
Promise.resolve(service.status(sessionId)).then(
|
|
572
890
|
(answer) => {
|
|
573
|
-
if (live)
|
|
891
|
+
if (!live) return;
|
|
892
|
+
const next = unwrap(answer);
|
|
893
|
+
setStatus(next);
|
|
894
|
+
pulseCheck(next);
|
|
574
895
|
},
|
|
575
896
|
(error) => {
|
|
576
897
|
if (live) setStatus({ ok: false, reason: String(error?.message ?? error) });
|
|
@@ -584,8 +905,13 @@ window.__ModuleLoader__.load({
|
|
|
584
905
|
// near zero and the chip appeared to jump straight from stale back
|
|
585
906
|
// to green. While the graph is MOVING — stale, mid-sync, or not yet
|
|
586
907
|
// known — it is watched closely; once it is in sync there is nothing
|
|
587
|
-
// to watch
|
|
588
|
-
|
|
908
|
+
// to watch. Even at rest it still polls at 4s rather than the old
|
|
909
|
+
// 15s, because graft tool feedback must keep up with the harness's
|
|
910
|
+
// own tool feedback — a settled chip that only looked twice a minute
|
|
911
|
+
// was the reported lag on every call after the first. The server
|
|
912
|
+
// caches status for 5s, so 4s adds no real reads; a pulse then holds
|
|
913
|
+
// the 2s cadence for a 20s tail.
|
|
914
|
+
const timer = window.setInterval(pull, settled && !recent ? 4000 : 2000);
|
|
589
915
|
return () => {
|
|
590
916
|
live = false;
|
|
591
917
|
window.clearInterval(timer);
|
|
@@ -593,7 +919,50 @@ window.__ModuleLoader__.load({
|
|
|
593
919
|
// `settled` is a dependency, not just a value read inside: without it
|
|
594
920
|
// the interval would keep whatever cadence it had at mount — 2s, from
|
|
595
921
|
// the initial unknown state — and never slow down again.
|
|
596
|
-
}, [sessionId, settled]);
|
|
922
|
+
}, [sessionId, settled, recent, pulseCheck]);
|
|
923
|
+
|
|
924
|
+
// The build the popup button triggers. Resolved here, in a callback
|
|
925
|
+
// like every other remote access, rather than in render.
|
|
926
|
+
const runBuild = React.useCallback(() => {
|
|
927
|
+
const service = ctx.get("remote.graftStatus");
|
|
928
|
+
if (service === undefined || service === null) {
|
|
929
|
+
return Promise.reject(new Error("graft-status host half is not mounted"));
|
|
930
|
+
}
|
|
931
|
+
return service.build(sessionId);
|
|
932
|
+
}, [sessionId]);
|
|
933
|
+
|
|
934
|
+
// Re-read status on demand — after the popup's Build lands — rather
|
|
935
|
+
// than waiting out the poll. Returns the observed freshness, so a
|
|
936
|
+
// waiter can hold until the rebuild is VISIBLE, not just started.
|
|
937
|
+
const repull = React.useCallback(() => {
|
|
938
|
+
const service = ctx.get("remote.graftStatus");
|
|
939
|
+
if (service === undefined || service === null) return Promise.resolve(null);
|
|
940
|
+
return Promise.resolve(service.status(sessionId)).then(
|
|
941
|
+
(answer) => {
|
|
942
|
+
const next = unwrap(answer);
|
|
943
|
+
setStatus(next);
|
|
944
|
+
pulseCheck(next);
|
|
945
|
+
return next !== null && next.ok === true ? next.freshness ?? "unknown" : null;
|
|
946
|
+
},
|
|
947
|
+
() => null,
|
|
948
|
+
);
|
|
949
|
+
}, [sessionId, pulseCheck]);
|
|
950
|
+
|
|
951
|
+
// Hold until a re-read OBSERVES `synced`, not until the build call
|
|
952
|
+
// lands: the rebuild runs detached, so "started" says nothing about
|
|
953
|
+
// what stats.json reads yet. Bounded — two minutes of 2s polls — so a
|
|
954
|
+
// wedged rebuild re-enables the button with the last error rather
|
|
955
|
+
// than holding Building… forever.
|
|
956
|
+
const waitForSynced = React.useCallback(() => {
|
|
957
|
+
const started = Date.now();
|
|
958
|
+
const look = () =>
|
|
959
|
+
Promise.resolve(repull()).then((freshness) => {
|
|
960
|
+
if (freshness === "synced") return true;
|
|
961
|
+
if (Date.now() - started > SYNC_WAIT_MS) return false;
|
|
962
|
+
return new Promise((resolve) => window.setTimeout(() => resolve(look().then(resolve)), 2000));
|
|
963
|
+
});
|
|
964
|
+
return look();
|
|
965
|
+
}, [repull]);
|
|
597
966
|
|
|
598
967
|
const openViz = React.useCallback(() => {
|
|
599
968
|
const repo = basename((status && status.root) || "");
|
|
@@ -750,11 +1119,21 @@ window.__ModuleLoader__.load({
|
|
|
750
1119
|
return h(
|
|
751
1120
|
"div",
|
|
752
1121
|
{ className: "gs-wrap", ref: wrap },
|
|
753
|
-
card
|
|
1122
|
+
card
|
|
1123
|
+
? h(StatusCard, {
|
|
1124
|
+
status,
|
|
1125
|
+
runBuild,
|
|
1126
|
+
// The button holds Building… until this OBSERVES synced, so
|
|
1127
|
+
// the pill flips first and the button follows — never the
|
|
1128
|
+
// other way round.
|
|
1129
|
+
onSynced: waitForSynced,
|
|
1130
|
+
})
|
|
1131
|
+
: null,
|
|
754
1132
|
h(
|
|
755
1133
|
"div",
|
|
756
1134
|
{
|
|
757
|
-
className:
|
|
1135
|
+
className:
|
|
1136
|
+
"gs-chip gs-clickable " + tone + (ok ? "" : " gs-muted") + (flick ? " gs-live" : ""),
|
|
758
1137
|
onMouseEnter: enter,
|
|
759
1138
|
onMouseLeave: leave,
|
|
760
1139
|
onClick: toggleCard,
|
|
@@ -778,7 +1157,7 @@ window.__ModuleLoader__.load({
|
|
|
778
1157
|
h("span", { className: "gs-dot", "aria-hidden": "true" }, glyph),
|
|
779
1158
|
h("span", { className: "gs-name" }, "graft"),
|
|
780
1159
|
h("span", { className: "gs-sep" }, "·"),
|
|
781
|
-
h("span",
|
|
1160
|
+
h("span", { className: "gs-label" }, label),
|
|
782
1161
|
ok && freshness === "stale" ? h("span", { className: "gs-sep" }, "·") : null,
|
|
783
1162
|
ok && freshness === "stale" ? h("span", null, "stale") : null,
|
|
784
1163
|
),
|