dsh-report-ledger 0.1.0 → 0.2.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/README.md +387 -29
- package/lib/client.js +2528 -331
- package/lib/client.js.map +1 -1
- package/lib/index.js +1802 -74
- package/package.json +4 -3
package/lib/client.js
CHANGED
|
@@ -5,9 +5,24 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
8
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
10
|
//#region src/client/timeline-model.ts
|
|
10
11
|
/**
|
|
12
|
+
* The timeline's ordering: by time, and at the same instant a session before a
|
|
13
|
+
* report.
|
|
14
|
+
*
|
|
15
|
+
* Extracted so that INSERTING a row uses the same rule as SORTING rows — two
|
|
16
|
+
* copies would drift, and the synthesized row would land somewhere the sort
|
|
17
|
+
* disagrees with.
|
|
18
|
+
* @param left - one row.
|
|
19
|
+
* @param right - the other row.
|
|
20
|
+
* @returns the comparison result.
|
|
21
|
+
*/
|
|
22
|
+
function byTime(left, right) {
|
|
23
|
+
return left.at - right.at || (left.kind === right.kind ? 0 : left.kind === "session" ? -1 : 1);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
11
26
|
* Build the merged, time-ordered row list.
|
|
12
27
|
*
|
|
13
28
|
* Sessions are placed at their creation time and reports at theirs, so the list
|
|
@@ -40,9 +55,79 @@ window.__ModuleLoader__.load({
|
|
|
40
55
|
depth: depth.get(front.from) ?? 0,
|
|
41
56
|
front
|
|
42
57
|
});
|
|
43
|
-
rows.sort(
|
|
58
|
+
rows.sort(byTime);
|
|
44
59
|
return rows;
|
|
45
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Shorten a session id so a digest row can be read.
|
|
63
|
+
*
|
|
64
|
+
* A session id is 36 characters and a report's recipients are a list of them, so
|
|
65
|
+
* an unabbreviated meta line is mostly uuid and carries almost no information a
|
|
66
|
+
* human can use. Eight characters of the leading segment stay unique within one
|
|
67
|
+
* collaboration and, unlike a session title, are bounded: titles are the first
|
|
68
|
+
* prompt of a session ("You are doing READ-ONLY research"), so swapping ids for
|
|
69
|
+
* titles trades one unreadable line for another.
|
|
70
|
+
*
|
|
71
|
+
* This is a DENSE-ROW display only. The detail panel keeps full ids, because
|
|
72
|
+
* that is the view somebody copies an id out of, and the tooltip on every
|
|
73
|
+
* shortened id carries the full value.
|
|
74
|
+
* @param id - the session id.
|
|
75
|
+
* @param length - how many characters to keep.
|
|
76
|
+
* @returns the shortened id.
|
|
77
|
+
*/
|
|
78
|
+
function shortId(id, length = 8) {
|
|
79
|
+
const bare = id.startsWith("session-") ? id.slice(8) : id;
|
|
80
|
+
return bare.length <= length ? bare : bare.slice(0, length);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Display titles for the sessions in one payload, keyed by session id.
|
|
84
|
+
*
|
|
85
|
+
* Titles are resolved only for the subtree, so an id from another tree simply
|
|
86
|
+
* has none — which is why every caller must fall back to the shortened id
|
|
87
|
+
* rather than assuming a name exists.
|
|
88
|
+
* @param payload - the endpoint payload.
|
|
89
|
+
* @returns the titles that were resolved.
|
|
90
|
+
*/
|
|
91
|
+
function sessionTitles(payload) {
|
|
92
|
+
const titles = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const node of payload.sessions) if (typeof node.title === "string" && node.title !== "") titles.set(node.id, node.title);
|
|
94
|
+
return titles;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Make sure the open report has a row, and put it where it belongs.
|
|
98
|
+
*
|
|
99
|
+
* The detail panel is anchored to a report row, so the open report must HAVE
|
|
100
|
+
* one. Two real user paths would otherwise leave the panel with nowhere to
|
|
101
|
+
* render: a thread link pointing at a report outside this tree's payload, and
|
|
102
|
+
* any filter applied while a report is open. In the first case the panel is also
|
|
103
|
+
* the only place the "not in this tree" notice can appear, so the panel is not
|
|
104
|
+
* hoisted out of the list — a row is synthesized instead.
|
|
105
|
+
*
|
|
106
|
+
* The synthesized row is INSERTED at its own place on the time axis rather than
|
|
107
|
+
* appended. Appending made a report created at 11:12 appear below one created at
|
|
108
|
+
* 14:30, which reads as a broken sort at exactly the moment the user is trying
|
|
109
|
+
* to understand why something is missing.
|
|
110
|
+
* @param kept - the rows that survived the filter.
|
|
111
|
+
* @param front - the digest of the open report.
|
|
112
|
+
* @param basis - the active time basis, so the row moves with the rest.
|
|
113
|
+
* @returns the rows, with the open report present exactly once.
|
|
114
|
+
*/
|
|
115
|
+
function withSynthesizedRow(kept, front, basis = "created") {
|
|
116
|
+
if (kept.some((row) => row.kind === "report" && row.front.report === front.report)) return [...kept];
|
|
117
|
+
const row = {
|
|
118
|
+
kind: "report",
|
|
119
|
+
at: basis === "created" ? front.created : front.updated,
|
|
120
|
+
depth: 0,
|
|
121
|
+
front
|
|
122
|
+
};
|
|
123
|
+
const index = kept.findIndex((candidate) => byTime(row, candidate) < 0);
|
|
124
|
+
if (index < 0) return [...kept, row];
|
|
125
|
+
return [
|
|
126
|
+
...kept.slice(0, index),
|
|
127
|
+
row,
|
|
128
|
+
...kept.slice(index)
|
|
129
|
+
];
|
|
130
|
+
}
|
|
46
131
|
/** Every string a report row can be found by. */
|
|
47
132
|
function reportHaystack(front) {
|
|
48
133
|
return [
|
|
@@ -54,7 +139,9 @@ window.__ModuleLoader__.load({
|
|
|
54
139
|
...front.cc,
|
|
55
140
|
...front.authors,
|
|
56
141
|
front.task ?? "",
|
|
57
|
-
...front.artifacts
|
|
142
|
+
...front.artifacts,
|
|
143
|
+
front.worktree?.branch ?? "",
|
|
144
|
+
front.worktree?.path ?? ""
|
|
58
145
|
].join("\n").toLowerCase();
|
|
59
146
|
}
|
|
60
147
|
/** Every string a session row can be found by. */
|
|
@@ -113,6 +200,51 @@ window.__ModuleLoader__.load({
|
|
|
113
200
|
return filter.status !== "all" || filter.text.trim() !== "";
|
|
114
201
|
}
|
|
115
202
|
/**
|
|
203
|
+
* Index the payload's working-tree lanes by report id.
|
|
204
|
+
*
|
|
205
|
+
* The lanes carry the lease state, which the digests do not: liveness is derived
|
|
206
|
+
* from the hop stream, and the host derives it once rather than each half of the
|
|
207
|
+
* plugin deriving it separately.
|
|
208
|
+
* @param payload - the endpoint payload.
|
|
209
|
+
* @returns report id to lane.
|
|
210
|
+
*/
|
|
211
|
+
function laneIndex(payload) {
|
|
212
|
+
return new Map(payload.worktrees.map((lane) => [lane.report, lane]));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* How a lease state should read at a glance.
|
|
216
|
+
*
|
|
217
|
+
* A live claim is the only state that means someone is working *now*, so it is
|
|
218
|
+
* the only one that earns a positive tone. A cleared checkout is deliberately
|
|
219
|
+
* plain rather than alarming: the ledger tidying up after itself is not a
|
|
220
|
+
* problem to be surfaced, and giving it a warning tone would teach a reader to
|
|
221
|
+
* ignore warnings.
|
|
222
|
+
*/
|
|
223
|
+
const LEASE_TONE = {
|
|
224
|
+
held: "success",
|
|
225
|
+
released: "info",
|
|
226
|
+
reclaimed: "neutral",
|
|
227
|
+
none: "neutral"
|
|
228
|
+
};
|
|
229
|
+
/** The last path segment, for naming a checkout that has no branch. */
|
|
230
|
+
function lastSegment(path) {
|
|
231
|
+
const parts = path.split(/[\\/]/);
|
|
232
|
+
return parts[parts.length - 1] || path;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The short name a checkout is shown under.
|
|
236
|
+
*
|
|
237
|
+
* Taken from the binding rather than from the lane, because a report opened by
|
|
238
|
+
* id can be rendered before it is in the payload's lane list — the binding is
|
|
239
|
+
* always on the row, the lane is not.
|
|
240
|
+
* @param binding - the row's binding, when it has one.
|
|
241
|
+
* @returns the label, or `undefined` when the report is not bound.
|
|
242
|
+
*/
|
|
243
|
+
function worktreeLabel(binding) {
|
|
244
|
+
if (binding === void 0) return void 0;
|
|
245
|
+
return binding.branch ?? lastSegment(binding.path);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
116
248
|
* Count how many report rows are visible under a filter.
|
|
117
249
|
* @param rows - every assembled row.
|
|
118
250
|
* @param filter - the active filter.
|
|
@@ -124,44 +256,1975 @@ window.__ModuleLoader__.load({
|
|
|
124
256
|
return count;
|
|
125
257
|
}
|
|
126
258
|
/**
|
|
127
|
-
*
|
|
259
|
+
* Decide which empty-state message applies.
|
|
260
|
+
*
|
|
261
|
+
* An empty ledger and a filter that matched nothing look identical on screen but
|
|
262
|
+
* mean opposite things: one says "nothing has happened in this tree yet", the
|
|
263
|
+
* other says "you are looking at a subset of what happened". Saying the wrong
|
|
264
|
+
* one makes a working ledger look broken, so the choice is made here — where it
|
|
265
|
+
* is pinned by tests — rather than inline in the view.
|
|
266
|
+
*
|
|
267
|
+
* Counted on REPORT rows only. Session rows always survive a status chip, so
|
|
268
|
+
* counting them would let a tree full of sessions report itself as "you have
|
|
269
|
+
* filtered everything out" while the actual reason is that no report exists.
|
|
270
|
+
*
|
|
271
|
+
* The `no-reports` case deliberately wins over the filter: when the ledger holds
|
|
272
|
+
* nothing, a filter cannot be the reason nothing is on screen, and blaming it
|
|
273
|
+
* would send the reader looking for a filter to clear that they never set.
|
|
274
|
+
* @param payload - the endpoint payload.
|
|
275
|
+
* @param rows - every assembled row.
|
|
276
|
+
* @param filter - the active filter.
|
|
277
|
+
* @returns which message the view should show, if any.
|
|
278
|
+
*/
|
|
279
|
+
function emptyState(payload, rows, filter) {
|
|
280
|
+
if (payload.reports.length === 0) return "no-reports";
|
|
281
|
+
if (!isFiltering(filter)) return "none";
|
|
282
|
+
return visibleReports(rows, filter) === 0 ? "filtered-out" : "none";
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The report ids present in one payload.
|
|
286
|
+
*
|
|
287
|
+
* A thread link can point outside the subtree — a report answering one opened by
|
|
288
|
+
* an unrelated session, for instance — and the detail endpoint is ledger-scoped,
|
|
289
|
+
* so such a report still opens. Knowing which ids are in the list is what lets
|
|
290
|
+
* the view say so instead of silently showing a card that is not in the timeline.
|
|
291
|
+
* @param payload - the endpoint payload.
|
|
292
|
+
* @returns the ids of every report in the payload.
|
|
293
|
+
*/
|
|
294
|
+
function reportIds(payload) {
|
|
295
|
+
const ids = /* @__PURE__ */ new Set();
|
|
296
|
+
for (const front of payload.reports) ids.add(front.report);
|
|
297
|
+
return ids;
|
|
298
|
+
}
|
|
299
|
+
/** Body length above which the detail panel shows only a head. */
|
|
300
|
+
const BODY_DISPLAY_LIMIT = 4e3;
|
|
301
|
+
/** How much of an over-long body is shown. */
|
|
302
|
+
const BODY_HEAD_LIMIT = 1200;
|
|
303
|
+
/**
|
|
304
|
+
* Trim a body for the detail panel.
|
|
305
|
+
*
|
|
306
|
+
* Mirrors what `report_read` already does for the model: the full text stays in
|
|
307
|
+
* the ledger file, and both readers get a bounded view plus a pointer rather than
|
|
308
|
+
* an unbounded paste. Keeping the human view and the model view bounded the same
|
|
309
|
+
* way means neither can be surprised by what the other sees.
|
|
310
|
+
* @param body - the raw body.
|
|
311
|
+
* @param limit - length above which truncation applies.
|
|
312
|
+
* @param head - how many characters to keep when truncating.
|
|
313
|
+
* @returns the display text and whether it was cut.
|
|
314
|
+
*/
|
|
315
|
+
function displayBody(body, limit = BODY_DISPLAY_LIMIT, head = BODY_HEAD_LIMIT) {
|
|
316
|
+
if (body.length <= limit) return {
|
|
317
|
+
text: body,
|
|
318
|
+
truncated: false
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
text: body.slice(0, head),
|
|
322
|
+
truncated: true
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Close a code fence left hanging by truncation.
|
|
327
|
+
*
|
|
328
|
+
* Cutting Markdown at a character count is not the same operation as cutting
|
|
329
|
+
* plain text: the result can be a document that does not parse as written. The
|
|
330
|
+
* one construct that changes the meaning of everything after it is a code fence,
|
|
331
|
+
* so a body cut inside a fenced block hands the renderer an unterminated one and
|
|
332
|
+
* leaves it to decide where the block ends.
|
|
333
|
+
*
|
|
334
|
+
* The renderer here does cope (measured: a body cut mid-fence renders as one
|
|
335
|
+
* closed code block with no trailing artifact), which is exactly why this is
|
|
336
|
+
* cheap insurance rather than a fix for something visibly broken: the renderer is
|
|
337
|
+
* a shell platform module this plugin does not own, and its leniency is not a
|
|
338
|
+
* contract. Balancing the document costs one line and makes the plugin's output
|
|
339
|
+
* valid Markdown no matter who renders it.
|
|
340
|
+
*
|
|
341
|
+
* Balanced text is returned untouched, so this only ever adds the fence the cut
|
|
342
|
+
* removed. Only backtick fences are counted; `~~~` fences are rare enough in
|
|
343
|
+
* report bodies that guessing which convention the author used would be worse
|
|
344
|
+
* than leaving them alone.
|
|
345
|
+
* @param text - the already-truncated head.
|
|
346
|
+
* @returns the head, with an unclosed fence closed.
|
|
347
|
+
*/
|
|
348
|
+
function closeOpenFence(text) {
|
|
349
|
+
let open = false;
|
|
350
|
+
for (const line of text.split("\n")) if (line.trimStart().startsWith("```")) open = !open;
|
|
351
|
+
return open ? `${text}\n\`\`\`` : text;
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/client/cardgraph-model.ts
|
|
355
|
+
/**
|
|
356
|
+
* Defaults.
|
|
357
|
+
*
|
|
358
|
+
* A full card is 240x110 — enough for a title, a status line and two lines of
|
|
359
|
+
* subject at a readable size. The chip tier is deliberately tiny: below 0.4 zoom
|
|
360
|
+
* a card is a colour, and drawing readable text there would be a lie about how
|
|
361
|
+
* much of the drawing the reader can actually take in.
|
|
362
|
+
*/
|
|
363
|
+
const DEFAULT_CARDGRAPH_OPTIONS = {
|
|
364
|
+
lod: "card",
|
|
365
|
+
cardMinZoom: .8,
|
|
366
|
+
compactMinZoom: .4,
|
|
367
|
+
sizes: {
|
|
368
|
+
card: {
|
|
369
|
+
width: 240,
|
|
370
|
+
height: 110
|
|
371
|
+
},
|
|
372
|
+
compact: {
|
|
373
|
+
width: 160,
|
|
374
|
+
height: 36
|
|
375
|
+
},
|
|
376
|
+
chip: {
|
|
377
|
+
width: 60,
|
|
378
|
+
height: 14
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
frameHeaderHeight: 26,
|
|
382
|
+
framePaddingX: 12,
|
|
383
|
+
framePaddingTop: 10,
|
|
384
|
+
framePaddingBottom: 12,
|
|
385
|
+
minFrameWidth: 180,
|
|
386
|
+
minFrameHeight: 96,
|
|
387
|
+
cardGap: 8,
|
|
388
|
+
columnGap: 48,
|
|
389
|
+
frameGap: 24,
|
|
390
|
+
margin: 24
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Which tier a zoom level calls for.
|
|
394
|
+
*
|
|
395
|
+
* Thresholds are inclusive on the low end, so 0.8 is a full card and 0.4 is a
|
|
396
|
+
* compact one: a zoom that lands exactly on a step reads as the richer of the
|
|
397
|
+
* two. A zoom that is not a usable number degrades to the cheapest tier instead
|
|
398
|
+
* of producing an infinite or negative box.
|
|
399
|
+
* @param zoom - the current scale.
|
|
400
|
+
* @param options - threshold overrides.
|
|
401
|
+
* @returns the tier to lay out at.
|
|
402
|
+
*/
|
|
403
|
+
function lodFor(zoom, options = {}) {
|
|
404
|
+
const opts = {
|
|
405
|
+
...DEFAULT_CARDGRAPH_OPTIONS,
|
|
406
|
+
...options
|
|
407
|
+
};
|
|
408
|
+
if (!Number.isFinite(zoom)) return "chip";
|
|
409
|
+
if (zoom >= opts.cardMinZoom) return "card";
|
|
410
|
+
if (zoom >= opts.compactMinZoom) return "compact";
|
|
411
|
+
return "chip";
|
|
412
|
+
}
|
|
413
|
+
/** Whether two rectangles overlap. Touching edges do not count as overlapping. */
|
|
414
|
+
function intersects(a, b) {
|
|
415
|
+
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
416
|
+
}
|
|
417
|
+
/** Whether a point is inside a rectangle. The top-left edges are inside, the bottom-right ones are not. */
|
|
418
|
+
function contains(rect, x, y) {
|
|
419
|
+
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
|
420
|
+
}
|
|
421
|
+
/** Grow a rectangle by `by` on every side. */
|
|
422
|
+
function grow(rect, by) {
|
|
423
|
+
return {
|
|
424
|
+
x: rect.x - by,
|
|
425
|
+
y: rect.y - by,
|
|
426
|
+
width: rect.width + by * 2,
|
|
427
|
+
height: rect.height + by * 2
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The topmost item under a point.
|
|
432
|
+
*
|
|
433
|
+
* Free of the DOM on purpose: the canvas has no elements to hit, so picking is a
|
|
434
|
+
* function from a point and an ordered list to an item — and that function is
|
|
435
|
+
* testable here rather than only by clicking in a browser. The caller passes the
|
|
436
|
+
* list **topmost first**, which is what makes "a card wins over the frame holding
|
|
437
|
+
* it" a property of the order rather than of the arithmetic.
|
|
438
|
+
* @param items - candidates, topmost first.
|
|
439
|
+
* @param x - point, in drawing coordinates.
|
|
440
|
+
* @param y - point, in drawing coordinates.
|
|
441
|
+
* @returns the first hit, or `undefined`.
|
|
442
|
+
*/
|
|
443
|
+
function hitTest(items, x, y) {
|
|
444
|
+
return items.find((item) => contains(item.bounds, x, y));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The part of the drawing a viewport can see, in drawing coordinates.
|
|
448
|
+
*
|
|
449
|
+
* The inverse of the transform the painter applies, and the input to culling. A
|
|
450
|
+
* broken transform is treated as identity rather than propagated: a scale of zero
|
|
451
|
+
* or a NaN would otherwise turn the visible rect into an infinite or reversed box
|
|
452
|
+
* and either cull everything or draw everything.
|
|
453
|
+
* @param view - the transform and the size of the paint box.
|
|
454
|
+
* @param overscan - drawing units to keep on each side, so a pan does not reveal a
|
|
455
|
+
* blank band before the next frame is painted.
|
|
456
|
+
* @returns the visible rect.
|
|
457
|
+
*/
|
|
458
|
+
function worldViewport(view, overscan = 0) {
|
|
459
|
+
const scale = Number.isFinite(view.scale) && view.scale > 0 ? view.scale : 1;
|
|
460
|
+
const width = Number.isFinite(view.width) ? Math.max(0, view.width) : 0;
|
|
461
|
+
const height = Number.isFinite(view.height) ? Math.max(0, view.height) : 0;
|
|
462
|
+
const offsetX = Number.isFinite(view.offsetX) ? view.offsetX : 0;
|
|
463
|
+
const offsetY = Number.isFinite(view.offsetY) ? view.offsetY : 0;
|
|
464
|
+
const rect = {
|
|
465
|
+
x: -offsetX / scale,
|
|
466
|
+
y: -offsetY / scale,
|
|
467
|
+
width: width / scale,
|
|
468
|
+
height: height / scale
|
|
469
|
+
};
|
|
470
|
+
return overscan === 0 ? rect : grow(rect, overscan);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Build the card graph.
|
|
474
|
+
*
|
|
475
|
+
* Frames come from the payload's sessions: column by subtree depth, vertical
|
|
476
|
+
* order by the payload's own order (the DFS pre-order the list indents by), so a
|
|
477
|
+
* session keeps its relative position across the two views. Cards are stacked
|
|
478
|
+
* inside their author's frame **by time**, because a frame is one session's own
|
|
479
|
+
* timeline; the reader who wants one timeline across all sessions has the list
|
|
480
|
+
* below. Only the reports handed in are drawn, so this is a filtered view rather
|
|
481
|
+
* than a second source of truth.
|
|
482
|
+
* @param payload - the endpoint payload, for the session frames.
|
|
483
|
+
* @param reports - the reports to plot; any order, since each frame sorts its own.
|
|
484
|
+
* @param options - geometry and tier overrides.
|
|
485
|
+
* @returns the layout; empty (and zero-sized) when there is nothing to draw.
|
|
486
|
+
*/
|
|
487
|
+
function buildCardgraph(payload, reports, options = {}) {
|
|
488
|
+
const opts = {
|
|
489
|
+
...DEFAULT_CARDGRAPH_OPTIONS,
|
|
490
|
+
...options
|
|
491
|
+
};
|
|
492
|
+
const size = opts.sizes[opts.lod];
|
|
493
|
+
const frameWidth = Math.max(opts.minFrameWidth, size.width + opts.framePaddingX * 2);
|
|
494
|
+
const sessions = payload.sessions;
|
|
495
|
+
const depths = [...new Set(sessions.map((node) => node.depth))].sort((a, b) => a - b);
|
|
496
|
+
const columnOfDepth = /* @__PURE__ */ new Map();
|
|
497
|
+
for (const [index, depth] of depths.entries()) columnOfDepth.set(depth, index);
|
|
498
|
+
const frameOf = /* @__PURE__ */ new Map();
|
|
499
|
+
const frames = sessions.map((node, index) => {
|
|
500
|
+
frameOf.set(node.id, index);
|
|
501
|
+
return {
|
|
502
|
+
...node.title === void 0 ? {} : { title: node.title },
|
|
503
|
+
id: node.id,
|
|
504
|
+
shortId: shortId(node.id),
|
|
505
|
+
depth: node.depth,
|
|
506
|
+
column: columnOfDepth.get(node.depth) ?? 0,
|
|
507
|
+
live: node.live,
|
|
508
|
+
external: false,
|
|
509
|
+
cards: 0
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
const mentions = (front) => [
|
|
513
|
+
front.from,
|
|
514
|
+
...front.to,
|
|
515
|
+
...front.cc,
|
|
516
|
+
...front.authors
|
|
517
|
+
];
|
|
518
|
+
const needsExternal = reports.some((front) => mentions(front).some((id) => id !== void 0 && !frameOf.has(id)));
|
|
519
|
+
const externalDepth = sessions.reduce((max, node) => Math.max(max, node.depth), -1) + 1;
|
|
520
|
+
if (needsExternal) frames.push({
|
|
521
|
+
shortId: "",
|
|
522
|
+
depth: externalDepth,
|
|
523
|
+
column: depths.length,
|
|
524
|
+
live: false,
|
|
525
|
+
external: true,
|
|
526
|
+
cards: 0
|
|
527
|
+
});
|
|
528
|
+
const externalIndex = needsExternal ? frames.length - 1 : -1;
|
|
529
|
+
const frameIndexById = (id) => id !== void 0 && frameOf.has(id) ? frameOf.get(id) : externalIndex;
|
|
530
|
+
const buckets = frames.map(() => []);
|
|
531
|
+
for (const front of reports) {
|
|
532
|
+
const target = frameIndexById(front.from);
|
|
533
|
+
if (target >= 0) buckets[target]?.push(front);
|
|
534
|
+
}
|
|
535
|
+
for (const bucket of buckets) bucket.sort((a, b) => a.created - b.created || (a.report < b.report ? -1 : a.report > b.report ? 1 : 0));
|
|
536
|
+
const columnX = (column) => opts.margin + column * (frameWidth + opts.columnGap);
|
|
537
|
+
const frameHeight = (count) => Math.max(opts.minFrameHeight, opts.frameHeaderHeight + opts.framePaddingTop + opts.framePaddingBottom + count * size.height + Math.max(0, count - 1) * opts.cardGap);
|
|
538
|
+
frames.forEach((frame, index) => {
|
|
539
|
+
frame.cards = buckets[index]?.length ?? 0;
|
|
540
|
+
});
|
|
541
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
542
|
+
const fixed = frames.map((frame) => {
|
|
543
|
+
const height = frameHeight(frame.cards);
|
|
544
|
+
const y = cursor.get(frame.column) ?? opts.margin;
|
|
545
|
+
cursor.set(frame.column, y + height + opts.frameGap);
|
|
546
|
+
return {
|
|
547
|
+
x: columnX(frame.column),
|
|
548
|
+
y,
|
|
549
|
+
width: frameWidth,
|
|
550
|
+
height
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
const columnCount = depths.length + (needsExternal ? 1 : 0);
|
|
554
|
+
const heights = Array.from({ length: columnCount }, (_unused, column) => {
|
|
555
|
+
const stacked = fixed.filter((_box, index) => frames[index]?.column === column);
|
|
556
|
+
return stacked.length === 0 ? 0 : stacked.reduce((sum, box) => sum + box.height, 0) + (stacked.length - 1) * opts.frameGap;
|
|
557
|
+
});
|
|
558
|
+
const layoutFrames = frames.map((frame, index) => ({
|
|
559
|
+
...frame.id === void 0 ? {} : { id: frame.id },
|
|
560
|
+
...frame.title === void 0 ? {} : { title: frame.title },
|
|
561
|
+
index,
|
|
562
|
+
shortId: frame.shortId,
|
|
563
|
+
depth: frame.depth,
|
|
564
|
+
column: frame.column,
|
|
565
|
+
live: frame.live,
|
|
566
|
+
external: frame.external,
|
|
567
|
+
cards: frame.cards,
|
|
568
|
+
bounds: fixed[index]
|
|
569
|
+
}));
|
|
570
|
+
const columns = Array.from({ length: columnCount }, (_unused, column) => ({
|
|
571
|
+
depth: depths[column] ?? externalDepth,
|
|
572
|
+
index: column,
|
|
573
|
+
bounds: {
|
|
574
|
+
x: columnX(column),
|
|
575
|
+
y: opts.margin,
|
|
576
|
+
width: frameWidth,
|
|
577
|
+
height: Math.max(0, heights[column] ?? 0)
|
|
578
|
+
}
|
|
579
|
+
}));
|
|
580
|
+
const cards = [];
|
|
581
|
+
const cardIndexOf = /* @__PURE__ */ new Map();
|
|
582
|
+
layoutFrames.forEach((frame) => {
|
|
583
|
+
(buckets[frame.index] ?? []).forEach((front, slot) => {
|
|
584
|
+
cardIndexOf.set(front.report, cards.length);
|
|
585
|
+
cards.push({
|
|
586
|
+
report: front.report,
|
|
587
|
+
subject: front.subject,
|
|
588
|
+
status: front.status,
|
|
589
|
+
...front.task === void 0 ? {} : { task: front.task },
|
|
590
|
+
frame: frame.index,
|
|
591
|
+
column: frame.column,
|
|
592
|
+
slot,
|
|
593
|
+
lod: opts.lod,
|
|
594
|
+
bounds: {
|
|
595
|
+
x: frame.bounds.x + opts.framePaddingX,
|
|
596
|
+
y: frame.bounds.y + opts.frameHeaderHeight + opts.framePaddingTop + slot * (size.height + opts.cardGap),
|
|
597
|
+
width: size.width,
|
|
598
|
+
height: size.height
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
const cardEnd = (index, side) => {
|
|
604
|
+
const box = cards[index]?.bounds ?? {
|
|
605
|
+
x: 0,
|
|
606
|
+
y: 0,
|
|
607
|
+
width: 0,
|
|
608
|
+
height: 0
|
|
609
|
+
};
|
|
610
|
+
return {
|
|
611
|
+
kind: "card",
|
|
612
|
+
id: cards[index]?.report,
|
|
613
|
+
index,
|
|
614
|
+
side,
|
|
615
|
+
x: side === "right" ? box.x + box.width : side === "left" ? box.x : box.x + box.width / 2,
|
|
616
|
+
y: side === "bottom" ? box.y + box.height : side === "top" ? box.y : box.y + box.height / 2
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
const frameEnd = (index, side) => {
|
|
620
|
+
const box = fixed[index] ?? {
|
|
621
|
+
x: 0,
|
|
622
|
+
y: 0,
|
|
623
|
+
width: 0,
|
|
624
|
+
height: 0
|
|
625
|
+
};
|
|
626
|
+
return {
|
|
627
|
+
kind: "frame",
|
|
628
|
+
...frames[index]?.id === void 0 ? {} : { id: frames[index]?.id },
|
|
629
|
+
index,
|
|
630
|
+
side,
|
|
631
|
+
x: side === "right" ? box.x + box.width : box.x,
|
|
632
|
+
y: box.y + box.height / 2
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
const externalBetween = (a, b) => externalIndex >= 0 && (a === externalIndex || b === externalIndex) || false;
|
|
636
|
+
const pending = [];
|
|
637
|
+
/**
|
|
638
|
+
* The side a wire should bend through to reach another frame without crossing
|
|
639
|
+
* one.
|
|
640
|
+
*
|
|
641
|
+
* Two sessions in the **same column** — siblings, or any two frames at one
|
|
642
|
+
* depth — cannot be reached left-to-right: a straight line from the sender's
|
|
643
|
+
* card to the recipient's facing edge runs back across the recipient's own box,
|
|
644
|
+
* which reads as "this wire belongs to that frame". Such a wire bends down the
|
|
645
|
+
* gutter beside the column instead: out of the card, along the gutter, into the
|
|
646
|
+
* frame's edge on that same side. The gutter is on the right except in the last
|
|
647
|
+
* column, where that would run along the outside of the drawing.
|
|
648
|
+
*/
|
|
649
|
+
const gutterSide = (column) => column < columns.length - 1 ? "right" : "left";
|
|
650
|
+
const gutterX = (column, side) => {
|
|
651
|
+
const box = columns[column]?.bounds ?? {
|
|
652
|
+
x: 0,
|
|
653
|
+
y: 0,
|
|
654
|
+
width: 0,
|
|
655
|
+
height: 0
|
|
656
|
+
};
|
|
657
|
+
return side === "right" ? box.x + box.width + opts.columnGap / 2 : box.x - opts.columnGap / 2;
|
|
658
|
+
};
|
|
659
|
+
const frontByReport = new Map(reports.map((front) => [front.report, front]));
|
|
660
|
+
for (const [cardIndex, card] of cards.entries()) {
|
|
661
|
+
const front = frontByReport.get(card.report);
|
|
662
|
+
if (front === void 0) continue;
|
|
663
|
+
const ownFrame = card.frame;
|
|
664
|
+
const ownColumn = card.column;
|
|
665
|
+
for (const [kind, ids] of [["to", front.to], ["cc", front.cc]]) for (const id of new Set(ids)) {
|
|
666
|
+
const target = frameIndexById(id);
|
|
667
|
+
if (target < 0 || target === ownFrame) continue;
|
|
668
|
+
const targetColumn = frames[target]?.column ?? 0;
|
|
669
|
+
const sameColumn = targetColumn === ownColumn;
|
|
670
|
+
const side = sameColumn ? gutterSide(ownColumn) : targetColumn > ownColumn ? "right" : "left";
|
|
671
|
+
pending.push({
|
|
672
|
+
report: front.report,
|
|
673
|
+
kind,
|
|
674
|
+
from: cardEnd(cardIndex, side),
|
|
675
|
+
to: frameEnd(target, sameColumn ? side : side === "right" ? "left" : "right"),
|
|
676
|
+
external: externalBetween(ownFrame, target),
|
|
677
|
+
...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
for (const id of new Set(front.authors)) {
|
|
681
|
+
if (id === front.from) continue;
|
|
682
|
+
const source = frameIndexById(id);
|
|
683
|
+
if (source < 0 || source === ownFrame) continue;
|
|
684
|
+
const sourceColumn = frames[source]?.column ?? 0;
|
|
685
|
+
const sameColumn = sourceColumn === ownColumn;
|
|
686
|
+
const side = sameColumn ? gutterSide(ownColumn) : sourceColumn < ownColumn ? "right" : "left";
|
|
687
|
+
pending.push({
|
|
688
|
+
report: front.report,
|
|
689
|
+
kind: "author",
|
|
690
|
+
from: frameEnd(source, side),
|
|
691
|
+
to: cardEnd(cardIndex, sameColumn ? side : side === "right" ? "left" : "right"),
|
|
692
|
+
external: externalBetween(source, ownFrame),
|
|
693
|
+
...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
const parentIndex = front.parent === void 0 || front.parent === front.report ? void 0 : cardIndexOf.get(front.parent);
|
|
697
|
+
const parentCard = parentIndex === void 0 ? void 0 : cards[parentIndex];
|
|
698
|
+
if (parentIndex !== void 0 && parentCard !== void 0) {
|
|
699
|
+
const from = cardEnd(parentIndex, "bottom");
|
|
700
|
+
const to = cardEnd(cardIndex, "top");
|
|
701
|
+
const column = parentCard.column;
|
|
702
|
+
const target = card.column;
|
|
703
|
+
const side = target > column ? "right" : target < column ? "left" : gutterSide(column);
|
|
704
|
+
pending.push({
|
|
705
|
+
report: front.report,
|
|
706
|
+
kind: "thread",
|
|
707
|
+
from,
|
|
708
|
+
to,
|
|
709
|
+
external: externalBetween(parentCard.frame, ownFrame),
|
|
710
|
+
viaX: gutterX(column, side)
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const groups = /* @__PURE__ */ new Map();
|
|
715
|
+
for (const edge of pending) for (const end of [edge.from, edge.to]) {
|
|
716
|
+
if (end.kind !== "frame") continue;
|
|
717
|
+
const other = end === edge.from ? edge.to : edge.from;
|
|
718
|
+
const key = `${end.index}|${end.side}`;
|
|
719
|
+
const list = groups.get(key) ?? [];
|
|
720
|
+
list.push({
|
|
721
|
+
end,
|
|
722
|
+
order: other.y,
|
|
723
|
+
tie: `${edge.report}|${edge.kind}`
|
|
724
|
+
});
|
|
725
|
+
groups.set(key, list);
|
|
726
|
+
}
|
|
727
|
+
for (const list of groups.values()) {
|
|
728
|
+
list.sort((a, b) => a.order - b.order || (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0));
|
|
729
|
+
const box = fixed[list[0]?.end.index ?? 0] ?? {
|
|
730
|
+
x: 0,
|
|
731
|
+
y: 0,
|
|
732
|
+
width: 0,
|
|
733
|
+
height: 0
|
|
734
|
+
};
|
|
735
|
+
const top = box.y + opts.frameHeaderHeight + opts.framePaddingTop;
|
|
736
|
+
const span = box.y + box.height - opts.framePaddingBottom - top;
|
|
737
|
+
list.forEach((entry, index) => {
|
|
738
|
+
entry.end.y = span > 0 ? top + (index + 1) * span / (list.length + 1) : box.y + box.height / 2;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const edges = pending.map((edge) => {
|
|
742
|
+
const xs = [
|
|
743
|
+
edge.from.x,
|
|
744
|
+
edge.to.x,
|
|
745
|
+
...edge.viaX === void 0 ? [] : [edge.viaX]
|
|
746
|
+
];
|
|
747
|
+
const ys = [edge.from.y, edge.to.y];
|
|
748
|
+
const minX = Math.min(...xs);
|
|
749
|
+
const minY = Math.min(...ys);
|
|
750
|
+
return {
|
|
751
|
+
report: edge.report,
|
|
752
|
+
kind: edge.kind,
|
|
753
|
+
from: { ...edge.from },
|
|
754
|
+
to: { ...edge.to },
|
|
755
|
+
external: edge.external,
|
|
756
|
+
...edge.viaX === void 0 ? {} : { viaX: edge.viaX },
|
|
757
|
+
bounds: {
|
|
758
|
+
x: minX,
|
|
759
|
+
y: minY,
|
|
760
|
+
width: Math.max(...xs) - minX,
|
|
761
|
+
height: Math.max(...ys) - minY
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
const contentWidth = columnCount === 0 ? 0 : opts.margin * 2 + columnCount * frameWidth + (columnCount - 1) * opts.columnGap;
|
|
766
|
+
const widest = heights.reduce((max, height) => Math.max(max, height), 0);
|
|
767
|
+
return {
|
|
768
|
+
lod: opts.lod,
|
|
769
|
+
columns,
|
|
770
|
+
frames: layoutFrames,
|
|
771
|
+
cards,
|
|
772
|
+
edges,
|
|
773
|
+
frameWidth,
|
|
774
|
+
cardWidth: size.width,
|
|
775
|
+
cardHeight: size.height,
|
|
776
|
+
width: columnCount === 0 ? 0 : contentWidth,
|
|
777
|
+
height: columnCount === 0 ? 0 : opts.margin * 2 + widest
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region src/client/cardgraph-painter.ts
|
|
782
|
+
/** A guaranteed-visible theme, for a context with no tokens at all. */
|
|
783
|
+
const FALLBACK_THEME = {
|
|
784
|
+
canvas: "#ffffff",
|
|
785
|
+
frame: "#f6f7f9",
|
|
786
|
+
frameBar: "#eef0f3",
|
|
787
|
+
surface: "#ffffff",
|
|
788
|
+
border: "#d8dbe0",
|
|
789
|
+
frameBorder: "#c3c7ce",
|
|
790
|
+
text: "#1a1c1f",
|
|
791
|
+
secondary: "#4a4f57",
|
|
792
|
+
caption: "#70757d",
|
|
793
|
+
tertiary: "#9aa0a8",
|
|
794
|
+
accent: "#2f6fed",
|
|
795
|
+
warning: "#c77700",
|
|
796
|
+
success: "#1f9254",
|
|
797
|
+
error: "#d33a2c",
|
|
798
|
+
business: "#2f6fed",
|
|
799
|
+
fonts: {
|
|
800
|
+
title: {
|
|
801
|
+
size: 13,
|
|
802
|
+
weight: 500,
|
|
803
|
+
lineHeight: 18,
|
|
804
|
+
family: "system-ui, sans-serif"
|
|
805
|
+
},
|
|
806
|
+
id: {
|
|
807
|
+
size: 12,
|
|
808
|
+
weight: 600,
|
|
809
|
+
lineHeight: 16,
|
|
810
|
+
family: "system-ui, sans-serif"
|
|
811
|
+
},
|
|
812
|
+
body: {
|
|
813
|
+
size: 11,
|
|
814
|
+
weight: 400,
|
|
815
|
+
lineHeight: 15,
|
|
816
|
+
family: "system-ui, sans-serif"
|
|
817
|
+
},
|
|
818
|
+
meta: {
|
|
819
|
+
size: 11,
|
|
820
|
+
weight: 500,
|
|
821
|
+
lineHeight: 15,
|
|
822
|
+
family: "system-ui, sans-serif"
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
/** Token name → the field it fills. Keeping it as data means one reader, not twenty. */
|
|
827
|
+
const TOKENS = [
|
|
828
|
+
[
|
|
829
|
+
"canvas",
|
|
830
|
+
"--dsw-alias-bg-base",
|
|
831
|
+
FALLBACK_THEME.canvas
|
|
832
|
+
],
|
|
833
|
+
[
|
|
834
|
+
"frame",
|
|
835
|
+
"--dsw-alias-bg-layer-1",
|
|
836
|
+
FALLBACK_THEME.frame
|
|
837
|
+
],
|
|
838
|
+
[
|
|
839
|
+
"frameBar",
|
|
840
|
+
"--dsw-alias-bg-layer-2",
|
|
841
|
+
FALLBACK_THEME.frameBar
|
|
842
|
+
],
|
|
843
|
+
[
|
|
844
|
+
"surface",
|
|
845
|
+
"--dsw-alias-bg-layer-3",
|
|
846
|
+
FALLBACK_THEME.surface
|
|
847
|
+
],
|
|
848
|
+
[
|
|
849
|
+
"border",
|
|
850
|
+
"--dsw-alias-border-l1",
|
|
851
|
+
FALLBACK_THEME.border
|
|
852
|
+
],
|
|
853
|
+
[
|
|
854
|
+
"frameBorder",
|
|
855
|
+
"--dsw-alias-border-l2",
|
|
856
|
+
FALLBACK_THEME.frameBorder
|
|
857
|
+
],
|
|
858
|
+
[
|
|
859
|
+
"text",
|
|
860
|
+
"--dsw-alias-label-primary",
|
|
861
|
+
FALLBACK_THEME.text
|
|
862
|
+
],
|
|
863
|
+
[
|
|
864
|
+
"secondary",
|
|
865
|
+
"--dsw-alias-label-secondary",
|
|
866
|
+
FALLBACK_THEME.secondary
|
|
867
|
+
],
|
|
868
|
+
[
|
|
869
|
+
"caption",
|
|
870
|
+
"--dsw-alias-label-caption",
|
|
871
|
+
FALLBACK_THEME.caption
|
|
872
|
+
],
|
|
873
|
+
[
|
|
874
|
+
"tertiary",
|
|
875
|
+
"--dsw-alias-label-tertiary",
|
|
876
|
+
FALLBACK_THEME.tertiary
|
|
877
|
+
],
|
|
878
|
+
[
|
|
879
|
+
"accent",
|
|
880
|
+
"--dsw-alias-brand-primary",
|
|
881
|
+
FALLBACK_THEME.accent
|
|
882
|
+
],
|
|
883
|
+
[
|
|
884
|
+
"warning",
|
|
885
|
+
"--dsw-alias-state-warn-primary",
|
|
886
|
+
FALLBACK_THEME.warning
|
|
887
|
+
],
|
|
888
|
+
[
|
|
889
|
+
"success",
|
|
890
|
+
"--dsw-alias-state-success-primary",
|
|
891
|
+
FALLBACK_THEME.success
|
|
892
|
+
],
|
|
893
|
+
[
|
|
894
|
+
"error",
|
|
895
|
+
"--dsw-alias-state-error-primary",
|
|
896
|
+
FALLBACK_THEME.error
|
|
897
|
+
],
|
|
898
|
+
[
|
|
899
|
+
"business",
|
|
900
|
+
"--dsw-alias-state-business-primary",
|
|
901
|
+
FALLBACK_THEME.business
|
|
902
|
+
]
|
|
903
|
+
];
|
|
904
|
+
/** Font token → the field it fills. Each token carries its own size, weight and family. */
|
|
905
|
+
const FONT_TOKENS = [
|
|
906
|
+
[
|
|
907
|
+
"title",
|
|
908
|
+
"--dsw-font-xs-13",
|
|
909
|
+
FALLBACK_THEME.fonts.title
|
|
910
|
+
],
|
|
911
|
+
[
|
|
912
|
+
"id",
|
|
913
|
+
"--dsw-font-xxs-strong-12",
|
|
914
|
+
FALLBACK_THEME.fonts.id
|
|
915
|
+
],
|
|
916
|
+
[
|
|
917
|
+
"body",
|
|
918
|
+
"--dsw-font-xxxs-11",
|
|
919
|
+
FALLBACK_THEME.fonts.body
|
|
920
|
+
],
|
|
921
|
+
[
|
|
922
|
+
"meta",
|
|
923
|
+
"--dsw-font-xxxs-strong-11",
|
|
924
|
+
FALLBACK_THEME.fonts.meta
|
|
925
|
+
]
|
|
926
|
+
];
|
|
927
|
+
/** The one token a theme change always moves, so a repaint can detect it cheaply. */
|
|
928
|
+
const CANARY = "--dsw-alias-bg-base";
|
|
929
|
+
/** Assemble a canvas font string. */
|
|
930
|
+
function fontString(font) {
|
|
931
|
+
return `${font.weight} ${font.size}px ${font.family}`;
|
|
932
|
+
}
|
|
933
|
+
/** Read one font token, taking its size/weight/line-height/family parts. */
|
|
934
|
+
function readFont(read, token, fallback) {
|
|
935
|
+
const size = Number.parseFloat(read(`${token}-font-size`));
|
|
936
|
+
const weight = Number.parseFloat(read(`${token}-font-weight`));
|
|
937
|
+
const lineHeight = Number.parseFloat(read(`${token}-line-height`));
|
|
938
|
+
const family = read(`${token}-font-family`);
|
|
939
|
+
return {
|
|
940
|
+
size: Number.isFinite(size) && size > 0 ? size : fallback.size,
|
|
941
|
+
weight: Number.isFinite(weight) && weight > 0 ? weight : fallback.weight,
|
|
942
|
+
lineHeight: Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : fallback.lineHeight,
|
|
943
|
+
family: family === "" ? fallback.family : family
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Read the drawing's colours and fonts out of the live theme.
|
|
948
|
+
* @param read - a token reader, normally `getComputedStyle(root).getPropertyValue`.
|
|
949
|
+
* @returns the theme; every field falls back so a missing token cannot blank the drawing.
|
|
950
|
+
*/
|
|
951
|
+
function readTheme(read) {
|
|
952
|
+
const pick = (token, fallback) => {
|
|
953
|
+
const value = read(token).trim();
|
|
954
|
+
return value === "" ? fallback : value;
|
|
955
|
+
};
|
|
956
|
+
const colours = {};
|
|
957
|
+
for (const [field, token, fallback] of TOKENS) colours[field] = pick(token, fallback);
|
|
958
|
+
const fonts = {};
|
|
959
|
+
for (const [field, token, fallback] of FONT_TOKENS) fonts[field] = readFont(read, token, fallback);
|
|
960
|
+
return {
|
|
961
|
+
...FALLBACK_THEME,
|
|
962
|
+
...colours,
|
|
963
|
+
fonts: {
|
|
964
|
+
...FALLBACK_THEME.fonts,
|
|
965
|
+
...fonts
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Watch the theme through one token.
|
|
971
|
+
*
|
|
972
|
+
* `getComputedStyle(...).getPropertyValue` forces a style read, so doing twenty of
|
|
973
|
+
* them per repaint would be paying for the theme on every hover. The canary is the
|
|
974
|
+
* page background: if it did not move, nothing else did, and the cached theme is
|
|
975
|
+
* returned.
|
|
976
|
+
* @param root - the element carrying the tokens, normally `document.documentElement`.
|
|
977
|
+
* @param win - the window to read styles from.
|
|
978
|
+
* @returns the reader.
|
|
979
|
+
*/
|
|
980
|
+
function createThemeReader(root, win) {
|
|
981
|
+
const style = win.getComputedStyle(root);
|
|
982
|
+
const read = (name) => style.getPropertyValue(name);
|
|
983
|
+
let canary;
|
|
984
|
+
let cached;
|
|
985
|
+
return { read() {
|
|
986
|
+
const current = read(CANARY);
|
|
987
|
+
if (cached === void 0 || current !== canary) {
|
|
988
|
+
canary = current;
|
|
989
|
+
cached = readTheme(read);
|
|
990
|
+
}
|
|
991
|
+
return cached;
|
|
992
|
+
} };
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Shorten text until it fits, ending it with an ellipsis.
|
|
996
|
+
* @param text - the text to fit.
|
|
997
|
+
* @param maxWidth - the room available.
|
|
998
|
+
* @param measure - width of a candidate string.
|
|
999
|
+
* @returns text that fits, or `'…'` when not even one character does.
|
|
1000
|
+
*/
|
|
1001
|
+
function elide(text, maxWidth, measure) {
|
|
1002
|
+
if (measure(text) <= maxWidth) return text;
|
|
1003
|
+
for (let take = text.length - 1; take > 0; take--) {
|
|
1004
|
+
const candidate = `${text.slice(0, take).trimEnd()}…`;
|
|
1005
|
+
if (measure(candidate) <= maxWidth) return candidate;
|
|
1006
|
+
}
|
|
1007
|
+
return "…";
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Wrap text to a width, eliding whatever does not fit in the last line.
|
|
1011
|
+
*
|
|
1012
|
+
* Character-by-character, with one concession to Latin text: when a line has to
|
|
1013
|
+
* break and there is a space far enough back, break at the space instead of mid
|
|
1014
|
+
* word. Chinese has no spaces, so a word-based rule would leave a long subject on
|
|
1015
|
+
* one line and elide most of it; this rule wraps it, and still avoids breaking
|
|
1016
|
+
* "report-ledger" in half when it can.
|
|
1017
|
+
* @param text - the text to lay out.
|
|
1018
|
+
* @param maxWidth - the room available.
|
|
1019
|
+
* @param measure - width of a candidate string.
|
|
1020
|
+
* @param maxLines - how many lines to produce.
|
|
1021
|
+
* @returns the lines, possibly empty.
|
|
1022
|
+
*/
|
|
1023
|
+
function foldText(text, maxWidth, measure, maxLines = 1) {
|
|
1024
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
1025
|
+
if (clean === "" || maxWidth <= 0 || maxLines < 1) return [];
|
|
1026
|
+
const lines = [];
|
|
1027
|
+
let rest = clean;
|
|
1028
|
+
while (rest !== "" && lines.length < maxLines) {
|
|
1029
|
+
if (lines.length === maxLines - 1) {
|
|
1030
|
+
lines.push(elide(rest, maxWidth, measure));
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
if (measure(rest) <= maxWidth) {
|
|
1034
|
+
lines.push(rest);
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
let cut = 0;
|
|
1038
|
+
for (let take = 1; take <= rest.length; take++) {
|
|
1039
|
+
if (measure(rest.slice(0, take)) > maxWidth) break;
|
|
1040
|
+
cut = take;
|
|
1041
|
+
}
|
|
1042
|
+
if (cut === 0) {
|
|
1043
|
+
lines.push(elide(rest, maxWidth, measure));
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
const space = rest.lastIndexOf(" ", cut);
|
|
1047
|
+
if (space > cut / 3) cut = space;
|
|
1048
|
+
lines.push(rest.slice(0, cut).trimEnd());
|
|
1049
|
+
rest = rest.slice(cut).trimStart();
|
|
1050
|
+
}
|
|
1051
|
+
return lines;
|
|
1052
|
+
}
|
|
1053
|
+
/** Corner radius of a card. */
|
|
1054
|
+
const CARD_RADIUS = 5;
|
|
1055
|
+
/** Corner radius of a frame. */
|
|
1056
|
+
const FRAME_RADIUS = 7;
|
|
1057
|
+
/** Inset from a card's edge to its text. */
|
|
1058
|
+
const CARD_PADDING = 9;
|
|
1059
|
+
/** Width of the status stripe down a card's left edge. */
|
|
1060
|
+
const STRIPE = 3;
|
|
1061
|
+
/** Arrowhead length and half-width. */
|
|
1062
|
+
const ARROW_LENGTH = 7;
|
|
1063
|
+
const ARROW_HALF = 3.5;
|
|
1064
|
+
/**
|
|
1065
|
+
* How far the scrim pushes everything but the focused path back.
|
|
1066
|
+
*
|
|
1067
|
+
* **Measured, then corrected.** The first version reused the swimlane's dim value
|
|
1068
|
+
* (0.18 left showing) and a screenshot of the real tab showed why that was wrong
|
|
1069
|
+
* there and wrong here: at 0.18 the frames, their titles and every other card
|
|
1070
|
+
* become unreadable, so tracing one path costs the reader the context the drawing
|
|
1071
|
+
* exists to provide. The swimlane had already learned the same thing from the other
|
|
1072
|
+
* side — *the relations recede, the participants stay* — and a canvas scrim is
|
|
1073
|
+
* blunter than a per-shape dim, so it has to be gentler: 0.45 showing keeps every
|
|
1074
|
+
* frame and card legible while the focused path, drawn back on top at full
|
|
1075
|
+
* strength, still reads as the thing being pointed at.
|
|
1076
|
+
*/
|
|
1077
|
+
const SCRIM_ALPHA = .55;
|
|
1078
|
+
/** Subject lines a full card has room for. */
|
|
1079
|
+
const SUBJECT_LINES = 2;
|
|
1080
|
+
/** The colour a status is drawn in, matching the tab's tones. */
|
|
1081
|
+
function statusColour(status, theme) {
|
|
1082
|
+
if (status === "open") return theme.warning;
|
|
1083
|
+
if (status === "acked") return theme.success;
|
|
1084
|
+
return theme.tertiary;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* How each kind of wire is drawn: the token its colour comes from, its dash
|
|
1088
|
+
* pattern, and its weight.
|
|
1089
|
+
*
|
|
1090
|
+
* Exported because the wires are canvas and the legend is DOM — the only way the
|
|
1091
|
+
* two cannot drift is for the legend to read this table instead of restating it in
|
|
1092
|
+
* CSS. The colour is a **token name**, not a value, so it resolves through the
|
|
1093
|
+
* theme like everything else.
|
|
1094
|
+
*/
|
|
1095
|
+
const WIRE_TOKENS = {
|
|
1096
|
+
to: {
|
|
1097
|
+
colour: "--dsw-alias-label-secondary",
|
|
1098
|
+
dash: [],
|
|
1099
|
+
width: 1.4
|
|
1100
|
+
},
|
|
1101
|
+
cc: {
|
|
1102
|
+
colour: "--dsw-alias-label-caption",
|
|
1103
|
+
dash: [2, 3],
|
|
1104
|
+
width: 1
|
|
1105
|
+
},
|
|
1106
|
+
author: {
|
|
1107
|
+
colour: "--dsw-alias-label-caption",
|
|
1108
|
+
dash: [1, 3],
|
|
1109
|
+
width: 1
|
|
1110
|
+
},
|
|
1111
|
+
thread: {
|
|
1112
|
+
colour: "--dsw-alias-state-business-primary",
|
|
1113
|
+
dash: [],
|
|
1114
|
+
width: 1.4
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
/** Reverse lookup: which theme field a token fills. */
|
|
1118
|
+
const FIELD_OF_TOKEN = new Map(TOKENS.map(([field, token]) => [token, field]));
|
|
1119
|
+
/** How one kind of wire is drawn, resolved against a theme. */
|
|
1120
|
+
function wireStyle(kind, theme) {
|
|
1121
|
+
const spec = WIRE_TOKENS[kind];
|
|
1122
|
+
const field = FIELD_OF_TOKEN.get(spec.colour);
|
|
1123
|
+
return {
|
|
1124
|
+
stroke: field === void 0 ? theme.secondary : theme[field],
|
|
1125
|
+
dash: spec.dash,
|
|
1126
|
+
width: spec.width
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* The polyline a wire is drawn as.
|
|
1131
|
+
*
|
|
1132
|
+
* A wire with a gutter bends through it in three segments — out, along, in — which
|
|
1133
|
+
* is what keeps a same-column wire from crossing the frame it lands on and a
|
|
1134
|
+
* thread from crossing the cards between its ends.
|
|
1135
|
+
* @param edge - the wire.
|
|
1136
|
+
* @returns between two and four points, first to last.
|
|
1137
|
+
*/
|
|
1138
|
+
function wirePoints(edge) {
|
|
1139
|
+
const from = {
|
|
1140
|
+
x: edge.from.x,
|
|
1141
|
+
y: edge.from.y
|
|
1142
|
+
};
|
|
1143
|
+
const to = {
|
|
1144
|
+
x: edge.to.x,
|
|
1145
|
+
y: edge.to.y
|
|
1146
|
+
};
|
|
1147
|
+
if (edge.viaX === void 0) return [from, to];
|
|
1148
|
+
return [
|
|
1149
|
+
from,
|
|
1150
|
+
{
|
|
1151
|
+
x: edge.viaX,
|
|
1152
|
+
y: from.y
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
x: edge.viaX,
|
|
1156
|
+
y: to.y
|
|
1157
|
+
},
|
|
1158
|
+
to
|
|
1159
|
+
];
|
|
1160
|
+
}
|
|
1161
|
+
/** Trace a rounded rectangle into the current path. */
|
|
1162
|
+
function pathRoundRect(ctx, rect, radius) {
|
|
1163
|
+
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
1164
|
+
ctx.beginPath();
|
|
1165
|
+
ctx.moveTo(rect.x + r, rect.y);
|
|
1166
|
+
ctx.lineTo(rect.x + rect.width - r, rect.y);
|
|
1167
|
+
ctx.arcTo(rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + r, r);
|
|
1168
|
+
ctx.lineTo(rect.x + rect.width, rect.y + rect.height - r);
|
|
1169
|
+
ctx.arcTo(rect.x + rect.width, rect.y + rect.height, rect.x + rect.width - r, rect.y + rect.height, r);
|
|
1170
|
+
ctx.lineTo(rect.x + r, rect.y + rect.height);
|
|
1171
|
+
ctx.arcTo(rect.x, rect.y + rect.height, rect.x, rect.y + rect.height - r, r);
|
|
1172
|
+
ctx.lineTo(rect.x, rect.y + r);
|
|
1173
|
+
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
|
1174
|
+
ctx.closePath();
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Build a painter that draws into a canvas.
|
|
1178
|
+
*
|
|
1179
|
+
* One transform carries pan and zoom; everything after that is drawn in the
|
|
1180
|
+
* layout's own coordinates. Coordinates are deliberately **not** snapped to
|
|
1181
|
+
* pixels: draw.io rounds after its transform because its canvas is 1:1 screen
|
|
1182
|
+
* space, while ours is scaled, so snapping would have to happen per point in
|
|
1183
|
+
* screen space. Antialiasing is the honest default until a measurement says the
|
|
1184
|
+
* blur costs readability.
|
|
1185
|
+
* @param canvas - the canvas to draw into.
|
|
1186
|
+
* @returns the painter.
|
|
1187
|
+
*/
|
|
1188
|
+
function createCanvasPainter(canvas) {
|
|
1189
|
+
const ctx = canvas.getContext("2d");
|
|
1190
|
+
if (ctx === null) throw new Error("card canvas: no 2d context");
|
|
1191
|
+
let width = 0;
|
|
1192
|
+
let height = 0;
|
|
1193
|
+
let dpr = 1;
|
|
1194
|
+
/** Measured text widths, keyed by font and text: measuring is a layout call. */
|
|
1195
|
+
const widths = /* @__PURE__ */ new Map();
|
|
1196
|
+
const measure = (text) => {
|
|
1197
|
+
const key = `${ctx.font}\u0000${text}`;
|
|
1198
|
+
const hit = widths.get(key);
|
|
1199
|
+
if (hit !== void 0) return hit;
|
|
1200
|
+
const value = ctx.measureText(text).width;
|
|
1201
|
+
widths.set(key, value);
|
|
1202
|
+
return value;
|
|
1203
|
+
};
|
|
1204
|
+
const line = (x1, y1, x2, y2, colour, dash, alpha) => {
|
|
1205
|
+
ctx.globalAlpha = alpha;
|
|
1206
|
+
ctx.strokeStyle = colour;
|
|
1207
|
+
ctx.setLineDash(dash);
|
|
1208
|
+
ctx.beginPath();
|
|
1209
|
+
ctx.moveTo(x1, y1);
|
|
1210
|
+
ctx.lineTo(x2, y2);
|
|
1211
|
+
ctx.stroke();
|
|
1212
|
+
};
|
|
1213
|
+
/** The frame's outline alone, for the interaction layer: the box is already drawn. */
|
|
1214
|
+
const drawFrameOutline = (frame, scene) => {
|
|
1215
|
+
ctx.globalAlpha = 1;
|
|
1216
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
1217
|
+
ctx.strokeStyle = scene.theme.accent;
|
|
1218
|
+
ctx.lineWidth = 1.6;
|
|
1219
|
+
ctx.stroke();
|
|
1220
|
+
};
|
|
1221
|
+
const drawFrame = (frame, scene) => {
|
|
1222
|
+
const { theme } = scene;
|
|
1223
|
+
ctx.globalAlpha = 1;
|
|
1224
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
1225
|
+
ctx.fillStyle = theme.frame;
|
|
1226
|
+
ctx.fill();
|
|
1227
|
+
ctx.strokeStyle = theme.frameBorder;
|
|
1228
|
+
ctx.lineWidth = 1;
|
|
1229
|
+
ctx.stroke();
|
|
1230
|
+
const bar = {
|
|
1231
|
+
x: frame.bounds.x,
|
|
1232
|
+
y: frame.bounds.y,
|
|
1233
|
+
width: frame.bounds.width,
|
|
1234
|
+
height: 26
|
|
1235
|
+
};
|
|
1236
|
+
ctx.save();
|
|
1237
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
1238
|
+
ctx.clip();
|
|
1239
|
+
ctx.globalAlpha = 1;
|
|
1240
|
+
ctx.fillStyle = theme.frameBar;
|
|
1241
|
+
ctx.fillRect(bar.x, bar.y, bar.width, bar.height);
|
|
1242
|
+
ctx.restore();
|
|
1243
|
+
line(bar.x, bar.y + bar.height, bar.x + bar.width, bar.y + bar.height, theme.frameBorder, [], 1);
|
|
1244
|
+
ctx.font = fontString(theme.fonts.title);
|
|
1245
|
+
ctx.textAlign = "left";
|
|
1246
|
+
ctx.textBaseline = "middle";
|
|
1247
|
+
ctx.fillStyle = theme.secondary;
|
|
1248
|
+
const label = frame.external ? scene.labels.external : frame.title ?? frame.shortId;
|
|
1249
|
+
ctx.fillText(elide(label, frame.bounds.width - 24 - 10, measure), bar.x + 10, bar.y + bar.height / 2 + .5);
|
|
1250
|
+
const cx = bar.x + bar.width - 13;
|
|
1251
|
+
const cy = bar.y + bar.height / 2;
|
|
1252
|
+
ctx.beginPath();
|
|
1253
|
+
ctx.arc(cx, cy, 3.5, 0, Math.PI * 2);
|
|
1254
|
+
if (frame.live) {
|
|
1255
|
+
ctx.fillStyle = theme.success;
|
|
1256
|
+
ctx.fill();
|
|
1257
|
+
} else {
|
|
1258
|
+
ctx.strokeStyle = theme.tertiary;
|
|
1259
|
+
ctx.lineWidth = 1;
|
|
1260
|
+
ctx.stroke();
|
|
1261
|
+
}
|
|
1262
|
+
if (frame.cards === 0) {
|
|
1263
|
+
ctx.font = fontString(theme.fonts.body);
|
|
1264
|
+
ctx.fillStyle = theme.tertiary;
|
|
1265
|
+
ctx.fillText(scene.labels.empty, frame.bounds.x + CARD_PADDING, bar.y + bar.height + 18);
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
const drawWire = (edge, scene, alpha) => {
|
|
1269
|
+
const { theme } = scene;
|
|
1270
|
+
const style = wireStyle(edge.kind, theme);
|
|
1271
|
+
const points = wirePoints(edge);
|
|
1272
|
+
ctx.globalAlpha = alpha;
|
|
1273
|
+
ctx.strokeStyle = style.stroke;
|
|
1274
|
+
ctx.lineWidth = style.width;
|
|
1275
|
+
ctx.setLineDash(style.dash);
|
|
1276
|
+
ctx.beginPath();
|
|
1277
|
+
points.forEach((point, index) => {
|
|
1278
|
+
if (index === 0) ctx.moveTo(point.x, point.y);
|
|
1279
|
+
else ctx.lineTo(point.x, point.y);
|
|
1280
|
+
});
|
|
1281
|
+
ctx.stroke();
|
|
1282
|
+
const last = points[points.length - 2];
|
|
1283
|
+
const end = points[points.length - 1];
|
|
1284
|
+
if (last === void 0 || end === void 0) return;
|
|
1285
|
+
const angle = Math.atan2(end.y - last.y, end.x - last.x);
|
|
1286
|
+
const back = {
|
|
1287
|
+
x: end.x - Math.cos(angle) * ARROW_LENGTH,
|
|
1288
|
+
y: end.y - Math.sin(angle) * ARROW_LENGTH
|
|
1289
|
+
};
|
|
1290
|
+
const side = {
|
|
1291
|
+
x: -Math.sin(angle) * ARROW_HALF,
|
|
1292
|
+
y: Math.cos(angle) * ARROW_HALF
|
|
1293
|
+
};
|
|
1294
|
+
ctx.setLineDash([]);
|
|
1295
|
+
ctx.fillStyle = style.stroke;
|
|
1296
|
+
ctx.beginPath();
|
|
1297
|
+
ctx.moveTo(end.x, end.y);
|
|
1298
|
+
ctx.lineTo(back.x + side.x, back.y + side.y);
|
|
1299
|
+
ctx.lineTo(back.x - side.x, back.y - side.y);
|
|
1300
|
+
ctx.closePath();
|
|
1301
|
+
ctx.fill();
|
|
1302
|
+
};
|
|
1303
|
+
const drawCard = (card, scene, accent) => {
|
|
1304
|
+
const { theme } = scene;
|
|
1305
|
+
const box = card.bounds;
|
|
1306
|
+
ctx.globalAlpha = 1;
|
|
1307
|
+
pathRoundRect(ctx, box, CARD_RADIUS);
|
|
1308
|
+
ctx.fillStyle = card.lod === "chip" ? statusColour(card.status, theme) : theme.surface;
|
|
1309
|
+
ctx.fill();
|
|
1310
|
+
if (card.lod === "chip") return;
|
|
1311
|
+
ctx.strokeStyle = accent ? theme.accent : theme.border;
|
|
1312
|
+
ctx.lineWidth = accent ? 1.6 : 1;
|
|
1313
|
+
ctx.stroke();
|
|
1314
|
+
ctx.save();
|
|
1315
|
+
pathRoundRect(ctx, box, CARD_RADIUS);
|
|
1316
|
+
ctx.clip();
|
|
1317
|
+
ctx.fillStyle = statusColour(card.status, theme);
|
|
1318
|
+
ctx.fillRect(box.x, box.y, STRIPE, box.height);
|
|
1319
|
+
ctx.restore();
|
|
1320
|
+
const textLeft = box.x + CARD_PADDING + STRIPE;
|
|
1321
|
+
const textWidth = box.width - CARD_PADDING * 2 - STRIPE;
|
|
1322
|
+
ctx.textAlign = "left";
|
|
1323
|
+
ctx.textBaseline = "top";
|
|
1324
|
+
ctx.font = fontString(theme.fonts.id);
|
|
1325
|
+
ctx.fillStyle = accent ? theme.accent : theme.secondary;
|
|
1326
|
+
if (card.lod === "compact") {
|
|
1327
|
+
const id = card.report;
|
|
1328
|
+
const idWidth = measure(id);
|
|
1329
|
+
const rest = foldText(`· ${card.subject}`, textWidth - idWidth - 4, measure, 1)[0] ?? "";
|
|
1330
|
+
ctx.fillText(id, textLeft, box.y + box.height / 2 - theme.fonts.id.size / 2 - 1);
|
|
1331
|
+
ctx.font = fontString(theme.fonts.body);
|
|
1332
|
+
ctx.fillStyle = theme.caption;
|
|
1333
|
+
ctx.fillText(rest, textLeft + idWidth + 4, box.y + box.height / 2 - theme.fonts.body.size / 2 - 1);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
ctx.fillText(card.report, textLeft, box.y + 8);
|
|
1337
|
+
ctx.font = fontString(theme.fonts.body);
|
|
1338
|
+
ctx.fillStyle = theme.text;
|
|
1339
|
+
foldText(card.subject, textWidth, measure, SUBJECT_LINES).forEach((text, index) => {
|
|
1340
|
+
ctx.fillText(text, textLeft, box.y + 28 + index * theme.fonts.body.lineHeight);
|
|
1341
|
+
});
|
|
1342
|
+
ctx.font = fontString(theme.fonts.meta);
|
|
1343
|
+
const baseline = box.y + box.height - theme.fonts.meta.lineHeight - 6;
|
|
1344
|
+
ctx.fillStyle = statusColour(card.status, theme);
|
|
1345
|
+
const status = scene.labels.status[card.status] ?? card.status;
|
|
1346
|
+
ctx.fillText(status, textLeft, baseline);
|
|
1347
|
+
if (card.task !== void 0) {
|
|
1348
|
+
const chipLeft = textLeft + measure(status) + 8;
|
|
1349
|
+
const chipText = foldText(card.task, box.x + box.width - CARD_PADDING - chipLeft - 10, measure, 1)[0] ?? "";
|
|
1350
|
+
if (chipText !== "") {
|
|
1351
|
+
ctx.fillStyle = theme.frameBar;
|
|
1352
|
+
const chipWidth = measure(chipText) + 10;
|
|
1353
|
+
ctx.fillRect(chipLeft, baseline - 2, chipWidth, theme.fonts.meta.lineHeight + 2);
|
|
1354
|
+
ctx.fillStyle = theme.caption;
|
|
1355
|
+
ctx.fillText(chipText, chipLeft + 5, baseline);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
return {
|
|
1360
|
+
resize(w, h, ratio) {
|
|
1361
|
+
width = Math.max(0, w);
|
|
1362
|
+
height = Math.max(0, h);
|
|
1363
|
+
dpr = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
1364
|
+
canvas.width = Math.max(1, Math.round(width * dpr));
|
|
1365
|
+
canvas.height = Math.max(1, Math.round(height * dpr));
|
|
1366
|
+
canvas.style.width = `${width}px`;
|
|
1367
|
+
canvas.style.height = `${height}px`;
|
|
1368
|
+
},
|
|
1369
|
+
reset() {
|
|
1370
|
+
widths.clear();
|
|
1371
|
+
},
|
|
1372
|
+
paint(scene) {
|
|
1373
|
+
const { layout, theme, viewport } = scene;
|
|
1374
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1375
|
+
ctx.globalAlpha = 1;
|
|
1376
|
+
ctx.setLineDash([]);
|
|
1377
|
+
ctx.clearRect(0, 0, width, height);
|
|
1378
|
+
ctx.fillStyle = theme.canvas;
|
|
1379
|
+
ctx.fillRect(0, 0, width, height);
|
|
1380
|
+
const world = worldViewport(viewport, scene.overscan ?? 0);
|
|
1381
|
+
const visible = (rect) => intersects(rect, world);
|
|
1382
|
+
ctx.save();
|
|
1383
|
+
ctx.translate(viewport.offsetX, viewport.offsetY);
|
|
1384
|
+
ctx.scale(viewport.scale, viewport.scale);
|
|
1385
|
+
for (const frame of layout.frames) if (visible(frame.bounds)) drawFrame(frame, scene);
|
|
1386
|
+
for (const edge of layout.edges) if (visible(edge.bounds)) drawWire(edge, scene, 1);
|
|
1387
|
+
for (const card of layout.cards) {
|
|
1388
|
+
if (!visible(card.bounds)) continue;
|
|
1389
|
+
drawCard(card, scene, card.report === scene.focus?.selected);
|
|
1390
|
+
}
|
|
1391
|
+
ctx.globalAlpha = 1;
|
|
1392
|
+
ctx.restore();
|
|
1393
|
+
},
|
|
1394
|
+
paintPath(scene) {
|
|
1395
|
+
const { layout, theme, viewport } = scene;
|
|
1396
|
+
const report = scene.focus?.report ?? scene.focus?.selected;
|
|
1397
|
+
const frameIndex = scene.focus?.frame;
|
|
1398
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1399
|
+
ctx.globalAlpha = 1;
|
|
1400
|
+
ctx.setLineDash([]);
|
|
1401
|
+
ctx.clearRect(0, 0, width, height);
|
|
1402
|
+
if (report === void 0 && frameIndex === void 0) return;
|
|
1403
|
+
ctx.save();
|
|
1404
|
+
ctx.translate(viewport.offsetX, viewport.offsetY);
|
|
1405
|
+
ctx.scale(viewport.scale, viewport.scale);
|
|
1406
|
+
if (report === void 0) {
|
|
1407
|
+
const frame = layout.frames[frameIndex];
|
|
1408
|
+
if (frame !== void 0) drawFrameOutline(frame, scene);
|
|
1409
|
+
ctx.restore();
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const world = worldViewport(viewport, 0);
|
|
1413
|
+
ctx.globalAlpha = SCRIM_ALPHA;
|
|
1414
|
+
ctx.fillStyle = theme.canvas;
|
|
1415
|
+
ctx.fillRect(world.x, world.y, world.width, world.height);
|
|
1416
|
+
ctx.globalAlpha = 1;
|
|
1417
|
+
const edges = layout.edges.filter((edge) => edge.report === report);
|
|
1418
|
+
const touched = /* @__PURE__ */ new Set();
|
|
1419
|
+
for (const edge of edges) {
|
|
1420
|
+
if (edge.from.kind === "frame") touched.add(edge.from.index);
|
|
1421
|
+
if (edge.to.kind === "frame") touched.add(edge.to.index);
|
|
1422
|
+
}
|
|
1423
|
+
for (const frame of layout.frames) if (touched.has(frame.index)) drawFrameOutline(frame, scene);
|
|
1424
|
+
for (const edge of edges) if (intersects(edge.bounds, world)) drawWire(edge, scene, 1);
|
|
1425
|
+
for (const card of layout.cards) if (card.report === report) drawCard(card, scene, true);
|
|
1426
|
+
ctx.restore();
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
//#endregion
|
|
1431
|
+
//#region src/client/locales.ts
|
|
1432
|
+
/** Chinese dictionary — the source of truth for the key set. */
|
|
1433
|
+
const zh = {
|
|
1434
|
+
"view.tab": "汇报",
|
|
1435
|
+
"view.loading": "正在读取账本…",
|
|
1436
|
+
"view.summary": "{sessions} 个会话 · {reports} 份汇报",
|
|
1437
|
+
"view.empty": "这个会话树下还没有汇报。代理用 report_author 开一份汇报后,这里会出现它的缩略卡。",
|
|
1438
|
+
"view.refresh": "刷新",
|
|
1439
|
+
"view.retry": "重试",
|
|
1440
|
+
"view.error": "读取失败:{code}",
|
|
1441
|
+
"view.root": "本会话",
|
|
1442
|
+
"view.live": "在线",
|
|
1443
|
+
"view.delegated": "子代理",
|
|
1444
|
+
"view.detail.loading": "正在读取传递路径…",
|
|
1445
|
+
"view.detail.route": "传递路径",
|
|
1446
|
+
"view.detail.routeEmpty": "(还没有记录跳)",
|
|
1447
|
+
"view.detail.pending": "仍在等待送达:{targets}",
|
|
1448
|
+
"view.detail.body": "正文",
|
|
1449
|
+
"view.detail.path": "账本文件",
|
|
1450
|
+
"view.copyPath": "复制账本路径",
|
|
1451
|
+
"row.hops": "{count} 跳",
|
|
1452
|
+
"row.authors": "{count} 位共写",
|
|
1453
|
+
"row.filterByTask": "只看任务「{task}」",
|
|
1454
|
+
"row.filterByWorktree": "只看检出「{worktree}」",
|
|
1455
|
+
"row.leaseHeld": "工作区:正在被这份汇报占用",
|
|
1456
|
+
"row.leaseReleased": "工作区:占用已结束,检出仍留在磁盘上",
|
|
1457
|
+
"row.leaseReclaimed": "工作区:已被账本回收清除",
|
|
1458
|
+
"row.leaseNone": "工作区:绑定已记录,但从未被占用",
|
|
1459
|
+
"row.expand": "展开 {report}",
|
|
1460
|
+
"row.collapse": "收起 {report}",
|
|
1461
|
+
"row.last": "最后",
|
|
1462
|
+
"hop.authored": "发起",
|
|
1463
|
+
"hop.contributed": "共写",
|
|
1464
|
+
"hop.sent": "主送",
|
|
1465
|
+
"hop.delivered": "送达",
|
|
1466
|
+
"hop.cc": "抄送",
|
|
1467
|
+
"hop.forwarded": "转呈",
|
|
1468
|
+
"hop.copied": "副本",
|
|
1469
|
+
"hop.read": "阅读",
|
|
1470
|
+
"hop.acked": "回执",
|
|
1471
|
+
"hop.leased": "认领工作区",
|
|
1472
|
+
"hop.released": "释放工作区",
|
|
1473
|
+
"hop.reclaimed": "回收工作区",
|
|
1474
|
+
"hop.amended": "修正",
|
|
1475
|
+
"hop.closed": "结案",
|
|
1476
|
+
"hop.reopened": "重开",
|
|
1477
|
+
"filter.all": "全部",
|
|
1478
|
+
"filter.open": "进行中",
|
|
1479
|
+
"filter.acked": "已回执",
|
|
1480
|
+
"filter.closed": "已结案",
|
|
1481
|
+
"filter.search": "搜索主题、编号、会话或收发…",
|
|
1482
|
+
"filter.clear": "清除",
|
|
1483
|
+
"filter.showing": "显示 {visible}/{total} 份汇报",
|
|
1484
|
+
"filter.none": "没有符合当前筛选的汇报。",
|
|
1485
|
+
"filter.hint": "状态筛选只作用于汇报;搜索会同时匹配会话标题与编号,以及任务标签和检出名。",
|
|
1486
|
+
"basis.created": "按创建",
|
|
1487
|
+
"basis.updated": "按最近活动",
|
|
1488
|
+
"basis.hint": "汇报按创建时间还是按最后一次活动排入时间线;会话始终按创建时间。",
|
|
1489
|
+
"detail.thread": "线程",
|
|
1490
|
+
"detail.parent": "上溯",
|
|
1491
|
+
"detail.children": "下递",
|
|
1492
|
+
"detail.openLinked": "打开 {report}",
|
|
1493
|
+
"detail.outside": "{report} 不在当前会话树的列表里(它可能属于另一条线);正文与路径仍可读取。",
|
|
1494
|
+
"detail.filteredOut": "{report} 在当前筛选下被隐藏了(用「清除」恢复)。",
|
|
1495
|
+
"detail.backToList": "回到列表",
|
|
1496
|
+
"detail.bodyTruncated": "正文过长,此处只显示开头 {chars} 字;完整内容见账本文件。",
|
|
1497
|
+
"topology.title": "拓扑",
|
|
1498
|
+
"topology.summary": "{lanes} 条泳道 · {reports} 份汇报",
|
|
1499
|
+
"topology.show": "展开拓扑",
|
|
1500
|
+
"topology.hide": "收起拓扑",
|
|
1501
|
+
"topology.external": "树外",
|
|
1502
|
+
"topology.externalHint": "不属于本树的会话",
|
|
1503
|
+
"topology.openHint": "点击在列表中打开",
|
|
1504
|
+
"cardgraph.zoomIn": "放大",
|
|
1505
|
+
"cardgraph.zoomOut": "缩小",
|
|
1506
|
+
"cardgraph.zoom": "{percent}%",
|
|
1507
|
+
"cardgraph.fit": "适应窗口",
|
|
1508
|
+
"cardgraph.summary": "{frames} 个框 · {cards} 份汇报",
|
|
1509
|
+
"cardgraph.empty": "没有汇报",
|
|
1510
|
+
"body.copy": "复制",
|
|
1511
|
+
"body.copied": "已复制",
|
|
1512
|
+
"body.footnotes": "脚注"
|
|
1513
|
+
};
|
|
1514
|
+
/** English dictionary, checked against the Chinese key set. */
|
|
1515
|
+
const en = {
|
|
1516
|
+
"view.tab": "Reports",
|
|
1517
|
+
"view.loading": "Reading the ledger…",
|
|
1518
|
+
"view.summary": "{sessions} sessions · {reports} reports",
|
|
1519
|
+
"view.empty": "No reports in this session tree yet. Once an agent opens one with report_author, its digest card appears here.",
|
|
1520
|
+
"view.refresh": "Refresh",
|
|
1521
|
+
"view.retry": "Retry",
|
|
1522
|
+
"view.error": "Read failed: {code}",
|
|
1523
|
+
"view.root": "this session",
|
|
1524
|
+
"view.live": "live",
|
|
1525
|
+
"view.delegated": "subagent",
|
|
1526
|
+
"view.detail.loading": "Reading the transfer path…",
|
|
1527
|
+
"view.detail.route": "Transfer path",
|
|
1528
|
+
"view.detail.routeEmpty": "(no hops recorded yet)",
|
|
1529
|
+
"view.detail.pending": "Still owed to: {targets}",
|
|
1530
|
+
"view.detail.body": "Body",
|
|
1531
|
+
"view.detail.path": "Ledger file",
|
|
1532
|
+
"view.copyPath": "Copy the ledger path",
|
|
1533
|
+
"row.hops": "{count} hops",
|
|
1534
|
+
"row.authors": "{count} authors",
|
|
1535
|
+
"row.filterByTask": "Show only task \"{task}\"",
|
|
1536
|
+
"row.filterByWorktree": "Show only checkout \"{worktree}\"",
|
|
1537
|
+
"row.leaseHeld": "Checkout: currently claimed by this report",
|
|
1538
|
+
"row.leaseReleased": "Checkout: claim ended, the checkout is still on disk",
|
|
1539
|
+
"row.leaseReclaimed": "Checkout: cleared away by the ledger",
|
|
1540
|
+
"row.leaseNone": "Checkout: bound but never claimed",
|
|
1541
|
+
"row.expand": "Expand {report}",
|
|
1542
|
+
"row.collapse": "Collapse {report}",
|
|
1543
|
+
"row.last": "last",
|
|
1544
|
+
"hop.authored": "authored",
|
|
1545
|
+
"hop.contributed": "contributed",
|
|
1546
|
+
"hop.sent": "sent",
|
|
1547
|
+
"hop.delivered": "delivered",
|
|
1548
|
+
"hop.cc": "cc",
|
|
1549
|
+
"hop.forwarded": "forwarded",
|
|
1550
|
+
"hop.copied": "copied",
|
|
1551
|
+
"hop.read": "read",
|
|
1552
|
+
"hop.acked": "acked",
|
|
1553
|
+
"hop.leased": "leased",
|
|
1554
|
+
"hop.released": "released",
|
|
1555
|
+
"hop.reclaimed": "reclaimed",
|
|
1556
|
+
"hop.amended": "amended",
|
|
1557
|
+
"hop.closed": "closed",
|
|
1558
|
+
"hop.reopened": "reopened",
|
|
1559
|
+
"filter.all": "All",
|
|
1560
|
+
"filter.open": "Open",
|
|
1561
|
+
"filter.acked": "Acked",
|
|
1562
|
+
"filter.closed": "Closed",
|
|
1563
|
+
"filter.search": "Search subject, id, session, or people…",
|
|
1564
|
+
"filter.clear": "Clear",
|
|
1565
|
+
"filter.showing": "Showing {visible}/{total} reports",
|
|
1566
|
+
"filter.none": "No reports match the current filter.",
|
|
1567
|
+
"filter.hint": "The status filter narrows reports only; search matches every row, including session titles and ids, task labels and checkout names.",
|
|
1568
|
+
"basis.created": "By creation",
|
|
1569
|
+
"basis.updated": "By activity",
|
|
1570
|
+
"basis.hint": "Whether reports are placed at creation or at their latest activity; sessions always keep their creation time.",
|
|
1571
|
+
"detail.thread": "Thread",
|
|
1572
|
+
"detail.parent": "Answers",
|
|
1573
|
+
"detail.children": "Answered by",
|
|
1574
|
+
"detail.openLinked": "Open {report}",
|
|
1575
|
+
"detail.outside": "{report} is not in this session tree's list (it may belong to another line); its body and path are still readable.",
|
|
1576
|
+
"detail.filteredOut": "{report} is hidden by the current filter (use Clear to restore it).",
|
|
1577
|
+
"detail.backToList": "Back to the list",
|
|
1578
|
+
"detail.bodyTruncated": "Body is long, so only the first {chars} characters are shown here; the ledger file has the whole thing.",
|
|
1579
|
+
"topology.title": "Topology",
|
|
1580
|
+
"topology.summary": "{lanes} lanes · {reports} reports",
|
|
1581
|
+
"topology.show": "Show topology",
|
|
1582
|
+
"topology.hide": "Hide topology",
|
|
1583
|
+
"topology.external": "outside",
|
|
1584
|
+
"topology.externalHint": "Sessions outside this tree",
|
|
1585
|
+
"topology.openHint": "Click to open it in the list",
|
|
1586
|
+
"cardgraph.zoomIn": "Zoom in",
|
|
1587
|
+
"cardgraph.zoomOut": "Zoom out",
|
|
1588
|
+
"cardgraph.zoom": "{percent}%",
|
|
1589
|
+
"cardgraph.fit": "Fit",
|
|
1590
|
+
"cardgraph.summary": "{frames} frames · {cards} reports",
|
|
1591
|
+
"cardgraph.empty": "no reports",
|
|
1592
|
+
"body.copy": "Copy",
|
|
1593
|
+
"body.copied": "Copied",
|
|
1594
|
+
"body.footnotes": "Footnotes"
|
|
1595
|
+
};
|
|
1596
|
+
/**
|
|
1597
|
+
* Lifecycle state → dictionary key.
|
|
1598
|
+
*
|
|
1599
|
+
* Deliberately the SAME keys the toolbar's filter chips use. A card's status
|
|
1600
|
+
* chip and the filter that selects it name one state, so giving them two
|
|
1601
|
+
* vocabularies (raw `open` on the card, 进行中 in the toolbar) made the reader
|
|
1602
|
+
* build the mapping themselves — and the mapping was the bug, not the words.
|
|
1603
|
+
*/
|
|
1604
|
+
const STATUS_LABEL = {
|
|
1605
|
+
open: "filter.open",
|
|
1606
|
+
acked: "filter.acked",
|
|
1607
|
+
closed: "filter.closed"
|
|
1608
|
+
};
|
|
1609
|
+
/**
|
|
1610
|
+
* Lifecycle state → the shell's chip tone.
|
|
1611
|
+
*
|
|
1612
|
+
* Colour-coded status is the point of the chip, and the shell's palette already
|
|
1613
|
+
* has the three tones this needs — so the states ride `Tag` instead of a
|
|
1614
|
+
* hand-mixed background. `open` reads as the one that wants attention. The tone
|
|
1615
|
+
* names are the ones the shell's CSS actually defines (`[data-tone=…]`), which
|
|
1616
|
+
* is a longer list than the shipped views happen to use.
|
|
1617
|
+
*/
|
|
1618
|
+
const STATUS_TONE = {
|
|
1619
|
+
open: "warning",
|
|
1620
|
+
acked: "success",
|
|
1621
|
+
closed: "neutral"
|
|
1622
|
+
};
|
|
1623
|
+
/**
|
|
1624
|
+
* Edge kind → the word the drawing calls it.
|
|
1625
|
+
*
|
|
1626
|
+
* The topology borrows the transfer path's own vocabulary rather than inventing
|
|
1627
|
+
* a second one: an arrow in the graph and a row in the panel name the same
|
|
1628
|
+
* relation, so they say the same thing.
|
|
1629
|
+
*/
|
|
1630
|
+
const EDGE_LABEL = {
|
|
1631
|
+
to: "hop.sent",
|
|
1632
|
+
cc: "hop.cc",
|
|
1633
|
+
author: "hop.contributed",
|
|
1634
|
+
thread: "detail.thread"
|
|
1635
|
+
};
|
|
1636
|
+
/**
|
|
1637
|
+
* Transfer-path hop action → dictionary key.
|
|
1638
|
+
*
|
|
1639
|
+
* These are ledger vocabulary: the model and the ledger files keep the raw
|
|
1640
|
+
* tokens (`authored`, `delivered`, …), and only the human view speaks a
|
|
1641
|
+
* language. Keyed by the full action union so a new hop action cannot ship
|
|
1642
|
+
* without a word for it.
|
|
1643
|
+
*/
|
|
1644
|
+
/** Lease-state description, for the checkout chip's tooltip. */
|
|
1645
|
+
const LEASE_HINT = {
|
|
1646
|
+
held: "row.leaseHeld",
|
|
1647
|
+
released: "row.leaseReleased",
|
|
1648
|
+
reclaimed: "row.leaseReclaimed",
|
|
1649
|
+
none: "row.leaseNone"
|
|
1650
|
+
};
|
|
1651
|
+
/** Hop action label, for the transfer-path rendering. */
|
|
1652
|
+
const HOP_LABEL = {
|
|
1653
|
+
authored: "hop.authored",
|
|
1654
|
+
contributed: "hop.contributed",
|
|
1655
|
+
sent: "hop.sent",
|
|
1656
|
+
delivered: "hop.delivered",
|
|
1657
|
+
cc: "hop.cc",
|
|
1658
|
+
forwarded: "hop.forwarded",
|
|
1659
|
+
copied: "hop.copied",
|
|
1660
|
+
read: "hop.read",
|
|
1661
|
+
acked: "hop.acked",
|
|
1662
|
+
leased: "hop.leased",
|
|
1663
|
+
released: "hop.released",
|
|
1664
|
+
reclaimed: "hop.reclaimed",
|
|
1665
|
+
amended: "hop.amended",
|
|
1666
|
+
closed: "hop.closed",
|
|
1667
|
+
reopened: "hop.reopened"
|
|
1668
|
+
};
|
|
1669
|
+
//#endregion
|
|
1670
|
+
//#region src/client/CardgraphView.tsx
|
|
1671
|
+
/**
|
|
1672
|
+
* The card canvas's view: two canvases, a pan/zoom transform, picking, and an
|
|
1673
|
+
* invisible button layer.
|
|
1674
|
+
*
|
|
1675
|
+
* The drawing is split across two surfaces, and that split is the whole
|
|
1676
|
+
* performance story:
|
|
1677
|
+
*
|
|
1678
|
+
* - the **base** canvas holds frames, wires and cards, and is repainted only when
|
|
1679
|
+
* the drawing itself changes (layout, transform, size, theme);
|
|
1680
|
+
* - the **interaction** canvas holds one report's path over a scrim, and is
|
|
1681
|
+
* repainted on every hover. Its cost does not grow with the ledger, because it
|
|
1682
|
+
* never touches the shapes underneath.
|
|
1683
|
+
*
|
|
1684
|
+
* Picking is arithmetic rather than elements: the canvas has nothing to hit, so
|
|
1685
|
+
* `hitTest` from the model is given the cards (topmost first) and then the frames.
|
|
1686
|
+
* What the canvas also cannot offer is accessibility — so every card that is on
|
|
1687
|
+
* screen gets a real, transparent `<button>` on top of it, which is what a keyboard
|
|
1688
|
+
* or a screen reader reaches, and the list below stays the full accessible path.
|
|
1689
|
+
*
|
|
1690
|
+
* @module dsh-report-ledger/client/CardgraphView
|
|
1691
|
+
*/
|
|
1692
|
+
/** How far the reader may zoom out and in. */
|
|
1693
|
+
const MIN_ZOOM = .25;
|
|
1694
|
+
const MAX_ZOOM = 2.5;
|
|
1695
|
+
/** One wheel notch. */
|
|
1696
|
+
const ZOOM_STEP = 1.25;
|
|
1697
|
+
/** Drawing units to keep beyond the viewport, so a pan does not reveal a blank band. */
|
|
1698
|
+
const OVERSCAN = 160;
|
|
1699
|
+
/** How tall the canvas box is, inside the tab. */
|
|
1700
|
+
const VIEW_HEIGHT = 420;
|
|
1701
|
+
/** How far the pointer may travel before a press becomes a drag instead of a click. */
|
|
1702
|
+
const DRAG_SLOP = 4;
|
|
1703
|
+
/** The four kinds, in the order the legend lists them. */
|
|
1704
|
+
const KINDS = [
|
|
1705
|
+
"to",
|
|
1706
|
+
"cc",
|
|
1707
|
+
"author",
|
|
1708
|
+
"thread"
|
|
1709
|
+
];
|
|
1710
|
+
/**
|
|
1711
|
+
* The legend.
|
|
1712
|
+
*
|
|
1713
|
+
* Built from the painter's own wire table (`WIRE_TOKENS`) rather than from CSS, so
|
|
1714
|
+
* a legend that disagreed with the lines beside it would take a change to that one
|
|
1715
|
+
* table — the drift the swimlane's legend avoided a different way (by sharing a
|
|
1716
|
+
* class name with its paths).
|
|
1717
|
+
* @param props - the translator.
|
|
1718
|
+
* @returns the legend row.
|
|
1719
|
+
*/
|
|
1720
|
+
function CardgraphLegend({ t }) {
|
|
1721
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1722
|
+
style: {
|
|
1723
|
+
display: "flex",
|
|
1724
|
+
alignItems: "center",
|
|
1725
|
+
gap: "12px",
|
|
1726
|
+
flexWrap: "wrap"
|
|
1727
|
+
},
|
|
1728
|
+
children: KINDS.map((kind) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1729
|
+
style: {
|
|
1730
|
+
display: "inline-flex",
|
|
1731
|
+
alignItems: "center",
|
|
1732
|
+
gap: "6px",
|
|
1733
|
+
fontSize: "var(--dsw-font-xxxs-11, 11px)",
|
|
1734
|
+
color: "var(--dsw-alias-label-caption)"
|
|
1735
|
+
},
|
|
1736
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1737
|
+
width: "26",
|
|
1738
|
+
height: "8",
|
|
1739
|
+
"aria-hidden": "true",
|
|
1740
|
+
focusable: "false",
|
|
1741
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
|
|
1742
|
+
x1: "0",
|
|
1743
|
+
y1: "4",
|
|
1744
|
+
x2: "26",
|
|
1745
|
+
y2: "4",
|
|
1746
|
+
stroke: `var(${WIRE_TOKENS[kind].colour})`,
|
|
1747
|
+
strokeWidth: WIRE_TOKENS[kind].width,
|
|
1748
|
+
...WIRE_TOKENS[kind].dash.length === 0 ? {} : { strokeDasharray: WIRE_TOKENS[kind].dash.join(" ") }
|
|
1749
|
+
})
|
|
1750
|
+
}), t(EDGE_LABEL[kind])]
|
|
1751
|
+
}, kind))
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
/** Keep a number inside a range. */
|
|
1755
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
1756
|
+
/**
|
|
1757
|
+
* The drawing.
|
|
1758
|
+
* @param props - payload, the reports to plot, translations, and the open handler.
|
|
1759
|
+
* @returns the canvas box with its controls.
|
|
1760
|
+
*/
|
|
1761
|
+
function CardgraphView({ payload, reports, t, openReport, onOpen, idHint }) {
|
|
1762
|
+
const boxRef = (0, react.useRef)(null);
|
|
1763
|
+
const baseRef = (0, react.useRef)(null);
|
|
1764
|
+
const pathRef = (0, react.useRef)(null);
|
|
1765
|
+
const painter = (0, react.useRef)(null);
|
|
1766
|
+
const pathPainter = (0, react.useRef)(null);
|
|
1767
|
+
const reader = (0, react.useRef)(null);
|
|
1768
|
+
const drag = (0, react.useRef)(null);
|
|
1769
|
+
const [size, setSize] = (0, react.useState)({
|
|
1770
|
+
width: 0,
|
|
1771
|
+
height: VIEW_HEIGHT
|
|
1772
|
+
});
|
|
1773
|
+
const [zoom, setZoom] = (0, react.useState)(1);
|
|
1774
|
+
const [offset, setOffset] = (0, react.useState)({
|
|
1775
|
+
x: 0,
|
|
1776
|
+
y: 0
|
|
1777
|
+
});
|
|
1778
|
+
const [hover, setHover] = (0, react.useState)({});
|
|
1779
|
+
const [ready, setReady] = (0, react.useState)(false);
|
|
1780
|
+
const fitted = (0, react.useRef)(false);
|
|
1781
|
+
/** The transform as of this render, for the one listener that is attached once. */
|
|
1782
|
+
const zoomRef = (0, react.useRef)(zoom);
|
|
1783
|
+
const offsetRef = (0, react.useRef)(offset);
|
|
1784
|
+
zoomRef.current = zoom;
|
|
1785
|
+
offsetRef.current = offset;
|
|
1786
|
+
const lod = lodFor(zoom);
|
|
1787
|
+
const layout = (0, react.useMemo)(() => buildCardgraph(payload, reports, { lod }), [
|
|
1788
|
+
payload,
|
|
1789
|
+
reports,
|
|
1790
|
+
lod
|
|
1791
|
+
]);
|
|
1792
|
+
const labels = (0, react.useMemo)(() => {
|
|
1793
|
+
const status = {};
|
|
1794
|
+
for (const [state, key] of Object.entries(STATUS_LABEL)) status[state] = t(key);
|
|
1795
|
+
return {
|
|
1796
|
+
external: t("topology.external"),
|
|
1797
|
+
empty: t("cardgraph.empty"),
|
|
1798
|
+
status
|
|
1799
|
+
};
|
|
1800
|
+
}, [t]);
|
|
1801
|
+
(0, react.useEffect)(() => {
|
|
1802
|
+
const base = baseRef.current;
|
|
1803
|
+
const path = pathRef.current;
|
|
1804
|
+
if (base === null || path === null) return;
|
|
1805
|
+
painter.current = createCanvasPainter(base);
|
|
1806
|
+
pathPainter.current = createCanvasPainter(path);
|
|
1807
|
+
reader.current = createThemeReader(globalThis.document.documentElement, globalThis.window);
|
|
1808
|
+
setReady(true);
|
|
1809
|
+
return () => {
|
|
1810
|
+
painter.current = null;
|
|
1811
|
+
pathPainter.current = null;
|
|
1812
|
+
reader.current = null;
|
|
1813
|
+
setReady(false);
|
|
1814
|
+
};
|
|
1815
|
+
}, []);
|
|
1816
|
+
(0, react.useEffect)(() => {
|
|
1817
|
+
const box = boxRef.current;
|
|
1818
|
+
if (box === null) return;
|
|
1819
|
+
const measure = () => {
|
|
1820
|
+
setSize({
|
|
1821
|
+
width: box.clientWidth,
|
|
1822
|
+
height: box.clientHeight
|
|
1823
|
+
});
|
|
1824
|
+
};
|
|
1825
|
+
measure();
|
|
1826
|
+
const observer = new ResizeObserver(measure);
|
|
1827
|
+
observer.observe(box);
|
|
1828
|
+
return () => {
|
|
1829
|
+
observer.disconnect();
|
|
1830
|
+
};
|
|
1831
|
+
}, []);
|
|
1832
|
+
const viewport = (0, react.useMemo)(() => ({
|
|
1833
|
+
offsetX: offset.x,
|
|
1834
|
+
offsetY: offset.y,
|
|
1835
|
+
scale: zoom,
|
|
1836
|
+
width: size.width,
|
|
1837
|
+
height: size.height
|
|
1838
|
+
}), [
|
|
1839
|
+
offset,
|
|
1840
|
+
zoom,
|
|
1841
|
+
size
|
|
1842
|
+
]);
|
|
1843
|
+
const focus = (0, react.useMemo)(() => ({
|
|
1844
|
+
...hover.report === void 0 ? {} : { report: hover.report },
|
|
1845
|
+
...hover.frame === void 0 ? {} : { frame: hover.frame },
|
|
1846
|
+
...openReport === void 0 ? {} : { selected: openReport }
|
|
1847
|
+
}), [hover, openReport]);
|
|
1848
|
+
const scene = (0, react.useMemo)(() => ({
|
|
1849
|
+
layout,
|
|
1850
|
+
theme: reader.current?.read() ?? FALLBACK_THEME,
|
|
1851
|
+
viewport,
|
|
1852
|
+
focus,
|
|
1853
|
+
labels,
|
|
1854
|
+
overscan: OVERSCAN
|
|
1855
|
+
}), [
|
|
1856
|
+
layout,
|
|
1857
|
+
viewport,
|
|
1858
|
+
focus,
|
|
1859
|
+
labels
|
|
1860
|
+
]);
|
|
1861
|
+
(0, react.useEffect)(() => {
|
|
1862
|
+
const target = painter.current;
|
|
1863
|
+
if (!ready || target === null) return;
|
|
1864
|
+
target.resize(size.width, size.height, globalThis.devicePixelRatio || 1);
|
|
1865
|
+
target.paint({
|
|
1866
|
+
...scene,
|
|
1867
|
+
focus: openReport === void 0 ? {} : { selected: openReport }
|
|
1868
|
+
});
|
|
1869
|
+
}, [
|
|
1870
|
+
ready,
|
|
1871
|
+
scene,
|
|
1872
|
+
size,
|
|
1873
|
+
openReport
|
|
1874
|
+
]);
|
|
1875
|
+
(0, react.useEffect)(() => {
|
|
1876
|
+
const target = pathPainter.current;
|
|
1877
|
+
if (!ready || target === null) return;
|
|
1878
|
+
target.resize(size.width, size.height, globalThis.devicePixelRatio || 1);
|
|
1879
|
+
target.paintPath(scene);
|
|
1880
|
+
}, [
|
|
1881
|
+
ready,
|
|
1882
|
+
scene,
|
|
1883
|
+
size
|
|
1884
|
+
]);
|
|
1885
|
+
/** Everything under the pointer, topmost first: cards, then the frames holding them. */
|
|
1886
|
+
const targets = (0, react.useMemo)(() => [...[...layout.cards].reverse().map((card) => ({
|
|
1887
|
+
kind: "card",
|
|
1888
|
+
report: card.report,
|
|
1889
|
+
bounds: card.bounds
|
|
1890
|
+
})), ...[...layout.frames].reverse().map((frame) => ({
|
|
1891
|
+
kind: "frame",
|
|
1892
|
+
frame: frame.index,
|
|
1893
|
+
bounds: frame.bounds
|
|
1894
|
+
}))], [layout]);
|
|
1895
|
+
const pick = (0, react.useCallback)((clientX, clientY) => {
|
|
1896
|
+
const box = boxRef.current;
|
|
1897
|
+
if (box === null) return void 0;
|
|
1898
|
+
const rect = box.getBoundingClientRect();
|
|
1899
|
+
const x = (clientX - rect.left - offset.x) / zoom;
|
|
1900
|
+
const y = (clientY - rect.top - offset.y) / zoom;
|
|
1901
|
+
return hitTest(targets, x, y);
|
|
1902
|
+
}, [
|
|
1903
|
+
targets,
|
|
1904
|
+
offset,
|
|
1905
|
+
zoom
|
|
1906
|
+
]);
|
|
1907
|
+
const fit = (0, react.useCallback)(() => {
|
|
1908
|
+
const box = boxRef.current;
|
|
1909
|
+
if (box === null || layout.width <= 0 || layout.height <= 0) return;
|
|
1910
|
+
const width = box.clientWidth;
|
|
1911
|
+
const height = box.clientHeight;
|
|
1912
|
+
const next = clamp(Math.min((width - 24) / layout.width, (height - 24) / layout.height), MIN_ZOOM, 1);
|
|
1913
|
+
setZoom(next);
|
|
1914
|
+
setOffset({
|
|
1915
|
+
x: Math.max(8, (width - layout.width * next) / 2),
|
|
1916
|
+
y: Math.max(8, (height - layout.height * next) / 2)
|
|
1917
|
+
});
|
|
1918
|
+
}, [layout]);
|
|
1919
|
+
/**
|
|
1920
|
+
* The first view, which is deliberately **not** the fit.
|
|
1921
|
+
*
|
|
1922
|
+
* Fitting the whole drawing into a 420px box on a three-column tree lands around
|
|
1923
|
+
* 57%, which is the compact tier — so the reader's first sight of a card canvas
|
|
1924
|
+
* would be a wall of one-line cards with the subjects this design is about elided.
|
|
1925
|
+
* Measured on the real tab, not reasoned about: the first screenshot of the wiring
|
|
1926
|
+
* came out at 57% and proved the point.
|
|
1927
|
+
*
|
|
1928
|
+
* So the first view fits the **width** and never drops below the card tier: frames,
|
|
1929
|
+
* columns and readable cards all visible at once, and the reader scrolls down
|
|
1930
|
+
* instead of squinting. `适应窗口` still gives the whole picture on demand.
|
|
1931
|
+
*/
|
|
1932
|
+
(0, react.useEffect)(() => {
|
|
1933
|
+
if (fitted.current || size.width === 0 || layout.cards.length === 0) return;
|
|
1934
|
+
fitted.current = true;
|
|
1935
|
+
const next = clamp((size.width - 24) / Math.max(layout.width, 1), .8, 1);
|
|
1936
|
+
setZoom(next);
|
|
1937
|
+
setOffset({
|
|
1938
|
+
x: Math.max(8, (size.width - layout.width * next) / 2),
|
|
1939
|
+
y: 12
|
|
1940
|
+
});
|
|
1941
|
+
}, [layout, size]);
|
|
1942
|
+
(0, react.useEffect)(() => {
|
|
1943
|
+
const box = boxRef.current;
|
|
1944
|
+
if (box === null) return;
|
|
1945
|
+
const onWheel = (event) => {
|
|
1946
|
+
event.preventDefault();
|
|
1947
|
+
const rect = box.getBoundingClientRect();
|
|
1948
|
+
const px = event.clientX - rect.left;
|
|
1949
|
+
const py = event.clientY - rect.top;
|
|
1950
|
+
const current = zoomRef.current;
|
|
1951
|
+
const next = clamp(current * (event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP), MIN_ZOOM, MAX_ZOOM);
|
|
1952
|
+
const ratio = next / current;
|
|
1953
|
+
const previous = offsetRef.current;
|
|
1954
|
+
setOffset({
|
|
1955
|
+
x: px - (px - previous.x) * ratio,
|
|
1956
|
+
y: py - (py - previous.y) * ratio
|
|
1957
|
+
});
|
|
1958
|
+
setZoom(next);
|
|
1959
|
+
};
|
|
1960
|
+
box.addEventListener("wheel", onWheel, { passive: false });
|
|
1961
|
+
return () => {
|
|
1962
|
+
box.removeEventListener("wheel", onWheel);
|
|
1963
|
+
};
|
|
1964
|
+
}, []);
|
|
1965
|
+
/** Drag to pan, click to open. Distinguishing them by distance travelled. */
|
|
1966
|
+
const onPointerDown = (0, react.useCallback)((event) => {
|
|
1967
|
+
if (event.button !== 0) return;
|
|
1968
|
+
drag.current = {
|
|
1969
|
+
x: event.clientX,
|
|
1970
|
+
y: event.clientY,
|
|
1971
|
+
offsetX: offset.x,
|
|
1972
|
+
offsetY: offset.y,
|
|
1973
|
+
moved: false
|
|
1974
|
+
};
|
|
1975
|
+
}, [offset]);
|
|
1976
|
+
const onPointerMove = (0, react.useCallback)((event) => {
|
|
1977
|
+
const pressing = drag.current;
|
|
1978
|
+
if (pressing !== null) {
|
|
1979
|
+
const dx = event.clientX - pressing.x;
|
|
1980
|
+
const dy = event.clientY - pressing.y;
|
|
1981
|
+
if (Math.abs(dx) + Math.abs(dy) > DRAG_SLOP) pressing.moved = true;
|
|
1982
|
+
if (pressing.moved) {
|
|
1983
|
+
setOffset({
|
|
1984
|
+
x: pressing.offsetX + dx,
|
|
1985
|
+
y: pressing.offsetY + dy
|
|
1986
|
+
});
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const hit = pick(event.clientX, event.clientY);
|
|
1991
|
+
setHover(hit?.kind === "card" && hit.report !== void 0 ? { report: hit.report } : hit?.kind === "frame" && hit.frame !== void 0 ? { frame: hit.frame } : {});
|
|
1992
|
+
}, [pick]);
|
|
1993
|
+
const onPointerUp = (0, react.useCallback)((event) => {
|
|
1994
|
+
const pressing = drag.current;
|
|
1995
|
+
drag.current = null;
|
|
1996
|
+
if (pressing === null || pressing.moved) return;
|
|
1997
|
+
const hit = pick(event.clientX, event.clientY);
|
|
1998
|
+
if (hit?.kind === "card" && hit.report !== void 0) onOpen(hit.report);
|
|
1999
|
+
}, [pick, onOpen]);
|
|
2000
|
+
const onPointerLeave = (0, react.useCallback)(() => {
|
|
2001
|
+
drag.current = null;
|
|
2002
|
+
setHover({});
|
|
2003
|
+
}, []);
|
|
2004
|
+
const visibleCards = (0, react.useMemo)(() => {
|
|
2005
|
+
const left = -offset.x / zoom;
|
|
2006
|
+
const top = -offset.y / zoom;
|
|
2007
|
+
const right = (size.width - offset.x) / zoom;
|
|
2008
|
+
const bottom = (size.height - offset.y) / zoom;
|
|
2009
|
+
return layout.cards.filter((card) => card.bounds.x < right && left < card.bounds.x + card.bounds.width && card.bounds.y < bottom && top < card.bounds.y + card.bounds.height);
|
|
2010
|
+
}, [
|
|
2011
|
+
layout,
|
|
2012
|
+
offset,
|
|
2013
|
+
zoom,
|
|
2014
|
+
size
|
|
2015
|
+
]);
|
|
2016
|
+
const percent = Math.round(zoom * 100);
|
|
2017
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2018
|
+
style: {
|
|
2019
|
+
display: "flex",
|
|
2020
|
+
flexDirection: "column",
|
|
2021
|
+
gap: "6px"
|
|
2022
|
+
},
|
|
2023
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2024
|
+
style: controls,
|
|
2025
|
+
children: [
|
|
2026
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2027
|
+
variant: "outline",
|
|
2028
|
+
size: "sm",
|
|
2029
|
+
"aria-label": t("cardgraph.zoomOut"),
|
|
2030
|
+
onClick: () => {
|
|
2031
|
+
setZoom((z) => clamp(z / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
|
|
2032
|
+
},
|
|
2033
|
+
children: "−"
|
|
2034
|
+
}),
|
|
2035
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2036
|
+
style: percentStyle,
|
|
2037
|
+
children: t("cardgraph.zoom", { percent })
|
|
2038
|
+
}),
|
|
2039
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2040
|
+
variant: "outline",
|
|
2041
|
+
size: "sm",
|
|
2042
|
+
"aria-label": t("cardgraph.zoomIn"),
|
|
2043
|
+
onClick: () => {
|
|
2044
|
+
setZoom((z) => clamp(z * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
|
|
2045
|
+
},
|
|
2046
|
+
children: "+"
|
|
2047
|
+
}),
|
|
2048
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2049
|
+
variant: "outline",
|
|
2050
|
+
size: "sm",
|
|
2051
|
+
onClick: fit,
|
|
2052
|
+
children: t("cardgraph.fit")
|
|
2053
|
+
}),
|
|
2054
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardgraphLegend, { t }),
|
|
2055
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2056
|
+
style: percentStyle,
|
|
2057
|
+
children: t("cardgraph.summary", {
|
|
2058
|
+
frames: layout.frames.length,
|
|
2059
|
+
cards: layout.cards.length
|
|
2060
|
+
})
|
|
2061
|
+
})
|
|
2062
|
+
]
|
|
2063
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2064
|
+
ref: boxRef,
|
|
2065
|
+
onPointerDown,
|
|
2066
|
+
onPointerMove,
|
|
2067
|
+
onPointerUp,
|
|
2068
|
+
onPointerLeave,
|
|
2069
|
+
style: {
|
|
2070
|
+
...box,
|
|
2071
|
+
height: `${VIEW_HEIGHT}px`,
|
|
2072
|
+
cursor: drag.current?.moved === true ? "grabbing" : hover.report === void 0 ? "grab" : "pointer"
|
|
2073
|
+
},
|
|
2074
|
+
children: [
|
|
2075
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
|
|
2076
|
+
ref: baseRef,
|
|
2077
|
+
role: "img",
|
|
2078
|
+
"aria-label": t("cardgraph.summary", {
|
|
2079
|
+
frames: layout.frames.length,
|
|
2080
|
+
cards: layout.cards.length
|
|
2081
|
+
}),
|
|
2082
|
+
style: layer
|
|
2083
|
+
}),
|
|
2084
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
|
|
2085
|
+
ref: pathRef,
|
|
2086
|
+
"aria-hidden": "true",
|
|
2087
|
+
style: layer
|
|
2088
|
+
}),
|
|
2089
|
+
visibleCards.map((card) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2090
|
+
type: "button",
|
|
2091
|
+
"aria-label": `${card.report} · ${card.subject}`,
|
|
2092
|
+
title: idHint(card.report),
|
|
2093
|
+
onClick: () => {
|
|
2094
|
+
onOpen(card.report);
|
|
2095
|
+
},
|
|
2096
|
+
onFocus: () => {
|
|
2097
|
+
setHover({ report: card.report });
|
|
2098
|
+
},
|
|
2099
|
+
onBlur: () => {
|
|
2100
|
+
setHover({});
|
|
2101
|
+
},
|
|
2102
|
+
style: {
|
|
2103
|
+
position: "absolute",
|
|
2104
|
+
left: `${card.bounds.x * zoom + offset.x}px`,
|
|
2105
|
+
top: `${card.bounds.y * zoom + offset.y}px`,
|
|
2106
|
+
width: `${card.bounds.width * zoom}px`,
|
|
2107
|
+
height: `${card.bounds.height * zoom}px`,
|
|
2108
|
+
padding: 0,
|
|
2109
|
+
border: 0,
|
|
2110
|
+
background: "transparent",
|
|
2111
|
+
cursor: "pointer"
|
|
2112
|
+
}
|
|
2113
|
+
}, card.report))
|
|
2114
|
+
]
|
|
2115
|
+
})]
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
const controls = {
|
|
2119
|
+
display: "flex",
|
|
2120
|
+
alignItems: "center",
|
|
2121
|
+
gap: "8px",
|
|
2122
|
+
flexWrap: "wrap"
|
|
2123
|
+
};
|
|
2124
|
+
const percentStyle = {
|
|
2125
|
+
fontSize: "var(--dsw-font-xxxs-11, 11px)",
|
|
2126
|
+
color: "var(--dsw-alias-label-caption)",
|
|
2127
|
+
fontVariantNumeric: "tabular-nums"
|
|
2128
|
+
};
|
|
2129
|
+
const box = {
|
|
2130
|
+
position: "relative",
|
|
2131
|
+
overflow: "hidden",
|
|
2132
|
+
borderRadius: "6px",
|
|
2133
|
+
border: "1px solid var(--dsw-alias-border-l1)",
|
|
2134
|
+
touchAction: "none"
|
|
2135
|
+
};
|
|
2136
|
+
const layer = {
|
|
2137
|
+
position: "absolute",
|
|
2138
|
+
inset: "0",
|
|
2139
|
+
display: "block"
|
|
2140
|
+
};
|
|
2141
|
+
//#endregion
|
|
2142
|
+
//#region src/client/styles.ts
|
|
2143
|
+
/**
|
|
2144
|
+
* The plugin's own stylesheet.
|
|
128
2145
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
2146
|
+
* The shell's primitives ship their own CSS with the shell, which is the whole
|
|
2147
|
+
* point of using them — a plugin gets the app's real look without carrying a
|
|
2148
|
+
* theme. What is left for us are the two things only a consumer can know:
|
|
2149
|
+
*
|
|
2150
|
+
* 1. **Layout that belongs to the consumer.** `Input` sizes itself to its
|
|
2151
|
+
* content (`display: inline-flex`, no width), which is right in a form and
|
|
2152
|
+
* wrong in this tab's toolbar, where the search seat takes the slack. The
|
|
2153
|
+
* component puts the `className` it is given on its own wrapper, so one rule
|
|
2154
|
+
* is enough — and targeting a class of our own keeps us off the hashed class
|
|
2155
|
+
* names, which are free to change between shell releases.
|
|
2156
|
+
*
|
|
2157
|
+
* 2. **Drawing the topology.** The graph is made of elements the shell has no
|
|
2158
|
+
* primitive for — edges, lane rails, row separators, the hover dimming — so
|
|
2159
|
+
* these rules style OUR OWN svg and divs. Nothing here restyles a shell
|
|
2160
|
+
* component; every library element keeps the look the shell gave it.
|
|
2161
|
+
*
|
|
2162
|
+
* Every colour comes from a `--dsw-*` token, so the drawing follows the theme
|
|
2163
|
+
* (light or dark) without knowing anything about it.
|
|
2164
|
+
*
|
|
2165
|
+
* @module dsh-report-ledger/client/styles
|
|
135
2166
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
2167
|
+
/** Class the tab puts on the toolbar's search seat. */
|
|
2168
|
+
const SEARCH_CLASS = "report-ledger-search";
|
|
2169
|
+
/** Applied to everything that is not part of the hovered report's path. */
|
|
2170
|
+
const DIM_CLASS = "report-ledger-dim";
|
|
2171
|
+
/** Element id, so a reload replaces the sheet instead of stacking one per apply. */
|
|
2172
|
+
const STYLE_ID = "dsh-report-ledger-styles";
|
|
2173
|
+
/** The adapter rules, kept in one place so the id and the text cannot drift. */
|
|
2174
|
+
const CSS = `
|
|
2175
|
+
.${SEARCH_CLASS} { flex: 1 1 auto; min-width: 0; }
|
|
2176
|
+
|
|
2177
|
+
/* ── topology: our own drawing primitives ──────────────────────────────────
|
|
2178
|
+
Rails and row separators are hairlines; edges carry the relation, so only
|
|
2179
|
+
they get colour and dash pattern. Everything reads through theme tokens. */
|
|
2180
|
+
.report-ledger-rail { stroke: var(--dsw-alias-border-l1); stroke-width: 1; }
|
|
2181
|
+
.report-ledger-rowsep { stroke: var(--dsw-alias-border-l1); stroke-width: 1; opacity: .45; }
|
|
2182
|
+
.report-ledger-headsep { stroke: var(--dsw-alias-border-l2); stroke-width: 1; }
|
|
2183
|
+
.report-ledger-edge { fill: none; }
|
|
2184
|
+
.report-ledger-edge-to { stroke: var(--dsw-alias-label-secondary); stroke-width: 1.4; }
|
|
2185
|
+
.report-ledger-edge-cc { stroke: var(--dsw-alias-label-caption); stroke-width: 1; stroke-dasharray: 2 3; }
|
|
2186
|
+
.report-ledger-edge-author { stroke: var(--dsw-alias-label-caption); stroke-width: 1; stroke-dasharray: 1 3; }
|
|
2187
|
+
.report-ledger-edge-thread { stroke: var(--dsw-alias-state-business-label, var(--dsw-alias-label-secondary)); stroke-width: 1.2; }
|
|
2188
|
+
.report-ledger-arrow-to { fill: var(--dsw-alias-label-secondary); }
|
|
2189
|
+
.report-ledger-arrow-cc { fill: var(--dsw-alias-label-caption); }
|
|
2190
|
+
.report-ledger-arrow-author { fill: var(--dsw-alias-label-caption); }
|
|
2191
|
+
.report-ledger-arrow-thread { fill: var(--dsw-alias-state-business-label, var(--dsw-alias-label-secondary)); }
|
|
2192
|
+
/* A hover label over a line needs its own backing, or the line runs through the word. */
|
|
2193
|
+
.report-ledger-edge-label {
|
|
2194
|
+
padding: 0 4px;
|
|
2195
|
+
border-radius: 3px;
|
|
2196
|
+
font-size: var(--dsw-font-xxxs-11, 11px);
|
|
2197
|
+
line-height: 15px;
|
|
2198
|
+
white-space: nowrap;
|
|
2199
|
+
color: var(--dsw-alias-label-secondary);
|
|
2200
|
+
background: var(--dsw-alias-bg-base);
|
|
2201
|
+
}
|
|
2202
|
+
/* ── hover focus ───────────────────────────────────────────────────────────
|
|
2203
|
+
The dim goes on ONE element — the edge layer — and never through a descendant
|
|
2204
|
+
selector. Measured on a 300-report drawing: flipping a data attribute that a
|
|
2205
|
+
descendant rule matches costs ~35ms per hover on its own, because the browser
|
|
2206
|
+
recalculates style for the whole subtree whether or not the result changes
|
|
2207
|
+
anything visible; dimming 831 individual groups cost ~89ms. One class on one
|
|
2208
|
+
layer costs neither.
|
|
2209
|
+
|
|
2210
|
+
Nodes are deliberately NOT dimmed: while tracing one report's path it is the
|
|
2211
|
+
*relations* that need to recede, not the participants. */
|
|
2212
|
+
.${DIM_CLASS} { opacity: .18; }
|
|
2213
|
+
`;
|
|
145
2214
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* Mirrors what `report_read` already does for the model: the full text stays in
|
|
149
|
-
* the ledger file, and both readers get a bounded view plus a pointer rather than
|
|
150
|
-
* an unbounded paste. Keeping the human view and the model view bounded the same
|
|
151
|
-
* way means neither can be surprised by what the other sees.
|
|
152
|
-
* @param body - the raw body.
|
|
153
|
-
* @param limit - length above which truncation applies.
|
|
154
|
-
* @param head - how many characters to keep when truncating.
|
|
155
|
-
* @returns the display text and whether it was cut.
|
|
2215
|
+
* Mount the adapter stylesheet.
|
|
2216
|
+
* @returns a disposer that removes it, owned by the plugin's effect scope.
|
|
156
2217
|
*/
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
2218
|
+
function installStyles() {
|
|
2219
|
+
const doc = globalThis.document;
|
|
2220
|
+
if (doc === void 0) return () => {};
|
|
2221
|
+
doc.getElementById(STYLE_ID)?.remove();
|
|
2222
|
+
const style = doc.createElement("style");
|
|
2223
|
+
style.id = STYLE_ID;
|
|
2224
|
+
style.textContent = CSS;
|
|
2225
|
+
doc.head.append(style);
|
|
2226
|
+
return () => {
|
|
2227
|
+
style.remove();
|
|
165
2228
|
};
|
|
166
2229
|
}
|
|
167
2230
|
//#endregion
|
|
@@ -186,6 +2249,15 @@ window.__ModuleLoader__.load({
|
|
|
186
2249
|
*/
|
|
187
2250
|
const TIMELINE_URL = "/api/report-ledger/timeline";
|
|
188
2251
|
const REPORT_URL = "/api/report-ledger/report";
|
|
2252
|
+
/**
|
|
2253
|
+
* How often the tab re-reads the timeline while it is on screen.
|
|
2254
|
+
*
|
|
2255
|
+
* The ledger is written by other agents in the background, so a view that loads
|
|
2256
|
+
* once goes stale precisely when it matters — the human is watching for a report
|
|
2257
|
+
* that has not been written yet. Ten seconds is quiet enough not to matter on a
|
|
2258
|
+
* large ledger and still fast enough to watch a collaboration happen.
|
|
2259
|
+
*/
|
|
2260
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
189
2261
|
/** Read one endpoint and unwrap its result envelope. */
|
|
190
2262
|
async function getJson(url) {
|
|
191
2263
|
const response = await fetch(url, {
|
|
@@ -211,21 +2283,17 @@ window.__ModuleLoader__.load({
|
|
|
211
2283
|
if (at === void 0 || at <= 0) return "";
|
|
212
2284
|
return new Date(at).toLocaleString();
|
|
213
2285
|
}
|
|
214
|
-
/**
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
color: "var(--dsw-alias-state-success-label, #1a7f37)",
|
|
218
|
-
background: "var(--dsw-alias-state-success-bg, rgba(26,127,55,0.10))"
|
|
219
|
-
};
|
|
220
|
-
if (status === "closed") return {
|
|
221
|
-
color: "var(--dsw-alias-label-tertiary, #888)",
|
|
222
|
-
background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.04))"
|
|
223
|
-
};
|
|
224
|
-
return {
|
|
225
|
-
color: "var(--dsw-alias-state-warn-label, #9a6700)",
|
|
226
|
-
background: "var(--dsw-alias-state-warn-bg, rgba(154,103,0,0.10))"
|
|
227
|
-
};
|
|
2286
|
+
/** Dom id of one report's card, so the topology can scroll to it. */
|
|
2287
|
+
function rowDomId(report) {
|
|
2288
|
+
return `report-ledger-row-${report}`;
|
|
228
2289
|
}
|
|
2290
|
+
/**
|
|
2291
|
+
* How long the copy control keeps its "copied" label.
|
|
2292
|
+
*
|
|
2293
|
+
* The chat view uses the same one-second acknowledgement through the same
|
|
2294
|
+
* `writeClipboard`, so a copy here feels like a copy there.
|
|
2295
|
+
*/
|
|
2296
|
+
const COPIED_HOLD_MS = 1e3;
|
|
229
2297
|
/** Storage key for the remembered status filter. */
|
|
230
2298
|
const FILTER_KEY = "dsh.reportLedger.filter.v1";
|
|
231
2299
|
/** Read the remembered status filter, tolerating absent or damaged storage. */
|
|
@@ -248,13 +2316,22 @@ window.__ModuleLoader__.load({
|
|
|
248
2316
|
const { sessionId, t } = props;
|
|
249
2317
|
const [payload, setPayload] = (0, react.useState)(void 0);
|
|
250
2318
|
const [error, setError] = (0, react.useState)(void 0);
|
|
251
|
-
const [
|
|
2319
|
+
const [timelineNonce, setTimelineNonce] = (0, react.useState)(0);
|
|
2320
|
+
const [detailNonce, setDetailNonce] = (0, react.useState)(0);
|
|
252
2321
|
const [openReport, setOpenReport] = (0, react.useState)(void 0);
|
|
253
2322
|
const [detail, setDetail] = (0, react.useState)(void 0);
|
|
254
2323
|
const [detailError, setDetailError] = (0, react.useState)(void 0);
|
|
255
2324
|
const [status, setStatus] = (0, react.useState)(rememberedStatus);
|
|
256
2325
|
const [text, setText] = (0, react.useState)("");
|
|
257
2326
|
const [basis, setBasis] = (0, react.useState)("created");
|
|
2327
|
+
const [copiedPath, setCopiedPath] = (0, react.useState)(false);
|
|
2328
|
+
const copyTimer = (0, react.useRef)(void 0);
|
|
2329
|
+
const [showTopology, setShowTopology] = (0, react.useState)(true);
|
|
2330
|
+
/** Set when the topology asks for a card, so the list can scroll to it once. */
|
|
2331
|
+
const scrollTo = (0, react.useRef)(void 0);
|
|
2332
|
+
(0, react.useEffect)(() => () => {
|
|
2333
|
+
if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
|
|
2334
|
+
}, []);
|
|
258
2335
|
(0, react.useEffect)(() => {
|
|
259
2336
|
try {
|
|
260
2337
|
globalThis.localStorage?.setItem(FILTER_KEY, JSON.stringify({ status }));
|
|
@@ -275,7 +2352,23 @@ window.__ModuleLoader__.load({
|
|
|
275
2352
|
return () => {
|
|
276
2353
|
cancelled = true;
|
|
277
2354
|
};
|
|
278
|
-
}, [sessionId,
|
|
2355
|
+
}, [sessionId, timelineNonce]);
|
|
2356
|
+
(0, react.useEffect)(() => {
|
|
2357
|
+
if (sessionId === void 0 || sessionId === "") return void 0;
|
|
2358
|
+
const tick = () => {
|
|
2359
|
+
if (globalThis.document?.visibilityState === "hidden") return;
|
|
2360
|
+
setTimelineNonce((value) => value + 1);
|
|
2361
|
+
};
|
|
2362
|
+
const timer = globalThis.setInterval(tick, POLL_INTERVAL_MS);
|
|
2363
|
+
const onVisibility = () => {
|
|
2364
|
+
if (globalThis.document?.visibilityState !== "hidden") tick();
|
|
2365
|
+
};
|
|
2366
|
+
globalThis.document?.addEventListener("visibilitychange", onVisibility);
|
|
2367
|
+
return () => {
|
|
2368
|
+
globalThis.clearInterval(timer);
|
|
2369
|
+
globalThis.document?.removeEventListener("visibilitychange", onVisibility);
|
|
2370
|
+
};
|
|
2371
|
+
}, [sessionId]);
|
|
279
2372
|
(0, react.useEffect)(() => {
|
|
280
2373
|
if (openReport === void 0) return;
|
|
281
2374
|
let cancelled = false;
|
|
@@ -289,11 +2382,24 @@ window.__ModuleLoader__.load({
|
|
|
289
2382
|
return () => {
|
|
290
2383
|
cancelled = true;
|
|
291
2384
|
};
|
|
292
|
-
}, [openReport,
|
|
2385
|
+
}, [openReport, detailNonce]);
|
|
293
2386
|
const rows = (0, react.useMemo)(() => payload === void 0 ? [] : buildRows(payload, basis), [payload, basis]);
|
|
294
2387
|
const counts = (0, react.useMemo)(() => statusCounts(payload?.reports ?? []), [payload]);
|
|
295
2388
|
const inList = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Set() : reportIds(payload), [payload]);
|
|
2389
|
+
const lanes = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : laneIndex(payload), [payload]);
|
|
296
2390
|
const shownBody = (0, react.useMemo)(() => displayBody(detail?.body ?? ""), [detail]);
|
|
2391
|
+
const bodyText = (0, react.useMemo)(() => {
|
|
2392
|
+
const text = shownBody.text.trim();
|
|
2393
|
+
return shownBody.truncated ? closeOpenFence(text) : text;
|
|
2394
|
+
}, [shownBody]);
|
|
2395
|
+
/** Strings for the prose renderer's own controls, from this plugin's dictionary. */
|
|
2396
|
+
const markdownLabels = (0, react.useMemo)(() => ({
|
|
2397
|
+
code: {
|
|
2398
|
+
copyLabel: t("body.copy"),
|
|
2399
|
+
copiedLabel: t("body.copied")
|
|
2400
|
+
},
|
|
2401
|
+
footnotes: t("body.footnotes")
|
|
2402
|
+
}), [t]);
|
|
297
2403
|
const filter = (0, react.useMemo)(() => ({
|
|
298
2404
|
status,
|
|
299
2405
|
text
|
|
@@ -301,18 +2407,13 @@ window.__ModuleLoader__.load({
|
|
|
301
2407
|
const visible = (0, react.useMemo)(() => {
|
|
302
2408
|
const kept = filterRows(rows, filter);
|
|
303
2409
|
if (openReport === void 0 || detail === void 0) return kept;
|
|
304
|
-
|
|
305
|
-
return [...kept, {
|
|
306
|
-
kind: "report",
|
|
307
|
-
at: detail.front.created,
|
|
308
|
-
depth: 0,
|
|
309
|
-
front: detail.front
|
|
310
|
-
}];
|
|
2410
|
+
return withSynthesizedRow(kept, detail.front, basis);
|
|
311
2411
|
}, [
|
|
312
2412
|
rows,
|
|
313
2413
|
filter,
|
|
314
2414
|
openReport,
|
|
315
|
-
detail
|
|
2415
|
+
detail,
|
|
2416
|
+
basis
|
|
316
2417
|
]);
|
|
317
2418
|
const filteredReportIds = (0, react.useMemo)(() => {
|
|
318
2419
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -321,8 +2422,75 @@ window.__ModuleLoader__.load({
|
|
|
321
2422
|
}, [rows, filter]);
|
|
322
2423
|
const filtering = isFiltering(filter);
|
|
323
2424
|
const visibleReportCount = (0, react.useMemo)(() => visibleReports(rows, filter), [rows, filter]);
|
|
2425
|
+
const empty = (0, react.useMemo)(() => payload === void 0 ? "none" : emptyState(payload, rows, filter), [
|
|
2426
|
+
payload,
|
|
2427
|
+
rows,
|
|
2428
|
+
filter
|
|
2429
|
+
]);
|
|
2430
|
+
const titles = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : sessionTitles(payload), [payload]);
|
|
2431
|
+
const idHint = (0, react.useCallback)((id) => {
|
|
2432
|
+
const title = titles.get(id);
|
|
2433
|
+
return title === void 0 ? id : `${title} · ${id}`;
|
|
2434
|
+
}, [titles]);
|
|
2435
|
+
/**
|
|
2436
|
+
* The reports the topology plots.
|
|
2437
|
+
*
|
|
2438
|
+
* Taken from the same filtered row set the list renders, so the graph is a view
|
|
2439
|
+
* of the current filter rather than a second source of truth — narrowing the
|
|
2440
|
+
* toolbar narrows both.
|
|
2441
|
+
*/
|
|
2442
|
+
const plotted = (0, react.useMemo)(() => filterRows(rows, filter).flatMap((row) => row.kind === "report" ? [row.front] : []), [rows, filter]);
|
|
2443
|
+
/**
|
|
2444
|
+
* One session id the way a dense row shows it: shortened, with the full value
|
|
2445
|
+
* and the session title on hover. The hover is not decoration — it is where
|
|
2446
|
+
* the human name lives, because titles are unbounded and a row is not.
|
|
2447
|
+
*/
|
|
2448
|
+
const idRef = (id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2449
|
+
label: idHint(id),
|
|
2450
|
+
side: "bottom",
|
|
2451
|
+
delayMs: 400,
|
|
2452
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: shortId(id) })
|
|
2453
|
+
});
|
|
324
2454
|
const refresh = (0, react.useCallback)(() => {
|
|
325
|
-
|
|
2455
|
+
setTimelineNonce((value) => value + 1);
|
|
2456
|
+
setDetailNonce((value) => value + 1);
|
|
2457
|
+
}, []);
|
|
2458
|
+
/**
|
|
2459
|
+
* Open a report from the topology.
|
|
2460
|
+
*
|
|
2461
|
+
* The graph does not get its own detail surface: it opens the card in the list
|
|
2462
|
+
* and brings it into view, which keeps one reading surface for the body and the
|
|
2463
|
+
* transfer path — and inherits the card's guarantees for free (a filtered-out
|
|
2464
|
+
* report still gets a row to render into).
|
|
2465
|
+
*/
|
|
2466
|
+
const openFromTopology = (0, react.useCallback)((report) => {
|
|
2467
|
+
scrollTo.current = report;
|
|
2468
|
+
setOpenReport(report);
|
|
2469
|
+
}, []);
|
|
2470
|
+
(0, react.useEffect)(() => {
|
|
2471
|
+
const target = scrollTo.current;
|
|
2472
|
+
if (target === void 0 || openReport !== target) return;
|
|
2473
|
+
scrollTo.current = void 0;
|
|
2474
|
+
globalThis.document?.getElementById(rowDomId(target))?.scrollIntoView({
|
|
2475
|
+
block: "center",
|
|
2476
|
+
behavior: "smooth"
|
|
2477
|
+
});
|
|
2478
|
+
}, [openReport]);
|
|
2479
|
+
/**
|
|
2480
|
+
* Copy the ledger path, acknowledging through the control's own label.
|
|
2481
|
+
*
|
|
2482
|
+
* Uses the shell's `writeClipboard` rather than `navigator.clipboard`, so the
|
|
2483
|
+
* plugin inherits whatever fallback chain the rest of the GUI relies on.
|
|
2484
|
+
*/
|
|
2485
|
+
const copyPath = (0, react.useCallback)((path) => {
|
|
2486
|
+
(0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(path).then((ok) => {
|
|
2487
|
+
if (!ok) return;
|
|
2488
|
+
setCopiedPath(true);
|
|
2489
|
+
if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
|
|
2490
|
+
copyTimer.current = globalThis.setTimeout(() => {
|
|
2491
|
+
setCopiedPath(false);
|
|
2492
|
+
}, COPIED_HOLD_MS);
|
|
2493
|
+
});
|
|
326
2494
|
}, []);
|
|
327
2495
|
const shell = {
|
|
328
2496
|
padding: "16px 20px",
|
|
@@ -342,24 +2510,17 @@ window.__ModuleLoader__.load({
|
|
|
342
2510
|
fontSize: "var(--dsw-font-xxs-12, 12)",
|
|
343
2511
|
flex: "1 1 auto"
|
|
344
2512
|
};
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
2513
|
+
/**
|
|
2514
|
+
* The toolbar's search seat.
|
|
2515
|
+
*
|
|
2516
|
+
* `Input` renders its own wrapper around the field, so the flex sizing lives
|
|
2517
|
+
* on this outer box rather than on the control itself.
|
|
2518
|
+
*/
|
|
2519
|
+
const searchBox = {
|
|
2520
|
+
flex: "1 1 200px",
|
|
2521
|
+
minWidth: "140px",
|
|
2522
|
+
display: "flex"
|
|
353
2523
|
};
|
|
354
|
-
const chip = (extra) => ({
|
|
355
|
-
display: "inline-block",
|
|
356
|
-
padding: "0 6px",
|
|
357
|
-
borderRadius: "999px",
|
|
358
|
-
fontSize: "var(--dsw-font-xxxs-11, 11)",
|
|
359
|
-
lineHeight: "17px",
|
|
360
|
-
marginRight: "6px",
|
|
361
|
-
...extra
|
|
362
|
-
});
|
|
363
2524
|
const tools = {
|
|
364
2525
|
display: "flex",
|
|
365
2526
|
alignItems: "center",
|
|
@@ -367,21 +2528,88 @@ window.__ModuleLoader__.load({
|
|
|
367
2528
|
gap: "6px",
|
|
368
2529
|
marginBottom: "10px"
|
|
369
2530
|
};
|
|
2531
|
+
/** Chips sit next to a label; the shell's tags carry no spacing of their own. */
|
|
2532
|
+
const tagRow = {
|
|
2533
|
+
display: "inline-flex",
|
|
2534
|
+
gap: "4px",
|
|
2535
|
+
marginRight: "6px",
|
|
2536
|
+
verticalAlign: "baseline"
|
|
2537
|
+
};
|
|
2538
|
+
/** Keeps the state dot on the row's text baseline. */
|
|
2539
|
+
const dotCell = {
|
|
2540
|
+
display: "inline-flex",
|
|
2541
|
+
alignItems: "center"
|
|
2542
|
+
};
|
|
370
2543
|
const time = {
|
|
371
2544
|
color: "var(--dsw-alias-label-caption, #999)",
|
|
372
2545
|
fontSize: "var(--dsw-font-xxxs-11, 11)",
|
|
373
2546
|
whiteSpace: "nowrap"
|
|
374
2547
|
};
|
|
2548
|
+
/**
|
|
2549
|
+
* The same caption look, for content that must be allowed to WRAP.
|
|
2550
|
+
*
|
|
2551
|
+
* `time` sets `white-space: nowrap`, which is right for a timestamp and wrong
|
|
2552
|
+
* for anything whose length the ledger decides. A nowrap element has a
|
|
2553
|
+
* min-content width equal to its whole unbreakable line, so inside a `1fr`
|
|
2554
|
+
* grid track — or a flex item without `min-width: 0` — it grows the track past
|
|
2555
|
+
* its container. Recipient lists and hop notes are exactly that: unbounded
|
|
2556
|
+
* text, which is how the transfer path used to push the whole tab into a
|
|
2557
|
+
* horizontal scrollbar and cut the notes off screen.
|
|
2558
|
+
*/
|
|
2559
|
+
const wrap = {
|
|
2560
|
+
...time,
|
|
2561
|
+
whiteSpace: "normal",
|
|
2562
|
+
minWidth: 0,
|
|
2563
|
+
overflowWrap: "anywhere"
|
|
2564
|
+
};
|
|
2565
|
+
/** The muted caption colour, for the empty-state sentences. */
|
|
2566
|
+
const muted = { color: "var(--dsw-alias-label-tertiary, #888)" };
|
|
2567
|
+
/** The frame the rendered body sits in — a block, not a scroll box. */
|
|
2568
|
+
const bodyFrame = {
|
|
2569
|
+
padding: "8px 10px",
|
|
2570
|
+
background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
|
|
2571
|
+
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
|
|
2572
|
+
borderRadius: "4px"
|
|
2573
|
+
};
|
|
2574
|
+
/** The transcript's footer line: ledger path plus its copy control. */
|
|
2575
|
+
const pathRow = {
|
|
2576
|
+
...wrap,
|
|
2577
|
+
marginTop: "6px",
|
|
2578
|
+
display: "flex",
|
|
2579
|
+
alignItems: "baseline",
|
|
2580
|
+
gap: "6px"
|
|
2581
|
+
};
|
|
2582
|
+
/** The topology's frame: an overview that sits above the list, not instead of it. */
|
|
2583
|
+
const topoSection = {
|
|
2584
|
+
marginBottom: "12px",
|
|
2585
|
+
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
|
|
2586
|
+
borderRadius: "6px",
|
|
2587
|
+
background: "var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.02))",
|
|
2588
|
+
overflow: "hidden"
|
|
2589
|
+
};
|
|
2590
|
+
const topoHead = {
|
|
2591
|
+
display: "flex",
|
|
2592
|
+
alignItems: "center",
|
|
2593
|
+
gap: "10px",
|
|
2594
|
+
padding: "6px 10px",
|
|
2595
|
+
borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
2596
|
+
};
|
|
375
2597
|
if (error !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
376
2598
|
style: shell,
|
|
377
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
378
|
-
style: {
|
|
379
|
-
|
|
2599
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2600
|
+
style: {
|
|
2601
|
+
display: "flex",
|
|
2602
|
+
alignItems: "center",
|
|
2603
|
+
gap: "6px"
|
|
2604
|
+
},
|
|
2605
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
2606
|
+
state: "error",
|
|
2607
|
+
size: 8
|
|
2608
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("view.error", { code: error }) })]
|
|
380
2609
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
381
2610
|
style: { marginTop: "8px" },
|
|
382
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
383
|
-
|
|
384
|
-
style: button,
|
|
2611
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2612
|
+
variant: "outline",
|
|
385
2613
|
onClick: refresh,
|
|
386
2614
|
children: t("view.retry")
|
|
387
2615
|
})
|
|
@@ -420,9 +2648,9 @@ window.__ModuleLoader__.load({
|
|
|
420
2648
|
reports: payload.reports.length
|
|
421
2649
|
})
|
|
422
2650
|
}),
|
|
423
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
424
|
-
|
|
425
|
-
|
|
2651
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2652
|
+
variant: "outline",
|
|
2653
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, { size: 16 }),
|
|
426
2654
|
onClick: refresh,
|
|
427
2655
|
children: t("view.refresh")
|
|
428
2656
|
})
|
|
@@ -437,72 +2665,79 @@ window.__ModuleLoader__.load({
|
|
|
437
2665
|
"open",
|
|
438
2666
|
"acked",
|
|
439
2667
|
"closed"
|
|
440
|
-
].map((candidate) => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
onClick: () => {
|
|
446
|
-
setStatus(candidate);
|
|
447
|
-
},
|
|
448
|
-
style: {
|
|
449
|
-
...button,
|
|
450
|
-
...active ? {
|
|
451
|
-
color: "var(--dsw-alias-label-primary, inherit)",
|
|
452
|
-
background: "var(--dsw-alias-interactive-bg-active, rgba(0,0,0,0.08))",
|
|
453
|
-
borderColor: "var(--dsw-alias-border-l3, rgba(0,0,0,0.20))"
|
|
454
|
-
} : {}
|
|
455
|
-
},
|
|
456
|
-
children: `${t(statusLabel[candidate])} ${statusCount[candidate]}`
|
|
457
|
-
}, candidate);
|
|
458
|
-
}),
|
|
459
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
460
|
-
type: "search",
|
|
461
|
-
value: text,
|
|
462
|
-
placeholder: t("filter.search"),
|
|
463
|
-
"aria-label": t("filter.search"),
|
|
464
|
-
onChange: (event) => {
|
|
465
|
-
setText(event.target.value);
|
|
2668
|
+
].map((candidate) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
2669
|
+
active: status === candidate,
|
|
2670
|
+
"aria-pressed": status === candidate,
|
|
2671
|
+
onClick: () => {
|
|
2672
|
+
setStatus(candidate);
|
|
466
2673
|
},
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
2674
|
+
children: `${t(statusLabel[candidate])} ${statusCount[candidate]}`
|
|
2675
|
+
}, candidate)),
|
|
2676
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2677
|
+
style: searchBox,
|
|
2678
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
2679
|
+
type: "search",
|
|
2680
|
+
className: SEARCH_CLASS,
|
|
2681
|
+
value: text,
|
|
2682
|
+
placeholder: t("filter.search"),
|
|
2683
|
+
"aria-label": t("filter.search"),
|
|
2684
|
+
onChange: (event) => {
|
|
2685
|
+
setText(event.target.value);
|
|
2686
|
+
}
|
|
2687
|
+
})
|
|
477
2688
|
}),
|
|
478
|
-
filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
479
|
-
|
|
480
|
-
style: button,
|
|
2689
|
+
filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2690
|
+
variant: "outline",
|
|
481
2691
|
onClick: () => {
|
|
482
2692
|
setStatus("all");
|
|
483
2693
|
setText("");
|
|
484
2694
|
},
|
|
485
2695
|
children: t("filter.clear")
|
|
486
2696
|
}) : null,
|
|
487
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
2697
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2698
|
+
label: t("basis.hint"),
|
|
2699
|
+
side: "bottom",
|
|
2700
|
+
delayMs: 400,
|
|
2701
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2702
|
+
variant: "outline",
|
|
2703
|
+
onClick: () => {
|
|
2704
|
+
setBasis(basis === "created" ? "updated" : "created");
|
|
2705
|
+
},
|
|
2706
|
+
children: t(basis === "created" ? "basis.created" : "basis.updated")
|
|
2707
|
+
})
|
|
495
2708
|
})
|
|
496
2709
|
]
|
|
497
2710
|
}),
|
|
498
|
-
|
|
499
|
-
style:
|
|
2711
|
+
empty === "no-reports" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2712
|
+
style: muted,
|
|
500
2713
|
children: t("view.empty")
|
|
501
2714
|
}) : null,
|
|
502
|
-
|
|
503
|
-
style:
|
|
2715
|
+
empty === "filtered-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2716
|
+
style: muted,
|
|
504
2717
|
children: t("filter.none")
|
|
505
2718
|
}) : null,
|
|
2719
|
+
payload !== void 0 && plotted.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
2720
|
+
style: topoSection,
|
|
2721
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2722
|
+
style: topoHead,
|
|
2723
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("topology.title") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2724
|
+
variant: "ghost",
|
|
2725
|
+
size: "sm",
|
|
2726
|
+
icon: showTopology ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 }),
|
|
2727
|
+
onClick: () => {
|
|
2728
|
+
setShowTopology((value) => !value);
|
|
2729
|
+
},
|
|
2730
|
+
children: showTopology ? t("topology.hide") : t("topology.show")
|
|
2731
|
+
})]
|
|
2732
|
+
}), showTopology ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardgraphView, {
|
|
2733
|
+
payload,
|
|
2734
|
+
reports: plotted,
|
|
2735
|
+
t,
|
|
2736
|
+
idHint,
|
|
2737
|
+
openReport,
|
|
2738
|
+
onOpen: openFromTopology
|
|
2739
|
+
}) : null]
|
|
2740
|
+
}) : null,
|
|
506
2741
|
visible.map((row, index) => {
|
|
507
2742
|
const pad = 8 + row.depth * 18;
|
|
508
2743
|
if (row.kind === "session") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -513,29 +2748,42 @@ window.__ModuleLoader__.load({
|
|
|
513
2748
|
alignItems: "baseline"
|
|
514
2749
|
},
|
|
515
2750
|
children: [
|
|
516
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
517
|
-
|
|
518
|
-
|
|
2751
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2752
|
+
label: idHint(row.id),
|
|
2753
|
+
side: "bottom",
|
|
2754
|
+
delayMs: 400,
|
|
2755
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2756
|
+
style: dotCell,
|
|
2757
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
2758
|
+
state: row.live ? "ongoing" : "idle",
|
|
2759
|
+
size: 8
|
|
2760
|
+
})
|
|
2761
|
+
})
|
|
519
2762
|
}),
|
|
520
2763
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
521
|
-
style: {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
2764
|
+
style: {
|
|
2765
|
+
flex: "1 1 auto",
|
|
2766
|
+
minWidth: 0
|
|
2767
|
+
},
|
|
2768
|
+
children: [row.delegated || row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2769
|
+
style: tagRow,
|
|
2770
|
+
children: [row.delegated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
2771
|
+
tone: "info",
|
|
528
2772
|
children: t("view.delegated")
|
|
529
|
-
}) : null,
|
|
530
|
-
|
|
531
|
-
style: chip({
|
|
532
|
-
color: "var(--dsw-alias-state-success-label, #1a7f37)",
|
|
533
|
-
background: "var(--dsw-alias-state-success-bg, rgba(26,127,55,0.10))"
|
|
534
|
-
}),
|
|
2773
|
+
}) : null, row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
2774
|
+
tone: "success",
|
|
535
2775
|
children: t("view.live")
|
|
536
|
-
}) : null
|
|
537
|
-
|
|
538
|
-
|
|
2776
|
+
}) : null]
|
|
2777
|
+
}) : null, row.title ?? shortId(row.id)]
|
|
2778
|
+
}),
|
|
2779
|
+
row.title === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2780
|
+
label: row.id,
|
|
2781
|
+
side: "right",
|
|
2782
|
+
delayMs: 400,
|
|
2783
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2784
|
+
style: time,
|
|
2785
|
+
children: shortId(row.id)
|
|
2786
|
+
})
|
|
539
2787
|
}),
|
|
540
2788
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
541
2789
|
style: time,
|
|
@@ -545,20 +2793,17 @@ window.__ModuleLoader__.load({
|
|
|
545
2793
|
}, `s-${row.id}-${index}`);
|
|
546
2794
|
const front = row.front;
|
|
547
2795
|
const expanded = openReport === front.report;
|
|
2796
|
+
const panelId = `report-ledger-panel-${front.report}`;
|
|
2797
|
+
const wtLabel = worktreeLabel(front.worktree);
|
|
2798
|
+
const wtLane = lanes.get(front.report);
|
|
2799
|
+
const toggleLabel = fill(expanded ? t("row.collapse") : t("row.expand"), { report: front.report });
|
|
548
2800
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2801
|
+
id: rowDomId(front.report),
|
|
549
2802
|
style: { paddingLeft: `${pad}px` },
|
|
550
2803
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
551
|
-
role: "button",
|
|
552
|
-
tabIndex: 0,
|
|
553
2804
|
onClick: () => {
|
|
554
2805
|
setOpenReport(expanded ? void 0 : front.report);
|
|
555
2806
|
},
|
|
556
|
-
onKeyDown: (event) => {
|
|
557
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
558
|
-
event.preventDefault();
|
|
559
|
-
setOpenReport(expanded ? void 0 : front.report);
|
|
560
|
-
}
|
|
561
|
-
},
|
|
562
2807
|
style: {
|
|
563
2808
|
display: "flex",
|
|
564
2809
|
gap: "8px",
|
|
@@ -571,50 +2816,72 @@ window.__ModuleLoader__.load({
|
|
|
571
2816
|
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
572
2817
|
},
|
|
573
2818
|
children: [
|
|
574
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
575
|
-
|
|
576
|
-
|
|
2819
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2820
|
+
variant: "ghost",
|
|
2821
|
+
size: "sm",
|
|
2822
|
+
icon: expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 }),
|
|
2823
|
+
"aria-expanded": expanded,
|
|
2824
|
+
"aria-controls": panelId,
|
|
2825
|
+
"aria-label": toggleLabel,
|
|
2826
|
+
title: toggleLabel,
|
|
2827
|
+
onClick: (event) => {
|
|
2828
|
+
event.stopPropagation();
|
|
2829
|
+
setOpenReport(expanded ? void 0 : front.report);
|
|
2830
|
+
}
|
|
577
2831
|
}),
|
|
578
2832
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
579
|
-
style: {
|
|
2833
|
+
style: {
|
|
2834
|
+
flex: "1 1 auto",
|
|
2835
|
+
minWidth: 0
|
|
2836
|
+
},
|
|
580
2837
|
children: [
|
|
581
2838
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
582
|
-
style:
|
|
583
|
-
children:
|
|
2839
|
+
style: tagRow,
|
|
2840
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
2841
|
+
tone: STATUS_TONE[front.status],
|
|
2842
|
+
children: t(STATUS_LABEL[front.status])
|
|
2843
|
+
})
|
|
584
2844
|
}),
|
|
585
2845
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: front.report }),
|
|
586
2846
|
" ",
|
|
587
2847
|
front.subject,
|
|
588
|
-
front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
589
|
-
type: "button",
|
|
2848
|
+
front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
590
2849
|
title: fill(t("row.filterByTask"), { task: front.task }),
|
|
591
2850
|
onClick: (event) => {
|
|
592
2851
|
event.stopPropagation();
|
|
2852
|
+
setStatus("all");
|
|
593
2853
|
setText(front.task);
|
|
594
2854
|
},
|
|
595
|
-
style: {
|
|
596
|
-
...chip({
|
|
597
|
-
color: "var(--dsw-alias-state-business-label, #0969da)",
|
|
598
|
-
background: "var(--dsw-alias-state-business-bg, rgba(9,105,218,0.10))"
|
|
599
|
-
}),
|
|
600
|
-
border: 0,
|
|
601
|
-
cursor: "pointer",
|
|
602
|
-
font: "inherit"
|
|
603
|
-
},
|
|
604
2855
|
children: front.task
|
|
605
2856
|
}),
|
|
2857
|
+
wtLabel === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
2858
|
+
tone: LEASE_TONE[wtLane?.state ?? "none"],
|
|
2859
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2860
|
+
title: `${fill(t("row.filterByWorktree"), { worktree: wtLabel })} — ${t(LEASE_HINT[wtLane?.state ?? "none"])}`,
|
|
2861
|
+
style: { cursor: "pointer" },
|
|
2862
|
+
onClick: (event) => {
|
|
2863
|
+
event.stopPropagation();
|
|
2864
|
+
setStatus("all");
|
|
2865
|
+
setText(wtLabel);
|
|
2866
|
+
},
|
|
2867
|
+
children: wtLabel
|
|
2868
|
+
})
|
|
2869
|
+
}),
|
|
606
2870
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
607
2871
|
style: {
|
|
608
2872
|
...time,
|
|
609
|
-
marginTop: "2px"
|
|
2873
|
+
marginTop: "2px",
|
|
2874
|
+
overflow: "hidden",
|
|
2875
|
+
textOverflow: "ellipsis"
|
|
610
2876
|
},
|
|
611
2877
|
children: [
|
|
612
|
-
`from
|
|
613
|
-
front.
|
|
614
|
-
front.
|
|
2878
|
+
`from=`,
|
|
2879
|
+
idRef(front.from),
|
|
2880
|
+
front.to.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" → ", front.to.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [index > 0 ? ", " : "", idRef(id)] }, id))] }) : null,
|
|
2881
|
+
front.cc.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" cc ", front.cc.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [index > 0 ? ", " : "", idRef(id)] }, id))] }) : null,
|
|
615
2882
|
` · ${fill(t("row.hops"), { count: front.hops })}`,
|
|
616
2883
|
front.authors.length > 1 ? ` · ${fill(t("row.authors"), { count: front.authors.length })}` : "",
|
|
617
|
-
front.last === void 0 ? "" : ` · last
|
|
2884
|
+
front.last === void 0 ? "" : ` · ${t("row.last")} ${t(HOP_LABEL[front.last.action])}`
|
|
618
2885
|
]
|
|
619
2886
|
})
|
|
620
2887
|
]
|
|
@@ -625,6 +2892,7 @@ window.__ModuleLoader__.load({
|
|
|
625
2892
|
})
|
|
626
2893
|
]
|
|
627
2894
|
}), expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2895
|
+
id: panelId,
|
|
628
2896
|
style: {
|
|
629
2897
|
margin: "4px 0 10px 24px",
|
|
630
2898
|
padding: "10px 12px",
|
|
@@ -661,40 +2929,53 @@ window.__ModuleLoader__.load({
|
|
|
661
2929
|
style: time,
|
|
662
2930
|
children: stamp(hop.at)
|
|
663
2931
|
}),
|
|
664
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: hop.action }) }),
|
|
2932
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t(HOP_LABEL[hop.action]) }) }),
|
|
665
2933
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
666
|
-
style:
|
|
667
|
-
children:
|
|
2934
|
+
style: wrap,
|
|
2935
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2936
|
+
label: idHint(hop.actor),
|
|
2937
|
+
side: "bottom",
|
|
2938
|
+
delayMs: 400,
|
|
2939
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hop.actor })
|
|
2940
|
+
})
|
|
668
2941
|
}),
|
|
669
2942
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
670
|
-
style:
|
|
2943
|
+
style: wrap,
|
|
671
2944
|
children: hop.to.length === 0 ? hop.note ?? "" : `→ ${hop.to.join(", ")}${hop.note === void 0 ? "" : ` (${hop.note})`}`
|
|
672
2945
|
})
|
|
673
2946
|
] }, `h-${hopIndex}`))
|
|
674
2947
|
}),
|
|
675
|
-
detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.
|
|
2948
|
+
detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
676
2949
|
style: {
|
|
677
|
-
...
|
|
678
|
-
|
|
2950
|
+
...wrap,
|
|
2951
|
+
display: "flex",
|
|
2952
|
+
alignItems: "baseline",
|
|
2953
|
+
gap: "6px"
|
|
679
2954
|
},
|
|
680
|
-
children:
|
|
2955
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
2956
|
+
state: "warning",
|
|
2957
|
+
size: 8
|
|
2958
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: fill(t("view.detail.pending"), { targets: detail.pending.join(", ") }) })]
|
|
681
2959
|
}),
|
|
682
2960
|
(() => {
|
|
683
2961
|
const parent = detail.front.parent;
|
|
684
2962
|
const children = detail.front.children;
|
|
685
2963
|
if (parent === void 0 && children.length === 0) return null;
|
|
686
|
-
const link = (target) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
2964
|
+
const link = (target) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2965
|
+
style: { marginRight: "6px" },
|
|
2966
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
2967
|
+
label: fill(t("detail.openLinked"), { report: target }),
|
|
2968
|
+
side: "bottom",
|
|
2969
|
+
delayMs: 400,
|
|
2970
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
2971
|
+
variant: "outline",
|
|
2972
|
+
size: "sm",
|
|
2973
|
+
onClick: () => {
|
|
2974
|
+
setOpenReport(target);
|
|
2975
|
+
},
|
|
2976
|
+
children: target
|
|
2977
|
+
})
|
|
2978
|
+
})
|
|
698
2979
|
}, target);
|
|
699
2980
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
700
2981
|
style: { marginBottom: "8px" },
|
|
@@ -719,15 +3000,14 @@ window.__ModuleLoader__.load({
|
|
|
719
3000
|
})(),
|
|
720
3001
|
inList.has(detail.front.report) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
721
3002
|
style: {
|
|
722
|
-
...
|
|
3003
|
+
...wrap,
|
|
723
3004
|
marginBottom: "8px"
|
|
724
3005
|
},
|
|
725
3006
|
children: [
|
|
726
3007
|
fill(t("detail.outside"), { report: detail.front.report }),
|
|
727
3008
|
" ",
|
|
728
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
729
|
-
|
|
730
|
-
style: button,
|
|
3009
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3010
|
+
variant: "outline",
|
|
731
3011
|
onClick: () => {
|
|
732
3012
|
setOpenReport(void 0);
|
|
733
3013
|
},
|
|
@@ -737,7 +3017,7 @@ window.__ModuleLoader__.load({
|
|
|
737
3017
|
}),
|
|
738
3018
|
inList.has(detail.front.report) && !filteredReportIds.has(detail.front.report) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
739
3019
|
style: {
|
|
740
|
-
...
|
|
3020
|
+
...wrap,
|
|
741
3021
|
marginBottom: "8px"
|
|
742
3022
|
},
|
|
743
3023
|
children: fill(t("detail.filteredOut"), { report: detail.front.report })
|
|
@@ -746,38 +3026,49 @@ window.__ModuleLoader__.load({
|
|
|
746
3026
|
style: { margin: "8px 0 4px" },
|
|
747
3027
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.body") })
|
|
748
3028
|
}),
|
|
749
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
750
|
-
style:
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
whiteSpace: "pre-wrap",
|
|
756
|
-
wordBreak: "break-word",
|
|
757
|
-
background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
|
|
758
|
-
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
|
|
759
|
-
borderRadius: "4px",
|
|
760
|
-
font: "var(--dsw-font-xs-13, 13px/1.5 ui-monospace, monospace)"
|
|
761
|
-
},
|
|
762
|
-
children: shownBody.text.trim()
|
|
3029
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3030
|
+
style: bodyFrame,
|
|
3031
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
|
|
3032
|
+
text: bodyText,
|
|
3033
|
+
labels: markdownLabels
|
|
3034
|
+
})
|
|
763
3035
|
}),
|
|
764
3036
|
shownBody.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
765
3037
|
style: {
|
|
766
|
-
...
|
|
3038
|
+
...wrap,
|
|
767
3039
|
marginTop: "4px"
|
|
768
3040
|
},
|
|
769
3041
|
children: fill(t("detail.bodyTruncated"), { chars: shownBody.text.trim().length })
|
|
770
3042
|
}) : null,
|
|
771
3043
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
772
|
-
style:
|
|
773
|
-
...time,
|
|
774
|
-
marginTop: "6px",
|
|
775
|
-
wordBreak: "break-all"
|
|
776
|
-
},
|
|
3044
|
+
style: pathRow,
|
|
777
3045
|
children: [
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
3046
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
3047
|
+
state: "idle",
|
|
3048
|
+
size: 6
|
|
3049
|
+
}),
|
|
3050
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3051
|
+
style: { wordBreak: "break-all" },
|
|
3052
|
+
children: [
|
|
3053
|
+
t("view.detail.path"),
|
|
3054
|
+
": ",
|
|
3055
|
+
detail.path
|
|
3056
|
+
]
|
|
3057
|
+
}),
|
|
3058
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
3059
|
+
label: copiedPath ? t("body.copied") : t("view.copyPath"),
|
|
3060
|
+
side: "bottom",
|
|
3061
|
+
delayMs: 300,
|
|
3062
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3063
|
+
variant: "ghost",
|
|
3064
|
+
size: "sm",
|
|
3065
|
+
icon: copiedPath ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, { size: 16 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutlineRegular, { size: 16 }),
|
|
3066
|
+
"aria-label": copiedPath ? t("body.copied") : t("view.copyPath"),
|
|
3067
|
+
onClick: () => {
|
|
3068
|
+
copyPath(detail.path);
|
|
3069
|
+
}
|
|
3070
|
+
})
|
|
3071
|
+
})
|
|
781
3072
|
]
|
|
782
3073
|
})
|
|
783
3074
|
] })
|
|
@@ -789,101 +3080,6 @@ window.__ModuleLoader__.load({
|
|
|
789
3080
|
});
|
|
790
3081
|
}
|
|
791
3082
|
//#endregion
|
|
792
|
-
//#region src/client/locales.ts
|
|
793
|
-
/**
|
|
794
|
-
* Dictionary for the report timeline tab.
|
|
795
|
-
*
|
|
796
|
-
* Both shipped locales are required by the client locale service, and the key
|
|
797
|
-
* union drives the typed `t()` the view uses, so a missing translation is a
|
|
798
|
-
* type error rather than an empty label.
|
|
799
|
-
*
|
|
800
|
-
* @module dsh-report-ledger/client/locales
|
|
801
|
-
*/
|
|
802
|
-
/** Chinese dictionary — the source of truth for the key set. */
|
|
803
|
-
const zh = {
|
|
804
|
-
"view.tab": "汇报",
|
|
805
|
-
"view.loading": "正在读取账本…",
|
|
806
|
-
"view.summary": "{sessions} 个会话 · {reports} 份汇报",
|
|
807
|
-
"view.empty": "这个会话树下还没有汇报。代理用 report_author 开一份汇报后,这里会出现它的缩略卡。",
|
|
808
|
-
"view.refresh": "刷新",
|
|
809
|
-
"view.retry": "重试",
|
|
810
|
-
"view.error": "读取失败:{code}",
|
|
811
|
-
"view.root": "本会话",
|
|
812
|
-
"view.live": "在线",
|
|
813
|
-
"view.delegated": "子代理",
|
|
814
|
-
"view.detail.loading": "正在读取传递路径…",
|
|
815
|
-
"view.detail.route": "传递路径",
|
|
816
|
-
"view.detail.routeEmpty": "(还没有记录跳)",
|
|
817
|
-
"view.detail.pending": "仍在等待送达:{targets}",
|
|
818
|
-
"view.detail.body": "正文",
|
|
819
|
-
"view.detail.path": "账本文件",
|
|
820
|
-
"row.hops": "{count} 跳",
|
|
821
|
-
"row.authors": "{count} 位共写",
|
|
822
|
-
"row.filterByTask": "只看任务「{task}」",
|
|
823
|
-
"filter.all": "全部",
|
|
824
|
-
"filter.open": "进行中",
|
|
825
|
-
"filter.acked": "已回执",
|
|
826
|
-
"filter.closed": "已结案",
|
|
827
|
-
"filter.search": "搜索主题、编号、会话或收发…",
|
|
828
|
-
"filter.clear": "清除",
|
|
829
|
-
"filter.showing": "显示 {visible}/{total} 份汇报",
|
|
830
|
-
"filter.none": "没有符合当前筛选的汇报。",
|
|
831
|
-
"filter.hint": "状态筛选只作用于汇报;搜索会同时匹配会话标题与编号。",
|
|
832
|
-
"basis.created": "按创建",
|
|
833
|
-
"basis.updated": "按最近活动",
|
|
834
|
-
"basis.hint": "汇报按创建时间还是按最后一次活动排入时间线;会话始终按创建时间。",
|
|
835
|
-
"detail.thread": "线程",
|
|
836
|
-
"detail.parent": "上溯",
|
|
837
|
-
"detail.children": "下递",
|
|
838
|
-
"detail.openLinked": "打开 {report}",
|
|
839
|
-
"detail.outside": "{report} 不在当前会话树的列表里(它可能属于另一条线);正文与路径仍可读取。",
|
|
840
|
-
"detail.filteredOut": "{report} 在当前筛选下被隐藏了(用「清除」恢复)。",
|
|
841
|
-
"detail.backToList": "回到列表",
|
|
842
|
-
"detail.bodyTruncated": "正文过长,此处只显示开头 {chars} 字;完整内容见账本文件。"
|
|
843
|
-
};
|
|
844
|
-
/** English dictionary, checked against the Chinese key set. */
|
|
845
|
-
const en = {
|
|
846
|
-
"view.tab": "Reports",
|
|
847
|
-
"view.loading": "Reading the ledger…",
|
|
848
|
-
"view.summary": "{sessions} sessions · {reports} reports",
|
|
849
|
-
"view.empty": "No reports in this session tree yet. Once an agent opens one with report_author, its digest card appears here.",
|
|
850
|
-
"view.refresh": "Refresh",
|
|
851
|
-
"view.retry": "Retry",
|
|
852
|
-
"view.error": "Read failed: {code}",
|
|
853
|
-
"view.root": "this session",
|
|
854
|
-
"view.live": "live",
|
|
855
|
-
"view.delegated": "subagent",
|
|
856
|
-
"view.detail.loading": "Reading the transfer path…",
|
|
857
|
-
"view.detail.route": "Transfer path",
|
|
858
|
-
"view.detail.routeEmpty": "(no hops recorded yet)",
|
|
859
|
-
"view.detail.pending": "Still owed to: {targets}",
|
|
860
|
-
"view.detail.body": "Body",
|
|
861
|
-
"view.detail.path": "Ledger file",
|
|
862
|
-
"row.hops": "{count} hops",
|
|
863
|
-
"row.authors": "{count} authors",
|
|
864
|
-
"row.filterByTask": "Show only task \"{task}\"",
|
|
865
|
-
"filter.all": "All",
|
|
866
|
-
"filter.open": "Open",
|
|
867
|
-
"filter.acked": "Acked",
|
|
868
|
-
"filter.closed": "Closed",
|
|
869
|
-
"filter.search": "Search subject, id, session, or people…",
|
|
870
|
-
"filter.clear": "Clear",
|
|
871
|
-
"filter.showing": "Showing {visible}/{total} reports",
|
|
872
|
-
"filter.none": "No reports match the current filter.",
|
|
873
|
-
"filter.hint": "The status filter narrows reports only; search matches every row, including session titles and ids.",
|
|
874
|
-
"basis.created": "By creation",
|
|
875
|
-
"basis.updated": "By activity",
|
|
876
|
-
"basis.hint": "Whether reports are placed at creation or at their latest activity; sessions always keep their creation time.",
|
|
877
|
-
"detail.thread": "Thread",
|
|
878
|
-
"detail.parent": "Answers",
|
|
879
|
-
"detail.children": "Answered by",
|
|
880
|
-
"detail.openLinked": "Open {report}",
|
|
881
|
-
"detail.outside": "{report} is not in this session tree's list (it may belong to another line); its body and path are still readable.",
|
|
882
|
-
"detail.filteredOut": "{report} is hidden by the current filter (use Clear to restore it).",
|
|
883
|
-
"detail.backToList": "Back to the list",
|
|
884
|
-
"detail.bodyTruncated": "Body is long, so only the first {chars} characters are shown here; the ledger file has the whole thing."
|
|
885
|
-
};
|
|
886
|
-
//#endregion
|
|
887
3083
|
//#region src/client/index.ts
|
|
888
3084
|
/**
|
|
889
3085
|
* Browser half of dsh-report-ledger — runs inside the dsh web GUI.
|
|
@@ -917,6 +3113,7 @@ window.__ModuleLoader__.load({
|
|
|
917
3113
|
zh,
|
|
918
3114
|
en
|
|
919
3115
|
}), "report-ledger: dictionaries");
|
|
3116
|
+
ctx.effect(() => installStyles(), "report-ledger: adapter styles");
|
|
920
3117
|
const t = ctx.locale.bind(NS);
|
|
921
3118
|
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
922
3119
|
name: "conversation.view",
|