@fluixi/devtools 0.2.0-alpha.1 → 0.2.0-alpha.3

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.
Files changed (96) hide show
  1. package/README.md +80 -2
  2. package/dist/callsite.d.ts +22 -0
  3. package/dist/callsite.d.ts.map +1 -0
  4. package/dist/callsite.js +82 -0
  5. package/dist/change-view.d.ts +10 -0
  6. package/dist/change-view.d.ts.map +1 -0
  7. package/dist/change-view.js +102 -0
  8. package/dist/copy.d.ts +9 -0
  9. package/dist/copy.d.ts.map +1 -0
  10. package/dist/copy.js +52 -0
  11. package/dist/describe.d.ts +3 -0
  12. package/dist/describe.d.ts.map +1 -0
  13. package/dist/describe.js +47 -0
  14. package/dist/detail-panel.d.ts +17 -0
  15. package/dist/detail-panel.d.ts.map +1 -0
  16. package/dist/detail-panel.js +217 -0
  17. package/dist/diff.d.ts +31 -0
  18. package/dist/diff.d.ts.map +1 -0
  19. package/dist/diff.js +132 -0
  20. package/dist/dom.d.ts +81 -0
  21. package/dist/dom.d.ts.map +1 -0
  22. package/dist/dom.js +269 -0
  23. package/dist/export.d.ts +55 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/export.js +90 -0
  26. package/dist/float.d.ts +54 -0
  27. package/dist/float.d.ts.map +1 -0
  28. package/dist/float.js +256 -0
  29. package/dist/format.d.ts +16 -0
  30. package/dist/format.d.ts.map +1 -0
  31. package/dist/format.js +188 -0
  32. package/dist/graph-view.d.ts +79 -2
  33. package/dist/graph-view.d.ts.map +1 -1
  34. package/dist/graph-view.js +866 -50
  35. package/dist/highlight.d.ts +56 -0
  36. package/dist/highlight.d.ts.map +1 -0
  37. package/dist/highlight.js +154 -0
  38. package/dist/history.d.ts +59 -0
  39. package/dist/history.d.ts.map +1 -0
  40. package/dist/history.js +85 -0
  41. package/dist/hook.d.ts +60 -0
  42. package/dist/hook.d.ts.map +1 -0
  43. package/dist/hook.js +142 -0
  44. package/dist/index.d.ts +44 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +25 -1
  47. package/dist/inspector.d.ts +60 -0
  48. package/dist/inspector.d.ts.map +1 -0
  49. package/dist/inspector.js +415 -0
  50. package/dist/instrument.d.ts +147 -5
  51. package/dist/instrument.d.ts.map +1 -1
  52. package/dist/instrument.js +164 -44
  53. package/dist/kind-filter.d.ts +39 -0
  54. package/dist/kind-filter.d.ts.map +1 -0
  55. package/dist/kind-filter.js +151 -0
  56. package/dist/menu.d.ts +37 -0
  57. package/dist/menu.d.ts.map +1 -0
  58. package/dist/menu.js +125 -0
  59. package/dist/observe.d.ts +80 -0
  60. package/dist/observe.d.ts.map +1 -0
  61. package/dist/observe.js +893 -0
  62. package/dist/palette.d.ts +11 -0
  63. package/dist/palette.d.ts.map +1 -0
  64. package/dist/palette.js +27 -0
  65. package/dist/path.d.ts +19 -0
  66. package/dist/path.d.ts.map +1 -0
  67. package/dist/path.js +49 -0
  68. package/dist/plugin.d.ts +121 -0
  69. package/dist/plugin.d.ts.map +1 -0
  70. package/dist/plugin.js +74 -0
  71. package/dist/plugins/context.d.ts +10 -0
  72. package/dist/plugins/context.d.ts.map +1 -0
  73. package/dist/plugins/context.js +80 -0
  74. package/dist/plugins/directives.d.ts +25 -0
  75. package/dist/plugins/directives.d.ts.map +1 -0
  76. package/dist/plugins/directives.js +78 -0
  77. package/dist/plugins/index.d.ts +7 -0
  78. package/dist/plugins/index.d.ts.map +1 -0
  79. package/dist/plugins/index.js +9 -0
  80. package/dist/source.d.ts +72 -0
  81. package/dist/source.d.ts.map +1 -0
  82. package/dist/source.js +153 -0
  83. package/dist/timeline.d.ts +30 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +271 -0
  86. package/dist/tree.d.ts +128 -0
  87. package/dist/tree.d.ts.map +1 -0
  88. package/dist/tree.js +645 -0
  89. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  90. package/dist/value-pane.d.ts +10 -0
  91. package/dist/value-pane.d.ts.map +1 -0
  92. package/dist/value-pane.js +61 -0
  93. package/dist/wire.d.ts +100 -0
  94. package/dist/wire.d.ts.map +1 -0
  95. package/dist/wire.js +93 -0
  96. package/package.json +10 -3
package/README.md CHANGED
@@ -9,7 +9,7 @@ import { createReactor, createGraphView, DEVTOOLS_CSS } from '@fluixi/devtools';
9
9
  const reactor = createReactor();
10
10
  reactor.run(({ createSignal, createMemo, createEffect }) => {
11
11
  const [count, setCount] = createSignal(0, { name: 'count' });
12
- const double = createMemo(() => count() * 2, { name: 'double' });
12
+ const double = createMemo(() => count() * 2, undefined, { name: 'double' });
13
13
  createEffect(() => console.log(double()), { name: 'log' });
14
14
  setInterval(() => setCount((n) => n + 1), 1000);
15
15
  });
@@ -21,13 +21,91 @@ tick();
21
21
 
22
22
  ## How it works
23
23
 
24
- The instrumented `signal`/`memo`/`effect` wrap the real `@fluixi/reactive` primitives. A read is a
24
+ The instrumented `createSignal`/`createMemo`/`createEffect` wrap the real `@fluixi/reactive`
25
+ primitives. A read is a
25
26
  subscription, so edges are recorded from actual reads — it's the genuine dependency graph, not a
26
27
  reconstruction. Values are re-read untracked each frame and effects count their own re-runs, so the
27
28
  inspector animates exactly as the core propagates.
28
29
 
30
+ ## Watching an app you did not write
31
+
32
+ createReactor hands out its own primitives, so it only sees code written against them. observeGraph() sees an ordinary app: the runtime reports each node as it is made, and the graph fills from plain `import { signal } from '@fluixi/core'`. Call it before the app's modules load — a node made earlier is not in the graph.
33
+
34
+ ```ts
35
+ import { observeGraph, createGraphView } from '@fluixi/devtools';
36
+ const observed = observeGraph();
37
+ await import('./src/main.js');
38
+ const view = createGraphView(svgEl);
39
+ const tick = () => { observed.sample(); view.update(observed.graph); requestAnimationFrame(tick); };
40
+ tick();
41
+ ```
42
+
43
+ The same Map<number, RNode> the reactor produces, so createGraphView and anything else reading the graph does not care which made it. Dependencies are read off the core node, which already keeps its own sources and observers, so nothing is wrapped and no read is intercepted.
44
+
45
+ No write edges, because nothing records which computation was running when a setter fired. Render effects are left out — the dom layer makes one per binding. An unwatched runtime pays one null check per node creation and per recompute.
46
+
47
+ ## Getting the graph somewhere else
48
+
49
+ serializeGraph flattens the graph to json — Sets become arrays, the live core node becomes version and state, the reader is dropped — and snapshotToRenderMap turns it back into something the view draws. sample() returns what moved, so a delta is the same call with those ids, and applySnapshot folds one into a graph already on screen.
50
+
51
+ installDevtoolsHook() is for a tool that cannot be imported by the app. It leaves a global behind; when the application's runtime loads it finds it and hands over its own registration function, so the graph is built from the app's copy of the framework rather than the tool's. It has to be installed before the app runs — a browser agent at document_start — because the runtime offers once. It works against a production build too, without names or locations.
52
+
53
+ ```js
54
+ import { installDevtoolsHook } from '@fluixi/devtools';
55
+ const hook = installDevtoolsHook(); // before the app loads
56
+ // …the app runs…
57
+ hook.snapshot(); // everything
58
+ hook.snapshot({ partial: true }); // only what moved since the last call
59
+ ```
60
+
61
+ Read edges survive the trip; write edges do not exist on an observed graph, since nothing records which computation was running when a setter fired.
62
+
63
+ ## Where a node came from
64
+
65
+ A graph node can carry an optional source: file, line and column of the place the author created it, plus the variable name when the source gave one. Every field optional, and the whole thing absent for a node the compiler did not see.
66
+
67
+ The compiler marks each creation site when `sourceLocations` is on, the mark names the file and position from the original source rather than the generated output, and the instrumented primitive takes the mark and keeps it on the node:
68
+
69
+ ```ts
70
+ // what the compiler emits in development
71
+ globalThis.__fx_source?.("src/App.ts", 10, 15, "count");
72
+ const count = signal(0);
73
+ ```
74
+
75
+ A renderer needs nothing else — no compiler, no source file, no ast — and file, line and column are enough for a host to decide how to open it. For example:
76
+
77
+ ```ts
78
+ for (const node of reactor.graph.values()) {
79
+ if (node.source) {
80
+ console.log(node.label, `${node.source.file}:${node.source.line}:${node.source.column}`);
81
+ }
82
+ }
83
+ ```
84
+
85
+ The compiler wraps every component in a memo, so the graph sees the wrapper rather than the component. A node whose value carries a component's name reports its kind as `component` and takes that name as its label, so the graph reads `App` rather than `m5`. Nothing about the node changes — it is the same memo, with the same dependencies.
86
+
87
+ A component node's `source` is where its tag was written, which is where the node was made — the same meaning `source` has for every other kind. `declaredAt` is where the component itself was declared, which is usually another file. It stands in for `source` when nothing marked the tag, and two components sharing a name are reported without a location rather than with the wrong one.
88
+
89
+ ### Making a node the compiler cannot see
90
+
91
+ For a primitive built at runtime, call `markReactiveSource` yourself immediately before it, since the next node created takes the mark:
92
+
93
+ ```ts
94
+ import { markReactiveSource } from '@fluixi/devtools';
95
+
96
+ markReactiveSource('src/factory.ts', 8, 2, 'fromFactory');
97
+ const s = signal(0);
98
+ ```
99
+
100
+ A production build should leave `sourceLocations` off and then carries none of it. One statement carries one mark, so `signal(compute(signal(1)))` labels the inner call and leaves the outer bare rather than giving it a location that is not its own.
101
+
29
102
  ## API
30
103
 
31
104
  - `createReactor()` → `{ graph, run(setup), sample(), dispose() }`
105
+ - `observeGraph()` → `{ graph, sample(), detach() }`
106
+ - `installDevtoolsHook()` → the global a tool attaches through
107
+ - `serializeGraph(graph, now?, { ids, removed }?)` / `snapshotToRenderMap(snapshot)`
108
+ - `applySnapshot(graph, snapshot)` — fold a full snapshot or a delta into what is drawn
109
+ - `formatValue(value, limit?)` — a printable rendering of any value; never throws
32
110
  - `createGraphView(svg)` → `{ update(graph, now?), clear() }`
33
111
  - `DEVTOOLS_CSS` — default theme string (override the `fx-*` classes to restyle)
@@ -0,0 +1,22 @@
1
+ import type { ReactiveSourceLocation } from './source.js';
2
+ /**
3
+ * Where a node came from, and whether that is the application or something it depends on.
4
+ *
5
+ * The application's own frame is the useful one, but a node made while a package was loading
6
+ * has no application frame above it at all — a router's revalidation counter, made when its
7
+ * module is evaluated. Falling back to the package's own frame is what stops that node being
8
+ * a card with no name, no origin and no explanation.
9
+ */
10
+ export declare function callSite(stack: string | undefined, origin?: string): {
11
+ location: ReactiveSourceLocation;
12
+ external: boolean;
13
+ } | undefined;
14
+ /**
15
+ * The call site of whatever is being created right now. Called from inside the observer, so
16
+ * the frames of the creating call are still on the stack.
17
+ */
18
+ export declare function currentCallSite(origin?: string): {
19
+ location: ReactiveSourceLocation;
20
+ external: boolean;
21
+ } | undefined;
22
+ //# sourceMappingURL=callsite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callsite.d.ts","sourceRoot":"","sources":["../src/callsite.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAqD1D;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,CAAC,EAAE,MAAM,GACd;IAAE,QAAQ,EAAE,sBAAsB,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAgBrE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,CAAC,EAAE,MAAM,GACd;IAAE,QAAQ,EAAE,sBAAsB,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAErE"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A browser extension's own bundle.
3
+ *
4
+ * The devtools agent carries a copy of this package, so every frame inside it looked like
5
+ * application code and every node reported `chrome-extension://…/agent.js` as its origin.
6
+ * Nothing under these schemes is ever the page's own source.
7
+ */
8
+ const EXTENSION = ['chrome-extension://', 'moz-extension://', 'safari-web-extension://', 'extension://'];
9
+ /** Frames that are the machinery rather than the caller. */
10
+ const INTERNAL = [
11
+ '/packages/reactive/',
12
+ '/packages/dom/',
13
+ '/packages/core/',
14
+ '/packages/devtools/',
15
+ '/@fluixi/',
16
+ 'node_modules',
17
+ 'fluixi-devtools',
18
+ ...EXTENSION,
19
+ ];
20
+ /** The plumbing that reports a node, which is never worth naming as its origin. */
21
+ const REPORTING = [
22
+ '/packages/reactive/src/lib/signal/graph/',
23
+ '/packages/devtools/',
24
+ 'fluixi-devtools',
25
+ // Not a fallback either: naming the agent as where a node came from is worse than nothing.
26
+ ...EXTENSION,
27
+ ];
28
+ /** `at name (url:line:col)` and `at url:line:col`, which is the whole of what is needed. */
29
+ const FRAME = /(?:at\s+(?:[^(]*\()?)([^()\s]+):(\d+):(\d+)\)?$/;
30
+ const isInternal = (frame) => INTERNAL.some((part) => frame.includes(part));
31
+ /**
32
+ * The first frame that is not the framework's own.
33
+ *
34
+ * `origin` trims a dev server's url back to a project path, so a location reads
35
+ * `src/App.tsx:6` rather than `http://localhost:5173/src/App.tsx?t=1712`.
36
+ */
37
+ function locate(frame, origin) {
38
+ const match = FRAME.exec(frame);
39
+ if (!match)
40
+ return undefined;
41
+ let file = match[1];
42
+ // A dev server serves a project file at its own origin, with a cache-busting query.
43
+ if (origin && file.startsWith(origin))
44
+ file = file.slice(origin.length);
45
+ file = file.replace(/^\/@fs\//, '/').replace(/\?.*$/, '').replace(/^\//, '');
46
+ return file ? { file, line: Number(match[2]), column: Number(match[3]) } : undefined;
47
+ }
48
+ /**
49
+ * Where a node came from, and whether that is the application or something it depends on.
50
+ *
51
+ * The application's own frame is the useful one, but a node made while a package was loading
52
+ * has no application frame above it at all — a router's revalidation counter, made when its
53
+ * module is evaluated. Falling back to the package's own frame is what stops that node being
54
+ * a card with no name, no origin and no explanation.
55
+ */
56
+ export function callSite(stack, origin) {
57
+ if (!stack)
58
+ return undefined;
59
+ let fallback;
60
+ for (const line of stack.split('\n')) {
61
+ const frame = line.trim();
62
+ if (!frame.startsWith('at '))
63
+ continue;
64
+ if (!isInternal(frame)) {
65
+ const location = locate(frame, origin);
66
+ if (location)
67
+ return { location, external: false };
68
+ continue;
69
+ }
70
+ // The first frame that is a package rather than the reporting machinery itself.
71
+ if (!fallback && !REPORTING.some((part) => frame.includes(part)))
72
+ fallback = locate(frame, origin);
73
+ }
74
+ return fallback ? { location: { ...fallback, external: true }, external: true } : undefined;
75
+ }
76
+ /**
77
+ * The call site of whatever is being created right now. Called from inside the observer, so
78
+ * the frames of the creating call are still on the stack.
79
+ */
80
+ export function currentCallSite(origin) {
81
+ return callSite(new Error().stack, origin);
82
+ }
@@ -0,0 +1,10 @@
1
+ import type { ValueChange } from './diff.js';
2
+ /** Empty when there is nothing structural: a plain value is already on the row above. */
3
+ export declare function changeRows(change: ValueChange | undefined, limit?: number): string;
4
+ /**
5
+ * Delegated: the rows are rebuilt on every render and per-button listeners would go with them.
6
+ * True when the click was a copy, so a host does not also treat it as a click on the row.
7
+ */
8
+ export declare function handleCopyClick(event: Event): boolean;
9
+ export declare const CHANGE_CSS = "\n.ch-list { margin-top: 4px; font-family: ui-monospace, monospace; font-size: 11px; }\n.ch-size { color: var(--fx-muted, #8a90a2); margin-bottom: 2px; }\n.ch-row { display: flex; gap: 6px; align-items: baseline; padding: 1px 0; }\n.ch-row.ch-stacked { align-items: flex-start; padding: 3px 0; }\n.ch-sign { flex: none; width: 9px; text-align: center; }\n.ch-added .ch-sign { color: var(--state, #34d399); }\n.ch-removed .ch-sign { color: var(--fx-write, #fb7185); }\n.ch-changed .ch-sign { color: var(--effect, #fbbf24); }\n.ch-key { flex: none; color: var(--fx-muted, #8a90a2); }\n.ch-sides { min-width: 0; flex: 1; overflow-wrap: anywhere; }\n.ch-stacked .ch-sides { display: flex; flex-direction: column; gap: 2px; }\n.ch-from { color: var(--fx-muted, #8a90a2); }\n.ch-row:not(.ch-stacked) .ch-from { text-decoration: line-through; }\n.ch-arrow { color: var(--fx-muted, #8a90a2); margin: 0 4px; }\n.ch-to { color: var(--fx-label, #e7e9ee); }\n.ch-more { color: var(--fx-muted, #8a90a2); margin-top: 2px; }\n/* In the corner: an object can be tall, and a button under it is a scroll away. */\n.ch-block { position: relative; border-radius: 4px; border: 1px solid var(--fx-line, #ffffff14); }\n.ch-block.ch-from { opacity: .72; }\n.ch-json { margin: 0; padding: 5px 7px; overflow-x: auto; white-space: pre; font: inherit; }\n.ch-copy {\n position: absolute; top: 3px; right: 3px; padding: 1px 6px; cursor: pointer;\n font: inherit; font-size: 9.5px; opacity: 0; transition: opacity .12s;\n border: 1px solid var(--fx-line, #ffffff26); border-radius: 3px;\n background: var(--fx-panel, #14161c); color: var(--fx-muted, #8a90a2);\n}\n.ch-block:hover .ch-copy, .ch-copy:focus { opacity: 1; }\n.ch-copy:hover { color: var(--fx-label, #e7e9ee); }\n";
10
+ //# sourceMappingURL=change-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-view.d.ts","sourceRoot":"","sources":["../src/change-view.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAgC7C,yFAAyF;AACzF,wBAAgB,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,EAAE,KAAK,SAAW,GAAG,MAAM,CAmCpF;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAKrD;AAED,eAAO,MAAM,UAAU,0tDA6BtB,CAAC"}
@@ -0,0 +1,102 @@
1
+ // A diff, drawn. Shared between the timeline's open row and the panel that opens from it —
2
+ // same rows, cut to a handful in the sidebar and shown in full when there is room.
3
+ //
4
+ // An object is drawn as an object: `{id: 1, text: "go"}` on one line is what the cards
5
+ // already say.
6
+ import { copyText } from './copy.js';
7
+ const escape = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
8
+ const SIGN = { added: '+', removed: '−', changed: '~' };
9
+ /** Indented JSON, or nothing when the value was not JSON to begin with. */
10
+ function pretty(json) {
11
+ if (!json)
12
+ return undefined;
13
+ try {
14
+ return JSON.stringify(JSON.parse(json), null, 2);
15
+ }
16
+ catch {
17
+ // It came from JSON.stringify, so this should not happen — but a malformed payload is
18
+ // not worth losing the row over.
19
+ return undefined;
20
+ }
21
+ }
22
+ /** Structured values get a block with a copy button; a number on three lines is worse. */
23
+ function sideOf(text, json, cls) {
24
+ const block = pretty(json);
25
+ if (!block)
26
+ return `<span class="ch-${cls}">${escape(text ?? '')}</span>`;
27
+ return (`<div class="ch-block ch-${cls}">` +
28
+ `<button type="button" class="ch-copy" title="Copy" data-copy="${escape(block)}">copy</button>` +
29
+ `<pre class="ch-json">${escape(block)}</pre>` +
30
+ '</div>');
31
+ }
32
+ /** Empty when there is nothing structural: a plain value is already on the row above. */
33
+ export function changeRows(change, limit = Infinity) {
34
+ if (!change?.entries?.length)
35
+ return '';
36
+ const shown = change.entries.slice(0, limit);
37
+ const hidden = change.entries.length - shown.length + (change.more ?? 0);
38
+ const rows = shown
39
+ .map((entry) => {
40
+ const from = sideOf(entry.from, entry.fromJson, 'from');
41
+ const to = sideOf(entry.to, entry.toJson, 'to');
42
+ const sides = entry.op === 'changed'
43
+ ? `${from}<span class="ch-arrow">→</span>${to}`
44
+ : entry.op === 'added'
45
+ ? to
46
+ : from;
47
+ // A block stacks; two inline values stay on the line.
48
+ const stacked = (entry.fromJson || entry.toJson) ? ' ch-stacked' : '';
49
+ return (`<div class="ch-row ch-${entry.op}${stacked}">` +
50
+ `<span class="ch-sign">${SIGN[entry.op] ?? '·'}</span>` +
51
+ `<span class="ch-key">${escape(entry.key)}</span>` +
52
+ `<span class="ch-sides">${sides}</span>` +
53
+ '</div>');
54
+ })
55
+ .join('');
56
+ // Only when it moved: an in-place replacement is not a list that grew.
57
+ const size = change.length
58
+ ? `<div class="ch-size">${change.length.from} → ${change.length.to} items</div>`
59
+ : '';
60
+ return `<div class="ch-list">${size}${rows}${hidden > 0 ? `<div class="ch-more">and ${hidden} more</div>` : ''}</div>`;
61
+ }
62
+ /**
63
+ * Delegated: the rows are rebuilt on every render and per-button listeners would go with them.
64
+ * True when the click was a copy, so a host does not also treat it as a click on the row.
65
+ */
66
+ export function handleCopyClick(event) {
67
+ const button = event.target?.closest?.('.ch-copy');
68
+ if (!button)
69
+ return false;
70
+ void copyText(button.dataset.copy ?? '', button);
71
+ return true;
72
+ }
73
+ export const CHANGE_CSS = `
74
+ .ch-list { margin-top: 4px; font-family: ui-monospace, monospace; font-size: 11px; }
75
+ .ch-size { color: var(--fx-muted, #8a90a2); margin-bottom: 2px; }
76
+ .ch-row { display: flex; gap: 6px; align-items: baseline; padding: 1px 0; }
77
+ .ch-row.ch-stacked { align-items: flex-start; padding: 3px 0; }
78
+ .ch-sign { flex: none; width: 9px; text-align: center; }
79
+ .ch-added .ch-sign { color: var(--state, #34d399); }
80
+ .ch-removed .ch-sign { color: var(--fx-write, #fb7185); }
81
+ .ch-changed .ch-sign { color: var(--effect, #fbbf24); }
82
+ .ch-key { flex: none; color: var(--fx-muted, #8a90a2); }
83
+ .ch-sides { min-width: 0; flex: 1; overflow-wrap: anywhere; }
84
+ .ch-stacked .ch-sides { display: flex; flex-direction: column; gap: 2px; }
85
+ .ch-from { color: var(--fx-muted, #8a90a2); }
86
+ .ch-row:not(.ch-stacked) .ch-from { text-decoration: line-through; }
87
+ .ch-arrow { color: var(--fx-muted, #8a90a2); margin: 0 4px; }
88
+ .ch-to { color: var(--fx-label, #e7e9ee); }
89
+ .ch-more { color: var(--fx-muted, #8a90a2); margin-top: 2px; }
90
+ /* In the corner: an object can be tall, and a button under it is a scroll away. */
91
+ .ch-block { position: relative; border-radius: 4px; border: 1px solid var(--fx-line, #ffffff14); }
92
+ .ch-block.ch-from { opacity: .72; }
93
+ .ch-json { margin: 0; padding: 5px 7px; overflow-x: auto; white-space: pre; font: inherit; }
94
+ .ch-copy {
95
+ position: absolute; top: 3px; right: 3px; padding: 1px 6px; cursor: pointer;
96
+ font: inherit; font-size: 9.5px; opacity: 0; transition: opacity .12s;
97
+ border: 1px solid var(--fx-line, #ffffff26); border-radius: 3px;
98
+ background: var(--fx-panel, #14161c); color: var(--fx-muted, #8a90a2);
99
+ }
100
+ .ch-block:hover .ch-copy, .ch-copy:focus { opacity: 1; }
101
+ .ch-copy:hover { color: var(--fx-label, #e7e9ee); }
102
+ `;
package/dist/copy.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Putting text on the clipboard from wherever a panel happens to be running.
3
+ *
4
+ * `navigator.clipboard` is not there in a vscode webview without permission, nor on an
5
+ * insecure origin. The hidden textarea is the old way and still the one that works in both.
6
+ */
7
+ /** True when the text reached the clipboard. */
8
+ export declare function copyText(text: string, button?: HTMLElement): Promise<boolean>;
9
+ //# sourceMappingURL=copy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copy.d.ts","sourceRoot":"","sources":["../src/copy.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAgCH,gDAAgD;AAChD,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAanF"}
package/dist/copy.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Putting text on the clipboard from wherever a panel happens to be running.
3
+ *
4
+ * `navigator.clipboard` is not there in a vscode webview without permission, nor on an
5
+ * insecure origin. The hidden textarea is the old way and still the one that works in both.
6
+ */
7
+ /** Says so on the control that was pressed: a toast needs somewhere to live. */
8
+ function confirmOn(button, said = 'copied') {
9
+ if (!button)
10
+ return;
11
+ const was = button.textContent;
12
+ button.textContent = said;
13
+ setTimeout(() => {
14
+ button.textContent = was;
15
+ }, 1200);
16
+ }
17
+ function fallback(text, button) {
18
+ try {
19
+ const doc = button?.ownerDocument ?? document;
20
+ const area = doc.createElement('textarea');
21
+ area.value = text;
22
+ area.setAttribute('readonly', '');
23
+ area.style.position = 'fixed';
24
+ area.style.opacity = '0';
25
+ doc.body.appendChild(area);
26
+ area.select();
27
+ doc.execCommand('copy');
28
+ area.remove();
29
+ confirmOn(button);
30
+ return true;
31
+ }
32
+ catch {
33
+ // Nothing left to try; whatever is on screen can still be selected by hand.
34
+ return false;
35
+ }
36
+ }
37
+ /** True when the text reached the clipboard. */
38
+ export async function copyText(text, button) {
39
+ const clipboard = globalThis
40
+ .navigator?.clipboard;
41
+ if (clipboard?.writeText) {
42
+ try {
43
+ await clipboard.writeText(text);
44
+ confirmOn(button);
45
+ return true;
46
+ }
47
+ catch {
48
+ // Denied, or a context the api refuses to work in.
49
+ }
50
+ }
51
+ return fallback(text, button);
52
+ }
@@ -0,0 +1,3 @@
1
+ /** An element, the text node it writes, or the marker for a hole with nothing in it yet. */
2
+ export declare function describeNode(target: unknown): string;
3
+ //# sourceMappingURL=describe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe.d.ts","sourceRoot":"","sources":["../src/describe.ts"],"names":[],"mappings":"AA8BA,4FAA4F;AAC5F,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAkBpD"}
@@ -0,0 +1,47 @@
1
+ // A dom node, in words.
2
+ //
3
+ // The element is described at the moment it is reported and the reference dropped: holding
4
+ // one would keep a removed subtree alive for as long as the panel is open.
5
+ //
6
+ // Enough to find it on the page — tag, id, a class or two — without being a selector.
7
+ /** More than this is a selector, not a label. */
8
+ const MAX_CLASSES = 2;
9
+ /** `text in li` — the element it sits in is what locates it. */
10
+ function textOwner(node) {
11
+ const parent = node.parentElement;
12
+ return parent ? `text in ${describeElement(parent)}` : 'text';
13
+ }
14
+ function describeElement(el) {
15
+ const tag = el.tagName.toLowerCase();
16
+ const id = el.id ? `#${el.id}` : '';
17
+ // Not a string on an SVG element.
18
+ const raw = typeof el.className === 'string' ? el.className : el.getAttribute('class') ?? '';
19
+ const classes = raw
20
+ .split(/\s+/)
21
+ .filter(Boolean)
22
+ .slice(0, MAX_CLASSES)
23
+ .map((c) => `.${c}`)
24
+ .join('');
25
+ return `${tag}${id}${classes}`;
26
+ }
27
+ /** An element, the text node it writes, or the marker for a hole with nothing in it yet. */
28
+ export function describeNode(target) {
29
+ const node = target;
30
+ if (!node || typeof node.nodeType !== 'number')
31
+ return 'detached';
32
+ switch (node.nodeType) {
33
+ case 1:
34
+ return describeElement(node);
35
+ case 3:
36
+ return textOwner(node);
37
+ case 8: {
38
+ // The hole is empty; where it will go is what matters.
39
+ const parent = node.parentElement;
40
+ return parent ? `slot in ${describeElement(parent)}` : 'slot';
41
+ }
42
+ case 11:
43
+ return 'fragment';
44
+ default:
45
+ return node.nodeName?.toLowerCase() ?? 'node';
46
+ }
47
+ }
@@ -0,0 +1,17 @@
1
+ import type { Commit } from './history.js';
2
+ import type { RNode } from './instrument.js';
3
+ export interface DetailPanel {
4
+ /** Show a commit. Opening a second one replaces the first rather than stacking. */
5
+ open(commit: Commit, graph: Map<number, RNode>): void;
6
+ close(): void;
7
+ readonly isOpen: boolean;
8
+ /** Remove the panel and its listeners. */
9
+ destroy(): void;
10
+ }
11
+ export interface DetailPanelOptions {
12
+ /** A node in the panel was clicked — the host can select it on the graph. */
13
+ onPick?(id: number): void;
14
+ }
15
+ export declare function createDetailPanel(host: HTMLElement, options?: DetailPanelOptions): DetailPanel;
16
+ export declare const DETAIL_PANEL_CSS = "\n.dp-panel {\n position: fixed; z-index: 40; display: flex; flex-direction: column;\n border: 1px solid var(--fx-line, #ffffff26); border-radius: 8px;\n background: var(--fx-panel, #14161c); color: var(--fx-label, #e7e9ee);\n box-shadow: 0 12px 40px rgba(0, 0, 0, .45); overflow: hidden;\n}\n.dp-panel[hidden] { display: none; }\n.dp-bar {\n display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px;\n cursor: move; user-select: none;\n border-bottom: 1px solid var(--fx-line, #ffffff1f);\n}\n.dp-title { font-family: ui-monospace, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.dp-close {\n margin-left: auto; width: 22px; height: 20px; padding: 0; cursor: pointer; font: inherit; font-size: 11px;\n border: 1px solid var(--fx-line, #ffffff1f); border-radius: 4px;\n background: transparent; color: var(--fx-muted, #8a90a2);\n}\n.dp-close:hover { color: var(--fx-label, #e7e9ee); }\n.dp-body { flex: 1; min-height: 0; overflow: auto; padding: 10px 12px; }\n.dp-change { font-family: ui-monospace, monospace; font-size: 12px; overflow-wrap: anywhere; }\n.dp-group { margin-top: 10px; }\n.dp-verb { font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; color: var(--fx-muted, #8a90a2); }\n.dp-nodes { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }\n.dp-node {\n padding: 2px 7px; cursor: pointer; font: inherit; font-family: ui-monospace, monospace; font-size: 11px;\n border: 1px solid var(--fx-line, #ffffff1f); border-radius: 999px;\n background: transparent; color: var(--fx-label, #e7e9ee);\n}\n.dp-node:hover { border-color: var(--fx-accent, #5eead4); }\n.dp-targets { margin-top: 4px; font-family: ui-monospace, monospace; font-size: 11px; color: var(--fx-label, #e7e9ee); }\n.dp-where { color: var(--fx-muted, #8a90a2); }\n/* Bottom-right, where every desktop puts it. */\n.dp-grip {\n position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;\n background: linear-gradient(135deg, transparent 50%, var(--fx-line, #ffffff40) 50%);\n}\n\n.ch-list { margin-top: 4px; font-family: ui-monospace, monospace; font-size: 11px; }\n.ch-size { color: var(--fx-muted, #8a90a2); margin-bottom: 2px; }\n.ch-row { display: flex; gap: 6px; align-items: baseline; padding: 1px 0; }\n.ch-row.ch-stacked { align-items: flex-start; padding: 3px 0; }\n.ch-sign { flex: none; width: 9px; text-align: center; }\n.ch-added .ch-sign { color: var(--state, #34d399); }\n.ch-removed .ch-sign { color: var(--fx-write, #fb7185); }\n.ch-changed .ch-sign { color: var(--effect, #fbbf24); }\n.ch-key { flex: none; color: var(--fx-muted, #8a90a2); }\n.ch-sides { min-width: 0; flex: 1; overflow-wrap: anywhere; }\n.ch-stacked .ch-sides { display: flex; flex-direction: column; gap: 2px; }\n.ch-from { color: var(--fx-muted, #8a90a2); }\n.ch-row:not(.ch-stacked) .ch-from { text-decoration: line-through; }\n.ch-arrow { color: var(--fx-muted, #8a90a2); margin: 0 4px; }\n.ch-to { color: var(--fx-label, #e7e9ee); }\n.ch-more { color: var(--fx-muted, #8a90a2); margin-top: 2px; }\n/* In the corner: an object can be tall, and a button under it is a scroll away. */\n.ch-block { position: relative; border-radius: 4px; border: 1px solid var(--fx-line, #ffffff14); }\n.ch-block.ch-from { opacity: .72; }\n.ch-json { margin: 0; padding: 5px 7px; overflow-x: auto; white-space: pre; font: inherit; }\n.ch-copy {\n position: absolute; top: 3px; right: 3px; padding: 1px 6px; cursor: pointer;\n font: inherit; font-size: 9.5px; opacity: 0; transition: opacity .12s;\n border: 1px solid var(--fx-line, #ffffff26); border-radius: 3px;\n background: var(--fx-panel, #14161c); color: var(--fx-muted, #8a90a2);\n}\n.ch-block:hover .ch-copy, .ch-copy:focus { opacity: 1; }\n.ch-copy:hover { color: var(--fx-label, #e7e9ee); }\n";
17
+ //# sourceMappingURL=detail-panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detail-panel.d.ts","sourceRoot":"","sources":["../src/detail-panel.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAc,MAAM,cAAc,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC;IACtD,KAAK,IAAI,IAAI,CAAC;IACd,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,0CAA0C;IAC1C,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,6EAA6E;IAC7E,MAAM,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AA2DD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAwIlG;AAED,eAAO,MAAM,gBAAgB,0vHAsCf,CAAC"}