dsh-graft-plugin 0.1.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 -0
- package/README.md +129 -0
- package/cordis.patch.yml +24 -0
- package/package.json +79 -0
- package/screenshots.json +5 -0
- package/scripts/postinstall.mjs +109 -0
- package/scripts/test-autosync.mjs +117 -0
- package/scripts/test-command.mjs +104 -0
- package/scripts/test-harness.mjs +240 -0
- package/scripts/test-tools.mjs +53 -0
- package/src/auto-sync.js +262 -0
- package/src/client.js +843 -0
- package/src/command.js +147 -0
- package/src/graft-tools.js +346 -0
- package/src/index.js +701 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "graft-status",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
|
|
8
|
+
const React = require("react");
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
|
|
11
|
+
/* ================================================================== *
|
|
12
|
+
* graft-status — the browser half.
|
|
13
|
+
*
|
|
14
|
+
* Two things in the composer's trailing row, beside the model name: a
|
|
15
|
+
* chip naming the repo whose graph this session is using and whether it
|
|
16
|
+
* still matches the code, and a button that opens `graft viz`.
|
|
17
|
+
*
|
|
18
|
+
* WHERE THE VISUALISER OPENS
|
|
19
|
+
*
|
|
20
|
+
* As a tab in dsh-better-sidebar, through its public `ctx.betterSidebar`
|
|
21
|
+
* service — `registerTab` to contribute the tab type and `openTab` to bring
|
|
22
|
+
* it up. Nothing in that plugin is modified; the README states third-party
|
|
23
|
+
* tabs and its own built-in eight go through the same API.
|
|
24
|
+
*
|
|
25
|
+
* Two earlier attempts were worse. A drawer of this plugin's own put a
|
|
26
|
+
* second sidebar beside the one dsh already has. A real browser tab threw
|
|
27
|
+
* the graph out of the workspace entirely. The service was there the whole
|
|
28
|
+
* time; the client SLOT catalogue simply does not describe it, which is why
|
|
29
|
+
* the first search for an extension point came up empty.
|
|
30
|
+
*
|
|
31
|
+
* The browser-tab path survives only as the fallback for a profile without
|
|
32
|
+
* dsh-better-sidebar installed, where `ctx.get('betterSidebar')` is
|
|
33
|
+
* undefined.
|
|
34
|
+
*
|
|
35
|
+
* WHY THE TRAILING ROW AND NOT THE DOCK
|
|
36
|
+
*
|
|
37
|
+
* `conversation.input.dock` renders in the zone ABOVE the composer — a
|
|
38
|
+
* first version put the chip there and it landed adrift at the far left,
|
|
39
|
+
* nowhere near the model. `conversation.input.right` is a list slot
|
|
40
|
+
* rendered inside the trailing div immediately before
|
|
41
|
+
* `conversation.input.model`, which is literally "next to the model name".
|
|
42
|
+
*
|
|
43
|
+
* WHY THE REPO NAME IS THE HEADLINE
|
|
44
|
+
*
|
|
45
|
+
* Counts are reassuring and almost never actionable; the wrong repo is
|
|
46
|
+
* actionable and looks exactly like the right one. So the basename leads,
|
|
47
|
+
* the freshness word follows, and the counts live in the hover card.
|
|
48
|
+
* ================================================================== */
|
|
49
|
+
|
|
50
|
+
function identity(value) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
const CODEC = { mode: "strict", typeSymbol: "graft-status/json", schema: { parse: identity } };
|
|
54
|
+
const descriptor = (method, names) => ({
|
|
55
|
+
id: "graft-status#graftStatus/" + method,
|
|
56
|
+
service: "graftStatus",
|
|
57
|
+
namespace: "graftStatus",
|
|
58
|
+
method,
|
|
59
|
+
invocation: { kind: "direct" },
|
|
60
|
+
parameters: names.map((name) => ({ name, wire: name, source: "json", codec: CODEC })),
|
|
61
|
+
result: { mode: "strict", typeSymbol: "graft-status/json", schema: { parse: identity } },
|
|
62
|
+
sourceLocation: { file: "graft-status/client.js", line: 1, column: 1 },
|
|
63
|
+
});
|
|
64
|
+
const CONTRIBUTION = {
|
|
65
|
+
package: "graft-status",
|
|
66
|
+
descriptors: [descriptor("status", ["sessionId"]), descriptor("viz", ["sessionId"])],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Unwrap one gateway response.
|
|
71
|
+
*
|
|
72
|
+
* The envelope is `{ ok, value }` / `{ ok: false, error }`. Reading a field
|
|
73
|
+
* off the envelope instead yields `undefined` with no rejection — which is
|
|
74
|
+
* exactly how the first version of this chip rendered as an empty "graft ·"
|
|
75
|
+
* with no repo name: it was displaying the envelope, whose `ok: true` it
|
|
76
|
+
* mistook for the payload's. Unwrapped once here so that cannot recur.
|
|
77
|
+
*/
|
|
78
|
+
function unwrap(answer) {
|
|
79
|
+
if (answer && answer.ok === false && answer.error) {
|
|
80
|
+
const error = answer.error || {};
|
|
81
|
+
throw new Error((error.code ? error.code + ": " : "") + (error.message || "the host refused the call"));
|
|
82
|
+
}
|
|
83
|
+
if (answer && Object.prototype.hasOwnProperty.call(answer, "value")) return answer.value;
|
|
84
|
+
return answer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ================================================================== *
|
|
88
|
+
* Presentation
|
|
89
|
+
* ================================================================== */
|
|
90
|
+
|
|
91
|
+
/** 1106 -> "1,106"; the counts are read, not compared, so grouping wins. */
|
|
92
|
+
function count(value) {
|
|
93
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
94
|
+
return value.toLocaleString("en-US");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 150427 -> "~150k". Abbreviated because an exact token estimate implies a
|
|
99
|
+
* precision it does not have.
|
|
100
|
+
*/
|
|
101
|
+
function tokens(value) {
|
|
102
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
103
|
+
if (value < 1000) return "~" + value;
|
|
104
|
+
if (value < 1000000) return "~" + Math.round(value / 100) / 10 + "k";
|
|
105
|
+
return "~" + Math.round(value / 100000) / 10 + "M";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The last path segment, which is what a person calls the repo. */
|
|
109
|
+
function basename(path) {
|
|
110
|
+
if (typeof path !== "string" || path.length === 0) return "";
|
|
111
|
+
const parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
112
|
+
return parts[parts.length - 1] || path;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TONE = { synced: "gs-ok", stale: "gs-warn", syncing: "gs-busy", unknown: "gs-idle" };
|
|
116
|
+
/** Shape as well as colour, never colour alone. */
|
|
117
|
+
const GLYPH = { synced: "●", stale: "▲", syncing: "◐", unknown: "○" };
|
|
118
|
+
|
|
119
|
+
function styles() {
|
|
120
|
+
return [
|
|
121
|
+
".gs-wrap{display:inline-flex;align-items:center;gap:4px}",
|
|
122
|
+
".gs-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;",
|
|
123
|
+
"border-radius:13px;border:1px solid var(--dsh-border,rgba(128,128,128,.28));",
|
|
124
|
+
"background:var(--dsh-surface-2,rgba(128,128,128,.08));color:var(--dsh-text-2,inherit);",
|
|
125
|
+
"font-size:12px;line-height:1;white-space:nowrap;cursor:default;max-width:200px}",
|
|
126
|
+
".gs-chip .gs-name{font-weight:600;overflow:hidden;text-overflow:ellipsis}",
|
|
127
|
+
".gs-chip .gs-sep{opacity:.45}",
|
|
128
|
+
".gs-chip .gs-dot{font-size:9px;line-height:1}",
|
|
129
|
+
".gs-ok .gs-dot{color:#3fa45b}",
|
|
130
|
+
".gs-warn .gs-dot{color:#d08442}",
|
|
131
|
+
".gs-busy .gs-dot{color:#4b8bd6;display:inline-block;animation:gs-spin 900ms linear infinite}",
|
|
132
|
+
"@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
|
+
".gs-chip.gs-busy{animation:gs-breathe 1.4s ease-in-out infinite}",
|
|
136
|
+
"@keyframes gs-breathe{0%,100%{opacity:1}50%{opacity:.62}}",
|
|
137
|
+
"@media (prefers-reduced-motion:reduce){.gs-busy .gs-dot{animation:none}.gs-chip.gs-busy{animation:none}}",
|
|
138
|
+
".gs-idle .gs-dot{color:var(--dsh-text-3,#888)}",
|
|
139
|
+
".gs-chip.gs-muted{opacity:.6}",
|
|
140
|
+
".gs-chip.gs-clickable{cursor:pointer}",
|
|
141
|
+
".gs-chip.gs-clickable:hover{border-color:var(--dsh-border-strong,rgba(128,128,128,.5))}",
|
|
142
|
+
".gs-chip.gs-clickable:focus-visible{outline:2px solid var(--dsh-accent,#4b8bd6);outline-offset:2px}",
|
|
143
|
+
".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(--dsh-text-2,inherit);",
|
|
145
|
+
"cursor:pointer;--accent:var(--dsh-accent,#e5484d)}",
|
|
146
|
+
".gs-viz:hover{background:var(--dsh-surface-2,rgba(128,128,128,.14))}",
|
|
147
|
+
".gs-viz:disabled{opacity:.5;cursor:progress}",
|
|
148
|
+
".gs-viz.gs-err{color:var(--dsh-danger,#e5484d)}",
|
|
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. */
|
|
151
|
+
".gs-tabwrap{display:flex;flex-direction:column;height:100%;min-height:0}",
|
|
152
|
+
".gs-tabbar{display:flex;align-items:center;gap:6px;padding:0 6px 0 10px;min-height:32px;",
|
|
153
|
+
"border-bottom:1px solid var(--dsh-border,rgba(128,128,128,.22));font-size:11.5px;",
|
|
154
|
+
"white-space:nowrap;overflow:hidden;flex:0 0 auto}",
|
|
155
|
+
".gs-tb-brand{color:var(--dsh-accent,#e5484d);font-weight:700}",
|
|
156
|
+
".gs-tb-sep{opacity:.38}",
|
|
157
|
+
".gs-tb-dim{opacity:.72;overflow:hidden;text-overflow:ellipsis}",
|
|
158
|
+
".gs-tb-fresh{font-weight:600}",
|
|
159
|
+
".gs-tb-fresh.gs-ok{color:#5cbe78}",
|
|
160
|
+
".gs-tb-fresh.gs-warn{color:#e0a066}",
|
|
161
|
+
".gs-tb-fresh.gs-busy{color:#79aee8}",
|
|
162
|
+
".gs-tb-fresh.gs-idle{color:var(--dsh-text-3,#9a9a9a)}",
|
|
163
|
+
".gs-tb-saved{color:#79aee8}",
|
|
164
|
+
".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(--dsh-text-2,inherit);cursor:pointer;opacity:.75}",
|
|
167
|
+
".gs-refresh:hover{background:var(--dsh-surface-2,rgba(128,128,128,.16));opacity:1}",
|
|
168
|
+
".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:#fff;display:block}",
|
|
170
|
+
".gs-tabmsg{padding:14px;font-size:12px;line-height:1.6;opacity:.85}",
|
|
171
|
+
".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
|
+
".gs-wrap{position:relative}",
|
|
176
|
+
".gs-pop{position:absolute;bottom:calc(100% + 10px);right:0;z-index:80;width:296px;",
|
|
177
|
+
"padding:12px 13px;border-radius:12px;pointer-events:none;text-align:left;",
|
|
178
|
+
"border:1px solid var(--dsh-border,rgba(128,128,128,.3));",
|
|
179
|
+
"background:var(--dsh-surface,#1b1b1e);color:var(--dsh-text,inherit);",
|
|
180
|
+
"box-shadow:0 10px 30px rgba(0,0,0,.34);font-size:12px;line-height:1.5;",
|
|
181
|
+
"opacity:0;transform:translateY(4px);animation:gs-pop-in .13s ease-out forwards}",
|
|
182
|
+
"@keyframes gs-pop-in{to{opacity:1;transform:none}}",
|
|
183
|
+
".gs-pop-head{display:flex;align-items:center;gap:8px;margin-bottom:9px}",
|
|
184
|
+
".gs-pop-head .gs-pop-title{font-weight:700;letter-spacing:.02em}",
|
|
185
|
+
".gs-pill{margin-left:auto;font-size:9.5px;font-weight:700;text-transform:uppercase;",
|
|
186
|
+
"letter-spacing:.07em;padding:3px 8px;border-radius:999px;white-space:nowrap}",
|
|
187
|
+
".gs-pill.gs-p-ok{background:rgba(63,164,91,.18);color:#5cbe78}",
|
|
188
|
+
".gs-pill.gs-p-warn{background:rgba(208,132,66,.2);color:#e0a066}",
|
|
189
|
+
".gs-pill.gs-p-busy{background:rgba(75,139,214,.2);color:#79aee8}",
|
|
190
|
+
".gs-pill.gs-p-idle{background:rgba(128,128,128,.2);color:var(--dsh-text-3,#9a9a9a)}",
|
|
191
|
+
".gs-pop-repo{font-weight:600;font-size:12.5px;margin-bottom:2px}",
|
|
192
|
+
".gs-pop-path{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
|
|
193
|
+
"opacity:.58;word-break:break-all}",
|
|
194
|
+
".gs-pop-stats{display:flex;gap:18px;margin:11px 0 0;padding:10px 0 2px;",
|
|
195
|
+
"border-top:1px solid var(--dsh-border,rgba(128,128,128,.2))}",
|
|
196
|
+
".gs-stat b{display:block;font-size:15px;font-weight:700;line-height:1.2;font-variant-numeric:tabular-nums}",
|
|
197
|
+
".gs-stat i{display:block;font-style:normal;font-size:9.5px;text-transform:uppercase;",
|
|
198
|
+
"letter-spacing:.07em;opacity:.55;margin-top:1px}",
|
|
199
|
+
".gs-pop-note{margin-top:10px;padding-top:9px;font-size:11px;opacity:.68;",
|
|
200
|
+
"border-top:1px solid var(--dsh-border,rgba(128,128,128,.2))}",
|
|
201
|
+
".gs-pop-reason{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
|
|
202
|
+
"opacity:.75;word-break:break-word;margin-top:4px}",
|
|
203
|
+
].join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function injectStyles() {
|
|
207
|
+
const id = "graft-status-styles";
|
|
208
|
+
if (document.getElementById(id) !== null) return;
|
|
209
|
+
const tag = document.createElement("style");
|
|
210
|
+
tag.id = id;
|
|
211
|
+
tag.textContent = styles();
|
|
212
|
+
document.head.appendChild(tag);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The freshness word as a short, colour-coded pill. */
|
|
216
|
+
const PILL = {
|
|
217
|
+
synced: { className: "gs-p-ok", label: "in sync" },
|
|
218
|
+
stale: { className: "gs-p-warn", label: "stale" },
|
|
219
|
+
syncing: { className: "gs-p-busy", label: "syncing" },
|
|
220
|
+
unknown: { className: "gs-p-idle", label: "unknown" },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The hover card.
|
|
225
|
+
*
|
|
226
|
+
* Replaces a `title` attribute, which the browser rendered as a wall of
|
|
227
|
+
* plain grey text with the repo path, the counts and a caveat all weighted
|
|
228
|
+
* identically. Here the two things that decide something — WHICH repo, and
|
|
229
|
+
* whether it is current — lead and are colour-coded, and the numbers sit
|
|
230
|
+
* under them as supporting detail.
|
|
231
|
+
*/
|
|
232
|
+
function StatusCard(props) {
|
|
233
|
+
const { status } = props;
|
|
234
|
+
if (status === null || status === undefined) {
|
|
235
|
+
return h(
|
|
236
|
+
"div",
|
|
237
|
+
{ className: "gs-pop", role: "tooltip" },
|
|
238
|
+
h("div", { className: "gs-pop-head" }, h("span", { className: "gs-pop-title" }, "graft")),
|
|
239
|
+
h("div", { className: "gs-pop-note" }, "Checking this workspace…"),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (status.ok !== true) {
|
|
244
|
+
return h(
|
|
245
|
+
"div",
|
|
246
|
+
{ className: "gs-pop", role: "tooltip" },
|
|
247
|
+
h(
|
|
248
|
+
"div",
|
|
249
|
+
{ className: "gs-pop-head" },
|
|
250
|
+
h("span", { className: "gs-pop-title" }, "graft"),
|
|
251
|
+
h("span", { className: "gs-pill gs-p-idle" }, "no graph"),
|
|
252
|
+
),
|
|
253
|
+
h("div", null, "No graft index for this workspace."),
|
|
254
|
+
h("div", { className: "gs-pop-reason" }, String(status.reason ?? "unknown reason")),
|
|
255
|
+
h("div", { className: "gs-pop-note" }, "Run `graft build` in it once — no API key needed."),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const freshness = status.freshness ?? "unknown";
|
|
260
|
+
const pill = PILL[freshness] ?? PILL.unknown;
|
|
261
|
+
const saved = tokens(status.savedTokens);
|
|
262
|
+
|
|
263
|
+
return h(
|
|
264
|
+
"div",
|
|
265
|
+
{ className: "gs-pop", role: "tooltip" },
|
|
266
|
+
h(
|
|
267
|
+
"div",
|
|
268
|
+
{ className: "gs-pop-head" },
|
|
269
|
+
h("span", { className: "gs-pop-title" }, "graft"),
|
|
270
|
+
h("span", { className: "gs-pill " + pill.className }, pill.label),
|
|
271
|
+
),
|
|
272
|
+
h("div", { className: "gs-pop-repo" }, basename(status.root)),
|
|
273
|
+
h("div", { className: "gs-pop-path" }, status.root),
|
|
274
|
+
h(
|
|
275
|
+
"div",
|
|
276
|
+
{ className: "gs-pop-stats" },
|
|
277
|
+
h("div", { className: "gs-stat" }, h("b", null, count(status.nodeCount)), h("i", null, "nodes")),
|
|
278
|
+
h("div", { className: "gs-stat" }, h("b", null, count(status.edgeCount)), h("i", null, "edges")),
|
|
279
|
+
saved === null ? null : h("div", { className: "gs-stat" }, h("b", null, saved), h("i", null, "tokens saved")),
|
|
280
|
+
),
|
|
281
|
+
freshness === "stale"
|
|
282
|
+
? h("div", { className: "gs-pop-note" }, "The code changed since the last sync — run `graft build` to catch the graph up.")
|
|
283
|
+
: status.source === "graph"
|
|
284
|
+
// Said plainly rather than hidden: without the cache there is no
|
|
285
|
+
// drift signal at all, so "in sync" is an absence of evidence.
|
|
286
|
+
? h("div", { className: "gs-pop-note" }, "Read from the graph itself; no live drift signal.")
|
|
287
|
+
: null,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The graph mark, exactly as supplied. `--accent` is set by `.gs-viz`. */
|
|
292
|
+
function GraphIcon() {
|
|
293
|
+
return h(
|
|
294
|
+
"svg",
|
|
295
|
+
{ width: 19, height: 19, viewBox: "0 0 24 24", "aria-hidden": "true" },
|
|
296
|
+
h(
|
|
297
|
+
"g",
|
|
298
|
+
{ fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" },
|
|
299
|
+
h("path", { d: "M12 21 V12" }),
|
|
300
|
+
h("path", { d: "M12 12 L6.8 6.6" }),
|
|
301
|
+
h("path", { stroke: "var(--accent)", d: "M12 12 L17.4 7.6" }),
|
|
302
|
+
),
|
|
303
|
+
h("circle", { cx: 12, cy: 12, r: 2.1, fill: "currentColor" }),
|
|
304
|
+
h("circle", { cx: 6.8, cy: 6.6, r: 1.7, fill: "currentColor" }),
|
|
305
|
+
h("circle", { cx: 17.4, cy: 7.6, r: 1.7, fill: "var(--accent)" }),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The placeholder the new tab shows while `graft viz` boots.
|
|
311
|
+
*
|
|
312
|
+
* The tab has to be opened synchronously inside the click handler or the
|
|
313
|
+
* popup blocker eats it, but the URL is not known for up to ten seconds
|
|
314
|
+
* (the server has to come up and start accepting). Without this the user
|
|
315
|
+
* stares at a blank white tab and assumes it broke.
|
|
316
|
+
*/
|
|
317
|
+
function waitingDocument(repo) {
|
|
318
|
+
return [
|
|
319
|
+
"<!doctype html><meta charset=utf-8>",
|
|
320
|
+
"<title>graft viz — ", repo, "</title>",
|
|
321
|
+
"<style>body{margin:0;display:grid;place-items:center;height:100vh;",
|
|
322
|
+
"font:14px system-ui,sans-serif;background:#161616;color:#ddd}</style>",
|
|
323
|
+
"<div>Starting <b>graft viz</b> for ", repo, "…</div>",
|
|
324
|
+
].join("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The tab type contributed to dsh-better-sidebar. */
|
|
328
|
+
const TAB_TYPE = "graft-status:viz";
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The sidebar tab: `graft viz` in an iframe.
|
|
332
|
+
*
|
|
333
|
+
* `visible` is false while the tab exists but is not the focused one, and
|
|
334
|
+
* the host's own note says live views should pause then. So the server is
|
|
335
|
+
* not started until the tab is actually looked at — opening it from the +
|
|
336
|
+
* menu and never selecting it should not spawn a process.
|
|
337
|
+
*/
|
|
338
|
+
/** Counts as graft's own statusline prints them: no digit grouping. */
|
|
339
|
+
const plain = (value) => (typeof value === "number" && Number.isFinite(value) ? String(value) : "—");
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The savings figure in full, grouped for the reader's own locale.
|
|
343
|
+
*
|
|
344
|
+
* The chip abbreviates this to `~664k` because it is one line among four in
|
|
345
|
+
* a small card. Here it has a bar to itself, so it is shown the way graft's
|
|
346
|
+
* own statusline shows it — and with no locale argument, so a reader whose
|
|
347
|
+
* system groups as `6,63,783` sees exactly that rather than a US regrouping
|
|
348
|
+
* of their own number.
|
|
349
|
+
*/
|
|
350
|
+
function tokensFull(value) {
|
|
351
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
352
|
+
return "~" + Math.round(value).toLocaleString() + " tok saved";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const FRESH_MARK = { synced: "✓ synced", stale: "▲ stale", syncing: "◐ syncing", unknown: "○ unknown" };
|
|
356
|
+
|
|
357
|
+
/** ⟳ — the one control adopted from the sidebar's own tab headers. */
|
|
358
|
+
function RefreshIcon() {
|
|
359
|
+
return h(
|
|
360
|
+
"svg",
|
|
361
|
+
{ width: 14, height: 14, viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
362
|
+
h("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }),
|
|
363
|
+
h("path", { d: "M21 3v6h-6" }),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function makeVizTab(ctx) {
|
|
368
|
+
return function GraftVizTab(props) {
|
|
369
|
+
const [viz, setViz] = React.useState(null);
|
|
370
|
+
const [status, setStatus] = React.useState(null);
|
|
371
|
+
const [reload, setReload] = React.useState(0);
|
|
372
|
+
const [busy, setBusy] = React.useState(false);
|
|
373
|
+
const visible = props?.visible !== false;
|
|
374
|
+
// The sidebar's own scope. Its `sessionId` is what tells the host WHICH
|
|
375
|
+
// workspace to resolve — the same session whose cwd the sidebar shows.
|
|
376
|
+
const sessionId = props?.scope?.sessionId;
|
|
377
|
+
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
if (!visible || viz !== null) return;
|
|
380
|
+
let live = true;
|
|
381
|
+
const service = ctx.get("remote.graftStatus");
|
|
382
|
+
if (service === undefined || service === null) {
|
|
383
|
+
setViz({ ok: false, reason: "graft-status host half is not mounted" });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Always asked, never seeded from `tab.path`. The seed's only job is
|
|
387
|
+
// to make `openTab` treat this as a content open so a collapsed panel
|
|
388
|
+
// expands; trusting it as the URL would iframe a stale address after
|
|
389
|
+
// the server was restarted for another repo.
|
|
390
|
+
Promise.resolve(service.viz(sessionId)).then(
|
|
391
|
+
(answer) => {
|
|
392
|
+
if (live) setViz(unwrap(answer));
|
|
393
|
+
},
|
|
394
|
+
(error) => {
|
|
395
|
+
if (live) setViz({ ok: false, reason: String(error?.message ?? error) });
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
return () => {
|
|
399
|
+
live = false;
|
|
400
|
+
};
|
|
401
|
+
}, [visible, viz, sessionId]);
|
|
402
|
+
|
|
403
|
+
// The header's own data. Separate from `viz` because the numbers move
|
|
404
|
+
// whenever the graph is rebuilt while the server URL does not, so they
|
|
405
|
+
// are re-read on every refresh and the iframe is left alone unless the
|
|
406
|
+
// reader asks for it.
|
|
407
|
+
React.useEffect(() => {
|
|
408
|
+
if (!visible) return;
|
|
409
|
+
let live = true;
|
|
410
|
+
const service = ctx.get("remote.graftStatus");
|
|
411
|
+
if (service === undefined || service === null) return;
|
|
412
|
+
Promise.resolve(service.status(sessionId)).then(
|
|
413
|
+
(answer) => {
|
|
414
|
+
if (live) setStatus(unwrap(answer));
|
|
415
|
+
},
|
|
416
|
+
() => {},
|
|
417
|
+
);
|
|
418
|
+
return () => {
|
|
419
|
+
live = false;
|
|
420
|
+
};
|
|
421
|
+
}, [visible, sessionId, reload]);
|
|
422
|
+
|
|
423
|
+
const refresh = React.useCallback(() => {
|
|
424
|
+
setBusy(true);
|
|
425
|
+
// Bumping the key remounts the iframe, which reloads it without
|
|
426
|
+
// touching the URL. A cache-busting query parameter would work too,
|
|
427
|
+
// but it would put a parameter graft never asked for into the address
|
|
428
|
+
// the reader can copy out.
|
|
429
|
+
setReload((n) => n + 1);
|
|
430
|
+
window.setTimeout(() => setBusy(false), 600);
|
|
431
|
+
}, []);
|
|
432
|
+
|
|
433
|
+
const ok = status !== null && status.ok === true;
|
|
434
|
+
const freshness = ok ? status.freshness ?? "unknown" : "unknown";
|
|
435
|
+
const saved = ok ? tokensFull(status.savedTokens) : null;
|
|
436
|
+
|
|
437
|
+
const bar = h(
|
|
438
|
+
"div",
|
|
439
|
+
{ className: "gs-tabbar" },
|
|
440
|
+
h("span", { className: "gs-tb-brand" }, "graft"),
|
|
441
|
+
ok
|
|
442
|
+
? h(
|
|
443
|
+
React.Fragment,
|
|
444
|
+
null,
|
|
445
|
+
h("span", { className: "gs-tb-sep" }, "·"),
|
|
446
|
+
h("span", { className: "gs-tb-dim" }, plain(status.nodeCount) + " nodes / " + plain(status.edgeCount) + " edges"),
|
|
447
|
+
h("span", { className: "gs-tb-sep" }, "·"),
|
|
448
|
+
h("span", { className: "gs-tb-fresh " + (TONE[freshness] ?? TONE.unknown) }, FRESH_MARK[freshness] ?? FRESH_MARK.unknown),
|
|
449
|
+
saved === null ? null : h("span", { className: "gs-tb-sep" }, "·"),
|
|
450
|
+
saved === null ? null : h("span", { className: "gs-tb-saved" }, saved),
|
|
451
|
+
)
|
|
452
|
+
: h("span", { className: "gs-tb-dim" }, status === null ? "…" : "no graph"),
|
|
453
|
+
h(
|
|
454
|
+
"button",
|
|
455
|
+
{
|
|
456
|
+
type: "button",
|
|
457
|
+
className: "gs-refresh" + (busy ? " gs-spinning" : ""),
|
|
458
|
+
onClick: refresh,
|
|
459
|
+
title: "Reload the visualisation",
|
|
460
|
+
"aria-label": "Reload the visualisation",
|
|
461
|
+
},
|
|
462
|
+
h(RefreshIcon),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
let body;
|
|
467
|
+
if (viz === null) {
|
|
468
|
+
body = h("div", { className: "gs-tabmsg" }, "Starting graft viz…");
|
|
469
|
+
} else if (viz.ok !== true) {
|
|
470
|
+
body = h(
|
|
471
|
+
"div",
|
|
472
|
+
{ className: "gs-tabmsg" },
|
|
473
|
+
h("div", null, "graft viz could not start."),
|
|
474
|
+
h("div", { className: "gs-tabreason" }, String(viz.reason ?? "unknown reason")),
|
|
475
|
+
);
|
|
476
|
+
} else {
|
|
477
|
+
body = h("iframe", { key: reload, className: "gs-tabframe", src: viz.url, title: "graft visualisation" });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return h("div", { className: "gs-tabwrap" }, bar, body);
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function makeSeat(ctx) {
|
|
485
|
+
return function GraftSeat(props) {
|
|
486
|
+
// Supplied by the slot registration's own `inject(sessionId)` below.
|
|
487
|
+
const sessionId = props?.sessionId;
|
|
488
|
+
const [status, setStatus] = React.useState(null);
|
|
489
|
+
const [viz, setViz] = React.useState(null);
|
|
490
|
+
const [busy, setBusy] = React.useState(false);
|
|
491
|
+
const [card, setCard] = React.useState(false);
|
|
492
|
+
const dwell = React.useRef(null);
|
|
493
|
+
const wrap = React.useRef(null);
|
|
494
|
+
|
|
495
|
+
// Whether the graph is at rest. Drives the poll cadence below.
|
|
496
|
+
const settled = status !== null && status.ok === true && status.freshness === "synced";
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Two seconds of dwell before the card appears.
|
|
500
|
+
*
|
|
501
|
+
* Long on purpose. The chip sits in the composer, which the pointer
|
|
502
|
+
* crosses constantly on its way to the send button, and a card that
|
|
503
|
+
* appeared on contact would flash on every pass. Two seconds is
|
|
504
|
+
* unmistakably "I am looking at this".
|
|
505
|
+
*/
|
|
506
|
+
const enter = React.useCallback(() => {
|
|
507
|
+
window.clearTimeout(dwell.current);
|
|
508
|
+
dwell.current = window.setTimeout(() => setCard(true), 2000);
|
|
509
|
+
}, []);
|
|
510
|
+
const leave = React.useCallback(() => {
|
|
511
|
+
window.clearTimeout(dwell.current);
|
|
512
|
+
// Only hover-opened cards close on leave. One opened by a CLICK is a
|
|
513
|
+
// deliberate act and stays until it is dismissed, so the pointer can
|
|
514
|
+
// move away — to read it, or to reach for something else — without
|
|
515
|
+
// the card vanishing mid-sentence.
|
|
516
|
+
setCard((open) => (open === "click" ? open : false));
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
// Clicking the chip shows the card at once, and clicking again hides
|
|
520
|
+
// it: the same affordance without waiting out the dwell.
|
|
521
|
+
const toggleCard = React.useCallback(() => {
|
|
522
|
+
window.clearTimeout(dwell.current);
|
|
523
|
+
setCard((open) => (open === "click" ? false : "click"));
|
|
524
|
+
}, []);
|
|
525
|
+
|
|
526
|
+
// A click-opened card is dismissed by clicking away from it, the way
|
|
527
|
+
// every other popover in this UI behaves. Only that mode listens: a
|
|
528
|
+
// hover card already closes on mouse-out, and a document-wide listener
|
|
529
|
+
// for it would fire on every click in the composer for nothing.
|
|
530
|
+
React.useEffect(() => {
|
|
531
|
+
if (card !== "click") return;
|
|
532
|
+
const onDocument = (event) => {
|
|
533
|
+
// `contains` rather than a target comparison, so a click on the
|
|
534
|
+
// card's own text — or on the chip, which toggles — is not treated
|
|
535
|
+
// as "outside" and closed twice.
|
|
536
|
+
if (wrap.current !== null && wrap.current.contains(event.target)) return;
|
|
537
|
+
setCard(false);
|
|
538
|
+
};
|
|
539
|
+
// Capture phase: a handler that stops propagation somewhere in the
|
|
540
|
+
// composer would otherwise leave the card stuck open.
|
|
541
|
+
document.addEventListener("mousedown", onDocument, true);
|
|
542
|
+
return () => document.removeEventListener("mousedown", onDocument, true);
|
|
543
|
+
}, [card]);
|
|
544
|
+
// A pending timer that fires after unmount would set state on a dead
|
|
545
|
+
// component; one that fires after the session switched would show the
|
|
546
|
+
// previous workspace's card.
|
|
547
|
+
React.useEffect(() => () => window.clearTimeout(dwell.current), []);
|
|
548
|
+
|
|
549
|
+
React.useEffect(() => {
|
|
550
|
+
let live = true;
|
|
551
|
+
const pull = () => {
|
|
552
|
+
const service = ctx.get("remote.graftStatus");
|
|
553
|
+
if (service === undefined || service === null) {
|
|
554
|
+
if (live) setStatus({ ok: false, reason: "graft-status host half is not mounted" });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
Promise.resolve(service.status(sessionId)).then(
|
|
558
|
+
(answer) => {
|
|
559
|
+
if (live) setStatus(unwrap(answer));
|
|
560
|
+
},
|
|
561
|
+
(error) => {
|
|
562
|
+
if (live) setStatus({ ok: false, reason: String(error?.message ?? error) });
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
pull();
|
|
567
|
+
// Adaptive, because a fixed 15s interval made the `syncing` state
|
|
568
|
+
// effectively invisible: a rebuild of a small repo finishes in about
|
|
569
|
+
// a second, so the odds of a poll landing inside that window were
|
|
570
|
+
// near zero and the chip appeared to jump straight from stale back
|
|
571
|
+
// to green. While the graph is MOVING — stale, mid-sync, or not yet
|
|
572
|
+
// known — it is watched closely; once it is in sync there is nothing
|
|
573
|
+
// to watch and the slow interval is right again.
|
|
574
|
+
const timer = window.setInterval(pull, settled ? 15000 : 2000);
|
|
575
|
+
return () => {
|
|
576
|
+
live = false;
|
|
577
|
+
window.clearInterval(timer);
|
|
578
|
+
};
|
|
579
|
+
// `settled` is a dependency, not just a value read inside: without it
|
|
580
|
+
// the interval would keep whatever cadence it had at mount — 2s, from
|
|
581
|
+
// the initial unknown state — and never slow down again.
|
|
582
|
+
}, [sessionId, settled]);
|
|
583
|
+
|
|
584
|
+
const openViz = React.useCallback(() => {
|
|
585
|
+
const repo = basename((status && status.root) || "");
|
|
586
|
+
|
|
587
|
+
// Preferred path: a tab in the sidebar dsh already has.
|
|
588
|
+
//
|
|
589
|
+
// No window is opened here, so none of the popup-blocker timing
|
|
590
|
+
// below applies — the await is free.
|
|
591
|
+
const sidebar = ctx.get("betterSidebar");
|
|
592
|
+
if (sidebar !== undefined && sidebar !== null) {
|
|
593
|
+
// ALWAYS carries a `url`, even when there is no server yet.
|
|
594
|
+
//
|
|
595
|
+
// `openTab` expands a collapsed panel only for a CONTENT open (a
|
|
596
|
+
// `path` or `url` seed); a type-only open is documented to leave
|
|
597
|
+
// the panel exactly as it found it, so clicking the button with
|
|
598
|
+
// the sidebar collapsed would appear to do nothing at all. The tab
|
|
599
|
+
// component never reads this value — it asks the host itself on
|
|
600
|
+
// every mount — so the placeholder is inert, and its only job is
|
|
601
|
+
// to make the open a content open.
|
|
602
|
+
const open = (url) =>
|
|
603
|
+
sidebar.openTab({ type: TAB_TYPE, title: "Graft", url: url || "graft://starting" });
|
|
604
|
+
|
|
605
|
+
if (viz !== null && viz.ok === true && viz.url) {
|
|
606
|
+
open(viz.url);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// Opened FIRST, before the await: the panel comes out on the click
|
|
610
|
+
// itself, showing "Starting graft viz…" while the server boots,
|
|
611
|
+
// rather than staying shut for the ten seconds that can take.
|
|
612
|
+
open(null);
|
|
613
|
+
|
|
614
|
+
const service = ctx.get("remote.graftStatus");
|
|
615
|
+
if (service === undefined || service === null) {
|
|
616
|
+
setViz({ ok: false, reason: "graft-status host half is not mounted" });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
setBusy(true);
|
|
620
|
+
Promise.resolve(service.viz(sessionId)).then(
|
|
621
|
+
(answer) => {
|
|
622
|
+
setViz(unwrap(answer));
|
|
623
|
+
setBusy(false);
|
|
624
|
+
},
|
|
625
|
+
(error) => {
|
|
626
|
+
setViz({ ok: false, reason: String(error?.message ?? error) });
|
|
627
|
+
setBusy(false);
|
|
628
|
+
},
|
|
629
|
+
);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Fallback for a profile with no dsh-better-sidebar: a browser tab.
|
|
634
|
+
|
|
635
|
+
// Opened SYNCHRONOUSLY, before any await. A tab opened from a
|
|
636
|
+
// resolved promise is a popup as far as the browser is concerned and
|
|
637
|
+
// is blocked; opened from the click itself it is a user gesture.
|
|
638
|
+
//
|
|
639
|
+
// NOT `noopener`. Per the HTML spec, window.open returns NULL when
|
|
640
|
+
// noopener is set — the whole point of the flag is to sever the
|
|
641
|
+
// handle. A first version passed it and then tried to use the handle
|
|
642
|
+
// it had just given away, so the tab opened, stayed at about:blank,
|
|
643
|
+
// and was never written to or navigated: a permanent white page.
|
|
644
|
+
// The handle is required here because the URL is not known yet.
|
|
645
|
+
// The cost is that graft viz gets a cross-origin `window.opener` —
|
|
646
|
+
// acceptable for a localhost tool this plugin spawned itself.
|
|
647
|
+
const tab = window.open("", "_blank");
|
|
648
|
+
if (tab !== null && tab.document !== undefined) {
|
|
649
|
+
try {
|
|
650
|
+
tab.document.write(waitingDocument(repo));
|
|
651
|
+
tab.document.close();
|
|
652
|
+
} catch {
|
|
653
|
+
// A blocker can hand back a tab that is not writable. The
|
|
654
|
+
// navigation below still works, so this is cosmetic only.
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const land = (url) => {
|
|
659
|
+
if (tab !== null) {
|
|
660
|
+
tab.location.href = url;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
// The tab was blocked. Say so with the URL rather than failing
|
|
664
|
+
// silently — the server is running either way.
|
|
665
|
+
setViz({ ok: false, reason: "your browser blocked the new tab — open " + url + " yourself" });
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// `graft viz` holds a fixed port and the host adopts a server that is
|
|
669
|
+
// already up, so a second click is cheap; the known URL just skips
|
|
670
|
+
// the round trip.
|
|
671
|
+
if (viz !== null && viz.ok === true && viz.url) {
|
|
672
|
+
land(viz.url);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const service = ctx.get("remote.graftStatus");
|
|
677
|
+
if (service === undefined || service === null) {
|
|
678
|
+
if (tab !== null) tab.close();
|
|
679
|
+
setViz({ ok: false, reason: "graft-status host half is not mounted" });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
setBusy(true);
|
|
684
|
+
Promise.resolve(service.viz(sessionId)).then(
|
|
685
|
+
(answer) => {
|
|
686
|
+
const next = unwrap(answer);
|
|
687
|
+
setViz(next);
|
|
688
|
+
setBusy(false);
|
|
689
|
+
if (next && next.ok === true && next.url) land(next.url);
|
|
690
|
+
else if (tab !== null) tab.close();
|
|
691
|
+
},
|
|
692
|
+
(error) => {
|
|
693
|
+
setViz({ ok: false, reason: String(error?.message ?? error) });
|
|
694
|
+
setBusy(false);
|
|
695
|
+
if (tab !== null) tab.close();
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
}, [status, viz]);
|
|
699
|
+
|
|
700
|
+
// NEVER returns null.
|
|
701
|
+
//
|
|
702
|
+
// The first version hid itself whenever the status was not ok, on the
|
|
703
|
+
// theory that a chip reading "no graft" is furniture in a repo that
|
|
704
|
+
// does not use graft. That was wrong twice over: the plugin is only
|
|
705
|
+
// installed by someone who does use graft, and hiding took the VIZ
|
|
706
|
+
// BUTTON with it — so the one situation that needs explaining (graft
|
|
707
|
+
// resolved to nothing) produced a blank composer and no way to ask
|
|
708
|
+
// why. A failure you can see is worth more than a tidy bar.
|
|
709
|
+
const ok = status !== null && status.ok === true;
|
|
710
|
+
const freshness = ok ? status.freshness ?? "unknown" : "unknown";
|
|
711
|
+
const tone = ok ? TONE[freshness] ?? TONE.unknown : TONE.unknown;
|
|
712
|
+
const glyph = status === null ? "◌" : ok ? GLYPH[freshness] ?? GLYPH.unknown : GLYPH.unknown;
|
|
713
|
+
const label = status === null ? "…" : ok ? basename(status.root) : "no graph";
|
|
714
|
+
// A failed start stays visible on the button rather than vanishing on
|
|
715
|
+
// the next render: the tab it would have explained itself in is gone.
|
|
716
|
+
const failed = viz !== null && viz.ok === false;
|
|
717
|
+
|
|
718
|
+
return h(
|
|
719
|
+
"div",
|
|
720
|
+
{ className: "gs-wrap", ref: wrap },
|
|
721
|
+
card ? h(StatusCard, { status }) : null,
|
|
722
|
+
h(
|
|
723
|
+
"div",
|
|
724
|
+
{
|
|
725
|
+
className: "gs-chip gs-clickable " + tone + (ok ? "" : " gs-muted"),
|
|
726
|
+
onMouseEnter: enter,
|
|
727
|
+
onMouseLeave: leave,
|
|
728
|
+
onClick: toggleCard,
|
|
729
|
+
// Reachable without a pointer, and announced as what it is: a
|
|
730
|
+
// control that discloses detail, not a button that acts.
|
|
731
|
+
role: "button",
|
|
732
|
+
tabIndex: 0,
|
|
733
|
+
"aria-expanded": card !== false,
|
|
734
|
+
onKeyDown: (event) => {
|
|
735
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
toggleCard();
|
|
738
|
+
} else if (event.key === "Escape") {
|
|
739
|
+
leave();
|
|
740
|
+
setCard(false);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
// No `title`: the browser would render its own grey box over the
|
|
744
|
+
// card, on its own schedule, saying the same thing worse.
|
|
745
|
+
},
|
|
746
|
+
h("span", { className: "gs-dot", "aria-hidden": "true" }, glyph),
|
|
747
|
+
h("span", { className: "gs-name" }, "graft"),
|
|
748
|
+
h("span", { className: "gs-sep" }, "·"),
|
|
749
|
+
h("span", null, label),
|
|
750
|
+
ok && freshness === "stale" ? h("span", { className: "gs-sep" }, "·") : null,
|
|
751
|
+
ok && freshness === "stale" ? h("span", null, "stale") : null,
|
|
752
|
+
),
|
|
753
|
+
h(
|
|
754
|
+
"button",
|
|
755
|
+
{
|
|
756
|
+
type: "button",
|
|
757
|
+
className: "gs-viz" + (failed ? " gs-err" : ""),
|
|
758
|
+
onClick: openViz,
|
|
759
|
+
disabled: busy,
|
|
760
|
+
title: busy
|
|
761
|
+
? "Starting graft viz…"
|
|
762
|
+
: failed
|
|
763
|
+
? "graft viz: " + viz.reason
|
|
764
|
+
: "Open the graph",
|
|
765
|
+
"aria-label": "Open the graph visualisation",
|
|
766
|
+
},
|
|
767
|
+
h(GraphIcon),
|
|
768
|
+
),
|
|
769
|
+
);
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* ================================================================== *
|
|
774
|
+
* Wiring
|
|
775
|
+
* ================================================================== */
|
|
776
|
+
const inject = ["slots", "remote"];
|
|
777
|
+
|
|
778
|
+
async function apply(ctx) {
|
|
779
|
+
const disposeRemote = await ctx.remote.$mount(CONTRIBUTION);
|
|
780
|
+
ctx.effect(() => disposeRemote, "graft-status: remote contribution");
|
|
781
|
+
|
|
782
|
+
injectStyles();
|
|
783
|
+
|
|
784
|
+
// Breadcrumb, not logging noise. If the chip is invisible this is the
|
|
785
|
+
// only way to tell "the bundle never loaded" from "the slot never
|
|
786
|
+
// rendered" without a second round trip through the user.
|
|
787
|
+
console.info("graft-status: client mounted, registering conversation.input.right");
|
|
788
|
+
|
|
789
|
+
// A `list` slot in the composer's trailing div, rendered immediately
|
|
790
|
+
// before `conversation.input.model` — so this sits directly left of the
|
|
791
|
+
// model name without displacing the model seat.
|
|
792
|
+
ctx.slots.inject("conversation.input.right", () =>
|
|
793
|
+
ctx.slots.register(
|
|
794
|
+
{
|
|
795
|
+
name: "conversation.input.right",
|
|
796
|
+
id: "graft-status",
|
|
797
|
+
order: 20,
|
|
798
|
+
label: "graft",
|
|
799
|
+
// The slot hands the session id to its `inject`, and that is the
|
|
800
|
+
// only way this seat learns WHICH workspace it is describing. The
|
|
801
|
+
// host resolves the repo from that session's own cwd, so a dsh
|
|
802
|
+
// launched from somewhere else still reports the right project.
|
|
803
|
+
inject: (sessionId) => ({ sessionId }),
|
|
804
|
+
},
|
|
805
|
+
makeSeat(ctx),
|
|
806
|
+
),
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
// Contributed through dsh-better-sidebar's PUBLIC service, not by
|
|
810
|
+
// touching that plugin. `ctx.inject` rather than the module-level
|
|
811
|
+
// `inject` list on purpose: a hard dependency would keep this whole
|
|
812
|
+
// plugin — chip included — from activating in a profile that does not
|
|
813
|
+
// have the sidebar installed. This way the tab appears when the service
|
|
814
|
+
// does, and the button falls back to a browser tab when it never comes.
|
|
815
|
+
ctx.inject(["betterSidebar"], (scope) => {
|
|
816
|
+
scope.effect(
|
|
817
|
+
() =>
|
|
818
|
+
scope.betterSidebar.registerTab({
|
|
819
|
+
id: TAB_TYPE,
|
|
820
|
+
title: "Graft",
|
|
821
|
+
icon: () => h(GraphIcon),
|
|
822
|
+
order: 60,
|
|
823
|
+
// One graph per repo, so a second open focuses the tab that is
|
|
824
|
+
// already there instead of stacking duplicates on one port.
|
|
825
|
+
single: true,
|
|
826
|
+
component: makeVizTab(ctx),
|
|
827
|
+
}),
|
|
828
|
+
"graft-status: sidebar tab",
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
exports.apply = apply;
|
|
834
|
+
exports.inject = inject;
|
|
835
|
+
// Exported for the harness; pure, and never read by the host.
|
|
836
|
+
exports.unwrap = unwrap;
|
|
837
|
+
exports.tokens = tokens;
|
|
838
|
+
exports.basename = basename;
|
|
839
|
+
exports.count = count;
|
|
840
|
+
|
|
841
|
+
return module.exports;
|
|
842
|
+
},
|
|
843
|
+
});
|