dialog-ide 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +211 -0
- package/THIRD_PARTY_LICENSES.md +157 -0
- package/bin/darwin-arm64/aambundle +0 -0
- package/bin/darwin-arm64/dgdebug +0 -0
- package/bin/darwin-arm64/dialogc +0 -0
- package/bin/dialog-lib/stddebug.dg +112 -0
- package/bin/dialog-lib/stdlib.dg +6730 -0
- package/bin/dialog-lib/unit.dg +187 -0
- package/dist/cli/commands/run-skein.d.ts +17 -0
- package/dist/cli/commands/run-skein.d.ts.map +1 -0
- package/dist/cli/commands/run-skein.js +105 -0
- package/dist/cli/commands/run-skein.js.map +1 -0
- package/dist/cli/commands/sources.d.ts +12 -0
- package/dist/cli/commands/sources.d.ts.map +1 -0
- package/dist/cli/commands/sources.js +42 -0
- package/dist/cli/commands/sources.js.map +1 -0
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.d.ts.map +1 -0
- package/dist/cli/commands/test.js +41 -0
- package/dist/cli/commands/test.js.map +1 -0
- package/dist/cli/context.d.ts +9 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +78 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -0
- package/dist/dialog-cover-resize.d.ts +2 -0
- package/dist/dialog-cover-resize.d.ts.map +1 -0
- package/dist/dialog-cover-resize.js +46 -0
- package/dist/dialog-cover-resize.js.map +1 -0
- package/dist/dialog-export.d.ts +23 -0
- package/dist/dialog-export.d.ts.map +1 -0
- package/dist/dialog-export.js +133 -0
- package/dist/dialog-export.js.map +1 -0
- package/dist/dialog-feelies.d.ts +4 -0
- package/dist/dialog-feelies.d.ts.map +1 -0
- package/dist/dialog-feelies.js +62 -0
- package/dist/dialog-feelies.js.map +1 -0
- package/dist/dialog-outline.d.ts +19 -0
- package/dist/dialog-outline.d.ts.map +1 -0
- package/dist/dialog-outline.js +122 -0
- package/dist/dialog-outline.js.map +1 -0
- package/dist/dialog-project-init.d.ts +5 -0
- package/dist/dialog-project-init.d.ts.map +1 -0
- package/dist/dialog-project-init.js +107 -0
- package/dist/dialog-project-init.js.map +1 -0
- package/dist/dialog-source-coverage.d.ts +9 -0
- package/dist/dialog-source-coverage.d.ts.map +1 -0
- package/dist/dialog-source-coverage.js +61 -0
- package/dist/dialog-source-coverage.js.map +1 -0
- package/dist/dialog-source-decoration-provider.d.ts +11 -0
- package/dist/dialog-source-decoration-provider.d.ts.map +1 -0
- package/dist/dialog-source-decoration-provider.js +79 -0
- package/dist/dialog-source-decoration-provider.js.map +1 -0
- package/dist/dialog-symbol-index.d.ts +19 -0
- package/dist/dialog-symbol-index.d.ts.map +1 -0
- package/dist/dialog-symbol-index.js +107 -0
- package/dist/dialog-symbol-index.js.map +1 -0
- package/dist/dialog-symbol-provider.d.ts +5 -0
- package/dist/dialog-symbol-provider.d.ts.map +1 -0
- package/dist/dialog-symbol-provider.js +56 -0
- package/dist/dialog-symbol-provider.js.map +1 -0
- package/dist/dialog-web-export.d.ts +25 -0
- package/dist/dialog-web-export.d.ts.map +1 -0
- package/dist/dialog-web-export.js +283 -0
- package/dist/dialog-web-export.js.map +1 -0
- package/dist/dialog-workspace-symbol-provider.d.ts +16 -0
- package/dist/dialog-workspace-symbol-provider.d.ts.map +1 -0
- package/dist/dialog-workspace-symbol-provider.js +107 -0
- package/dist/dialog-workspace-symbol-provider.js.map +1 -0
- package/dist/dialog-workspace-watcher.d.ts +22 -0
- package/dist/dialog-workspace-watcher.d.ts.map +1 -0
- package/dist/dialog-workspace-watcher.js +102 -0
- package/dist/dialog-workspace-watcher.js.map +1 -0
- package/dist/dialoged/skein/compile-error.d.ts +10 -0
- package/dist/dialoged/skein/compile-error.d.ts.map +1 -0
- package/dist/dialoged/skein/compile-error.js +28 -0
- package/dist/dialoged/skein/compile-error.js.map +1 -0
- package/dist/dialoged/skein/dynamic.d.ts +14 -0
- package/dist/dialoged/skein/dynamic.d.ts.map +1 -0
- package/dist/dialoged/skein/dynamic.js +176 -0
- package/dist/dialoged/skein/dynamic.js.map +1 -0
- package/dist/dialoged/skein/frotz-build.d.ts +10 -0
- package/dist/dialoged/skein/frotz-build.d.ts.map +1 -0
- package/dist/dialoged/skein/frotz-build.js +70 -0
- package/dist/dialoged/skein/frotz-build.js.map +1 -0
- package/dist/dialoged/skein/index.d.ts +15 -0
- package/dist/dialoged/skein/index.d.ts.map +1 -0
- package/dist/dialoged/skein/index.js +31 -0
- package/dist/dialoged/skein/index.js.map +1 -0
- package/dist/dialoged/skein/io.d.ts +15 -0
- package/dist/dialoged/skein/io.d.ts.map +1 -0
- package/dist/dialoged/skein/io.js +53 -0
- package/dist/dialoged/skein/io.js.map +1 -0
- package/dist/dialoged/skein/persistence.d.ts +13 -0
- package/dist/dialoged/skein/persistence.d.ts.map +1 -0
- package/dist/dialoged/skein/persistence.js +228 -0
- package/dist/dialoged/skein/persistence.js.map +1 -0
- package/dist/dialoged/skein/process.d.ts +38 -0
- package/dist/dialoged/skein/process.d.ts.map +1 -0
- package/dist/dialoged/skein/process.js +189 -0
- package/dist/dialoged/skein/process.js.map +1 -0
- package/dist/dialoged/skein/progress.d.ts +17 -0
- package/dist/dialoged/skein/progress.d.ts.map +1 -0
- package/dist/dialoged/skein/progress.js +9 -0
- package/dist/dialoged/skein/progress.js.map +1 -0
- package/dist/dialoged/skein/project.d.ts +44 -0
- package/dist/dialoged/skein/project.d.ts.map +1 -0
- package/dist/dialoged/skein/project.js +223 -0
- package/dist/dialoged/skein/project.js.map +1 -0
- package/dist/dialoged/skein/search.d.ts +15 -0
- package/dist/dialoged/skein/search.d.ts.map +1 -0
- package/dist/dialoged/skein/search.js +99 -0
- package/dist/dialoged/skein/search.js.map +1 -0
- package/dist/dialoged/skein/service.d.ts +91 -0
- package/dist/dialoged/skein/service.d.ts.map +1 -0
- package/dist/dialoged/skein/service.js +1097 -0
- package/dist/dialoged/skein/service.js.map +1 -0
- package/dist/dialoged/skein/session.d.ts +96 -0
- package/dist/dialoged/skein/session.d.ts.map +1 -0
- package/dist/dialoged/skein/session.js +690 -0
- package/dist/dialoged/skein/session.js.map +1 -0
- package/dist/dialoged/skein/syntax.d.ts +5 -0
- package/dist/dialoged/skein/syntax.d.ts.map +1 -0
- package/dist/dialoged/skein/syntax.js +150 -0
- package/dist/dialoged/skein/syntax.js.map +1 -0
- package/dist/dialoged/skein/trace.d.ts +31 -0
- package/dist/dialoged/skein/trace.d.ts.map +1 -0
- package/dist/dialoged/skein/trace.js +172 -0
- package/dist/dialoged/skein/trace.js.map +1 -0
- package/dist/dialoged/skein/tree.d.ts +114 -0
- package/dist/dialoged/skein/tree.d.ts.map +1 -0
- package/dist/dialoged/skein/tree.js +701 -0
- package/dist/dialoged/skein/tree.js.map +1 -0
- package/dist/dialoged/skein/ui/ansi.d.ts +4 -0
- package/dist/dialoged/skein/ui/ansi.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/ansi.js +215 -0
- package/dist/dialoged/skein/ui/ansi.js.map +1 -0
- package/dist/dialoged/skein/ui/diff.d.ts +7 -0
- package/dist/dialoged/skein/ui/diff.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/diff.js +17 -0
- package/dist/dialoged/skein/ui/diff.js.map +1 -0
- package/dist/dialoged/skein/ui/knot-menu.d.ts +5 -0
- package/dist/dialoged/skein/ui/knot-menu.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/knot-menu.js +89 -0
- package/dist/dialoged/skein/ui/knot-menu.js.map +1 -0
- package/dist/dialoged/skein/ui/marker-colors.d.ts +5 -0
- package/dist/dialoged/skein/ui/marker-colors.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/marker-colors.js +18 -0
- package/dist/dialoged/skein/ui/marker-colors.js.map +1 -0
- package/dist/dialoged/skein/ui/render.d.ts +13 -0
- package/dist/dialoged/skein/ui/render.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/render.js +332 -0
- package/dist/dialoged/skein/ui/render.js.map +1 -0
- package/dist/dialoged/skein/ui/traceRender.d.ts +11 -0
- package/dist/dialoged/skein/ui/traceRender.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/traceRender.js +139 -0
- package/dist/dialoged/skein/ui/traceRender.js.map +1 -0
- package/dist/dialoged/skein/ui/tree-pane.d.ts +3 -0
- package/dist/dialoged/skein/ui/tree-pane.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/tree-pane.js +135 -0
- package/dist/dialoged/skein/ui/tree-pane.js.map +1 -0
- package/dist/dialoged/skein/ui/webExportPage.d.ts +29 -0
- package/dist/dialoged/skein/ui/webExportPage.d.ts.map +1 -0
- package/dist/dialoged/skein/ui/webExportPage.js +72 -0
- package/dist/dialoged/skein/ui/webExportPage.js.map +1 -0
- package/dist/extension.d.ts +4 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +885 -0
- package/dist/extension.js.map +1 -0
- package/dist/session-runner.d.ts +23 -0
- package/dist/session-runner.d.ts.map +1 -0
- package/dist/session-runner.js +92 -0
- package/dist/session-runner.js.map +1 -0
- package/language-configuration.json +43 -0
- package/media/icon.png +0 -0
- package/media/icons/arrow-down-circle-solid.svg +3 -0
- package/media/icons/arrow-down-solid.svg +3 -0
- package/media/icons/arrow-path-solid.svg +3 -0
- package/media/icons/arrow-right-solid.svg +3 -0
- package/media/icons/arrow-up-on-square-stack-solid.svg +5 -0
- package/media/icons/arrow-up-solid.svg +3 -0
- package/media/icons/arrow-uturn-left.svg +3 -0
- package/media/icons/arrow-uturn-right.svg +3 -0
- package/media/icons/bookmark-solid.svg +3 -0
- package/media/icons/bug-ant-solid.svg +3 -0
- package/media/icons/check-badge-solid.svg +3 -0
- package/media/icons/check-circle-solid.svg +3 -0
- package/media/icons/chevron-double-down-solid.svg +4 -0
- package/media/icons/chevron-double-up-solid.svg +4 -0
- package/media/icons/command-line-solid.svg +3 -0
- package/media/icons/cpu-chip-solid.svg +4 -0
- package/media/icons/dg-file-dark.svg +6 -0
- package/media/icons/dg-file-light.svg +6 -0
- package/media/icons/ellipsis-vertical-solid.svg +3 -0
- package/media/icons/exclamation-triangle.svg +3 -0
- package/media/icons/folder-arrow-down-solid.svg +3 -0
- package/media/icons/globe-alt.svg +3 -0
- package/media/icons/lock-closed-solid.svg +3 -0
- package/media/icons/magnifying-glass-solid.svg +3 -0
- package/media/icons/pencil-solid.svg +4 -0
- package/media/icons/play-solid.svg +3 -0
- package/media/icons/plus-circle-solid.svg +3 -0
- package/media/icons/scissors-solid.svg +5 -0
- package/media/icons/tag-solid.svg +3 -0
- package/media/icons/trash-solid.svg +3 -0
- package/media/icons/x-circle-solid.svg +3 -0
- package/media/js/datastar.js +9 -0
- package/media/js/main.js +1109 -0
- package/media/js/skein-loader.js +10 -0
- package/media/js/trace-loader.js +4 -0
- package/media/js/trace.js +147 -0
- package/media/style.css +5783 -0
- package/package.json +231 -0
- package/resources/bundle/default-cover.png +0 -0
- package/resources/bundle/introduction-to-if.pdf +0 -0
- package/resources/bundle/play-if-card.pdf +1990 -1
- package/resources/bundle/play.css +432 -0
- package/resources/bundle/style.css +450 -0
- package/resources/dfrotz-skein-patch.dg +4 -0
- package/syntaxes/dialog.tmLanguage.json +490 -0
package/media/js/main.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
// Client-side JS the server-rendered skein page relies on. Actual event dispatch to the server
|
|
2
|
+
// (click -> POST) is Datastar's job via data-on:*="@post(...)" attributes baked directly into
|
|
3
|
+
// the server-rendered markup (see render.ts/tree-pane.ts) - this file only holds what Datastar
|
|
4
|
+
// has no opinion about: focus management, SVG drawing, drag math, and popover positioning.
|
|
5
|
+
window.sk = {
|
|
6
|
+
// scrollToKnotId (service.ts's select-knot/new-child broadcasts) scrolls that knot's own
|
|
7
|
+
// transcript row into view instead of the command input - the transcript always shows the
|
|
8
|
+
// whole spine root-to-leaf regardless of which knot on it was clicked (tree.ts's selectKnot
|
|
9
|
+
// never truncates it), so scrolling to the input alone only happened to land on the right knot
|
|
10
|
+
// when it was already the leaf; clicking any ancestor scrolled straight past it to the bottom.
|
|
11
|
+
// Omitted (handleSendCommand/handleSseConnect's own bare calls) falls back to the input itself -
|
|
12
|
+
// correct there since a just-run command's new response is always the last thing shown, right
|
|
13
|
+
// above it.
|
|
14
|
+
//
|
|
15
|
+
// Only focuses the input when the target is the spine's actual leaf (or unspecified) - there's
|
|
16
|
+
// nothing to type into anyway unless you're actually looking at the spine's end.
|
|
17
|
+
resetAndFocusCommandInput(scrollToKnotId) {
|
|
18
|
+
// Deferred, not run inline: this executes from a dynamically-appended <script> patch
|
|
19
|
+
// (service.ts's sendScript), which can run before Datastar has fully finished settling the
|
|
20
|
+
// *separate*, immediately-preceding content patch in the same SSE stream - the two are
|
|
21
|
+
// independent datastar-patch-elements events, not one atomic update. A single
|
|
22
|
+
// requestAnimationFrame (the fix used elsewhere in this file, e.g. initTreeGraph's own
|
|
23
|
+
// MutationObserver-triggered drawTreeArrows) was NOT enough here and left scrollIntoView a
|
|
24
|
+
// silent no-op - confirmed by direct comparison against a real running instance of this page,
|
|
25
|
+
// embedded the same way extension.ts's outer webview embeds it (an iframe next to a
|
|
26
|
+
// position:sticky sibling, the tree pane): identical code, only the delay changed, and only a
|
|
27
|
+
// real ~150ms setTimeout reliably let whatever else still needed to settle finish first. A
|
|
28
|
+
// plain rAF, and even rAF-then-rAF, both still landed too early in testing.
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
const el = document.getElementById('new-command-input');
|
|
31
|
+
const isLeaf = scrollToKnotId == null || scrollToKnotId === spineLeafId();
|
|
32
|
+
if (el) {
|
|
33
|
+
el.value = '';
|
|
34
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
35
|
+
}
|
|
36
|
+
const knotEl = scrollToKnotId != null && document.getElementById(`knot-${scrollToKnotId}`);
|
|
37
|
+
if (knotEl) {
|
|
38
|
+
// block: 'center', not 'nearest' - also verified empirically: 'nearest' and 'end' silently
|
|
39
|
+
// no-op for a transcript row in this layout, while 'start' and 'center' reliably scroll.
|
|
40
|
+
// 'center' keeps some surrounding context visible instead of jamming the knot against the
|
|
41
|
+
// very top edge.
|
|
42
|
+
knotEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
43
|
+
} else {
|
|
44
|
+
// No specific knot (or it wasn't found) - fall back to the input itself, where 'nearest'
|
|
45
|
+
// has always worked reliably (it's the last element in the column, so "nearest" and
|
|
46
|
+
// "aligned to the bottom" naturally coincide).
|
|
47
|
+
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
48
|
+
}
|
|
49
|
+
if (isLeaf) {
|
|
50
|
+
el?.focus({ preventScroll: true });
|
|
51
|
+
}
|
|
52
|
+
}, 150);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// An acknowledging flash (service.ts's broadcastFlash) - ported near-verbatim from dialog-tool's
|
|
56
|
+
// own sk.showFlash: top-center, one at a time (a new flash dismisses whatever's currently
|
|
57
|
+
// showing rather than stacking), fades in then auto-dismisses. Built client-side rather than
|
|
58
|
+
// server-rendered into renderApp, so the ordinary #skein-app morph a tree change triggers can
|
|
59
|
+
// never clip one mid-display. type is 'info' (default; blue, no dismiss button, auto-fades) or
|
|
60
|
+
// 'error' (red, persists until the user dismisses it via the X button or Escape) - service.ts's
|
|
61
|
+
// broadcastFlash sends 'error' for a rejected action with no other UI to surface through (e.g.
|
|
62
|
+
// deleting a knot with a locked descendant), since postAction's fire-and-forget callers never
|
|
63
|
+
// look at the response.
|
|
64
|
+
_dismissFlash() {
|
|
65
|
+
if (this._flashTimer) {
|
|
66
|
+
clearTimeout(this._flashTimer);
|
|
67
|
+
this._flashTimer = null;
|
|
68
|
+
}
|
|
69
|
+
if (this._flashEl) {
|
|
70
|
+
this._flashEl.remove();
|
|
71
|
+
this._flashEl = null;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
showFlash(message, type = 'info') {
|
|
76
|
+
const isError = type === 'error';
|
|
77
|
+
this._dismissFlash();
|
|
78
|
+
|
|
79
|
+
const wrapper = document.createElement('div');
|
|
80
|
+
wrapper.className = 'fixed top-20 left-1/2 -translate-x-1/2 z-50';
|
|
81
|
+
wrapper.style.pointerEvents = isError ? 'auto' : 'none';
|
|
82
|
+
|
|
83
|
+
const inner = document.createElement('div');
|
|
84
|
+
inner.className = isError
|
|
85
|
+
? 'flex items-center gap-3 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg'
|
|
86
|
+
: 'bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg transition-opacity duration-500';
|
|
87
|
+
if (!isError) inner.style.opacity = '0';
|
|
88
|
+
|
|
89
|
+
const msg = document.createElement('span');
|
|
90
|
+
msg.textContent = message;
|
|
91
|
+
inner.appendChild(msg);
|
|
92
|
+
|
|
93
|
+
if (isError) {
|
|
94
|
+
inner.setAttribute('tabindex', '-1');
|
|
95
|
+
inner.addEventListener('keydown', (e) => {
|
|
96
|
+
if (e.key === 'Escape') this._dismissFlash();
|
|
97
|
+
});
|
|
98
|
+
const btn = document.createElement('button');
|
|
99
|
+
btn.type = 'button';
|
|
100
|
+
btn.className = 'ml-2 opacity-80 hover:opacity-100 text-lg font-bold cursor-pointer';
|
|
101
|
+
btn.textContent = '✕';
|
|
102
|
+
btn.addEventListener('click', () => this._dismissFlash());
|
|
103
|
+
inner.appendChild(btn);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
wrapper.appendChild(inner);
|
|
107
|
+
document.body.appendChild(wrapper);
|
|
108
|
+
this._flashEl = wrapper;
|
|
109
|
+
|
|
110
|
+
if (isError) {
|
|
111
|
+
inner.focus();
|
|
112
|
+
} else {
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
inner.style.opacity = '1';
|
|
115
|
+
this._flashTimer = setTimeout(() => {
|
|
116
|
+
inner.style.opacity = '0';
|
|
117
|
+
this._flashTimer = setTimeout(() => this._dismissFlash(), 600);
|
|
118
|
+
}, 2000);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// A centered progress modal for Replay All (service.ts's ProgressHost implementation) - ported
|
|
124
|
+
// visually from dialog-tool's modals/progress + components/modal.clj (backdrop, centered white
|
|
125
|
+
// panel, header/body layout, <progress> bar), but with a Cancel button that actually works:
|
|
126
|
+
// dialog-tool's own sets a :continue flag that's never read anywhere in its codebase, so
|
|
127
|
+
// clicking it does nothing there. Here it POSTs to /actions/cancel-replay, which flips the
|
|
128
|
+
// CancellationToken session.ts's replayTo checks between each replayed command (see service.ts's
|
|
129
|
+
// withProgress/cancelCurrentReplay).
|
|
130
|
+
showProgress(title, cancellable) {
|
|
131
|
+
this.hideProgress();
|
|
132
|
+
|
|
133
|
+
const overlay = document.createElement('div');
|
|
134
|
+
overlay.id = 'progress-modal';
|
|
135
|
+
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
|
|
136
|
+
|
|
137
|
+
const panel = document.createElement('div');
|
|
138
|
+
panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
|
|
139
|
+
panel.setAttribute('role', 'dialog');
|
|
140
|
+
panel.setAttribute('aria-modal', 'true');
|
|
141
|
+
|
|
142
|
+
const header = document.createElement('div');
|
|
143
|
+
header.className = 'px-6 py-4 border-b border-base-200';
|
|
144
|
+
const heading = document.createElement('h3');
|
|
145
|
+
heading.className = 'text-lg font-medium text-base-content';
|
|
146
|
+
heading.textContent = title;
|
|
147
|
+
header.appendChild(heading);
|
|
148
|
+
|
|
149
|
+
const body = document.createElement('div');
|
|
150
|
+
body.className = 'px-6 py-4';
|
|
151
|
+
|
|
152
|
+
const statusRow = document.createElement('div');
|
|
153
|
+
statusRow.className = 'flex justify-between mb-2';
|
|
154
|
+
const percentEl = document.createElement('span');
|
|
155
|
+
percentEl.id = 'progress-modal-percent';
|
|
156
|
+
percentEl.className = 'text-sm font-medium text-base-content';
|
|
157
|
+
percentEl.textContent = '0%';
|
|
158
|
+
const labelEl = document.createElement('span');
|
|
159
|
+
labelEl.id = 'progress-modal-label';
|
|
160
|
+
labelEl.className = 'text-sm text-base-content opacity-70';
|
|
161
|
+
statusRow.append(percentEl, labelEl);
|
|
162
|
+
|
|
163
|
+
const bar = document.createElement('progress');
|
|
164
|
+
bar.id = 'progress-modal-bar';
|
|
165
|
+
bar.className = 'progress progress-primary w-full';
|
|
166
|
+
bar.value = 0;
|
|
167
|
+
bar.max = 100;
|
|
168
|
+
bar.setAttribute('aria-label', title);
|
|
169
|
+
|
|
170
|
+
body.append(statusRow, bar);
|
|
171
|
+
|
|
172
|
+
if (cancellable) {
|
|
173
|
+
const buttonRow = document.createElement('div');
|
|
174
|
+
buttonRow.className = 'flex justify-end gap-2 mt-4';
|
|
175
|
+
const cancelBtn = document.createElement('button');
|
|
176
|
+
cancelBtn.type = 'button';
|
|
177
|
+
cancelBtn.className = 'btn btn-neutral';
|
|
178
|
+
cancelBtn.textContent = 'Cancel';
|
|
179
|
+
cancelBtn.addEventListener('click', () => {
|
|
180
|
+
fetch('/actions/cancel-replay', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
181
|
+
});
|
|
182
|
+
buttonRow.appendChild(cancelBtn);
|
|
183
|
+
body.appendChild(buttonRow);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
panel.append(header, body);
|
|
187
|
+
overlay.appendChild(panel);
|
|
188
|
+
document.body.appendChild(overlay);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
updateProgress(percent, message) {
|
|
192
|
+
const bar = document.getElementById('progress-modal-bar');
|
|
193
|
+
if (bar) bar.value = percent;
|
|
194
|
+
const percentEl = document.getElementById('progress-modal-percent');
|
|
195
|
+
if (percentEl) percentEl.textContent = Math.round(percent) + '%';
|
|
196
|
+
const labelEl = document.getElementById('progress-modal-label');
|
|
197
|
+
if (labelEl) labelEl.textContent = message || '';
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
hideProgress() {
|
|
201
|
+
const el = document.getElementById('progress-modal');
|
|
202
|
+
if (el) el.remove();
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// A real modal (OK/Cancel, Escape-to-cancel) for editing a knot's label - replaces an earlier
|
|
206
|
+
// window.prompt()-based attempt, which VS Code's webview sandbox (no allow-modals) silently
|
|
207
|
+
// swallows entirely. Built the same way as showProgress above: plain DOM construction, not
|
|
208
|
+
// server-rendered Datastar markup, since there's no session-level "which modal is open" state
|
|
209
|
+
// worth tracking server-side for something this transient. Submitting POSTs to /actions/set-label
|
|
210
|
+
// directly; a 409 (tree.ts's LabelConflictError - labels are tree-unique) shows its message
|
|
211
|
+
// inline via the alert-error block and leaves the modal open with the input focused for another
|
|
212
|
+
// try, rather than closing on failure. Success (204) relies on the session's own 'change'
|
|
213
|
+
// broadcast to re-render the knot's label chip - this modal only ever closes itself, never
|
|
214
|
+
// touches #skein-app.
|
|
215
|
+
showLabelModal(knotId, currentLabel) {
|
|
216
|
+
this.hideLabelModal();
|
|
217
|
+
// The trigger is a menu item inside an open dropdown - that dropdown's content is a native
|
|
218
|
+
// Popover (see knot-menu.ts's own doc comment on why), which renders in the browser's top
|
|
219
|
+
// layer, *above* this modal's plain fixed/z-50 overlay regardless of z-index. Left open, it
|
|
220
|
+
// would keep visually covering the modal until some unrelated click closed it. closeAllDropdowns
|
|
221
|
+
// (below) is the same close-and-sync-with-server logic the outside-click listener already uses.
|
|
222
|
+
closeAllDropdowns();
|
|
223
|
+
|
|
224
|
+
const overlay = document.createElement('div');
|
|
225
|
+
overlay.id = 'label-modal';
|
|
226
|
+
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
|
|
227
|
+
|
|
228
|
+
const panel = document.createElement('div');
|
|
229
|
+
panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
|
|
230
|
+
panel.setAttribute('role', 'dialog');
|
|
231
|
+
panel.setAttribute('aria-modal', 'true');
|
|
232
|
+
panel.setAttribute('tabindex', '-1');
|
|
233
|
+
|
|
234
|
+
const header = document.createElement('div');
|
|
235
|
+
header.className = 'px-6 py-4 border-b border-base-200';
|
|
236
|
+
const heading = document.createElement('h3');
|
|
237
|
+
heading.className = 'text-lg font-medium text-base-content';
|
|
238
|
+
heading.textContent = 'Edit Label';
|
|
239
|
+
header.appendChild(heading);
|
|
240
|
+
|
|
241
|
+
const body = document.createElement('div');
|
|
242
|
+
body.className = 'px-6 py-4';
|
|
243
|
+
|
|
244
|
+
const errorEl = document.createElement('div');
|
|
245
|
+
errorEl.className = 'alert alert-error text-sm mb-3 hidden';
|
|
246
|
+
errorEl.setAttribute('role', 'alert');
|
|
247
|
+
errorEl.setAttribute('aria-live', 'assertive');
|
|
248
|
+
body.appendChild(errorEl);
|
|
249
|
+
|
|
250
|
+
const input = document.createElement('input');
|
|
251
|
+
input.type = 'text';
|
|
252
|
+
input.className = 'input input-bordered w-full';
|
|
253
|
+
input.value = currentLabel || '';
|
|
254
|
+
input.placeholder = 'Label (blank to clear)';
|
|
255
|
+
input.setAttribute('aria-label', 'Label');
|
|
256
|
+
body.appendChild(input);
|
|
257
|
+
|
|
258
|
+
const buttonRow = document.createElement('div');
|
|
259
|
+
buttonRow.className = 'flex justify-end gap-2 mt-4';
|
|
260
|
+
|
|
261
|
+
const cancelBtn = document.createElement('button');
|
|
262
|
+
cancelBtn.type = 'button';
|
|
263
|
+
cancelBtn.className = 'btn btn-neutral';
|
|
264
|
+
cancelBtn.textContent = 'Cancel';
|
|
265
|
+
cancelBtn.addEventListener('click', () => this.hideLabelModal());
|
|
266
|
+
|
|
267
|
+
const okBtn = document.createElement('button');
|
|
268
|
+
okBtn.type = 'button';
|
|
269
|
+
okBtn.className = 'btn btn-primary';
|
|
270
|
+
okBtn.textContent = 'OK';
|
|
271
|
+
const submit = async () => {
|
|
272
|
+
errorEl.classList.add('hidden');
|
|
273
|
+
let res;
|
|
274
|
+
try {
|
|
275
|
+
res = await fetch('/actions/set-label', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ knotId, label: input.value })
|
|
279
|
+
});
|
|
280
|
+
} catch {
|
|
281
|
+
errorEl.textContent = 'Failed to reach the server.';
|
|
282
|
+
errorEl.classList.remove('hidden');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (res.status === 204) {
|
|
286
|
+
this.hideLabelModal();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (res.status === 409) {
|
|
290
|
+
const body = await res.json().catch(() => ({}));
|
|
291
|
+
errorEl.textContent = body.error || 'That label is already in use.';
|
|
292
|
+
} else {
|
|
293
|
+
errorEl.textContent = 'Failed to save label.';
|
|
294
|
+
}
|
|
295
|
+
errorEl.classList.remove('hidden');
|
|
296
|
+
input.focus();
|
|
297
|
+
input.select();
|
|
298
|
+
};
|
|
299
|
+
okBtn.addEventListener('click', submit);
|
|
300
|
+
|
|
301
|
+
buttonRow.append(cancelBtn, okBtn);
|
|
302
|
+
body.appendChild(buttonRow);
|
|
303
|
+
|
|
304
|
+
panel.append(header, body);
|
|
305
|
+
overlay.appendChild(panel);
|
|
306
|
+
document.body.appendChild(overlay);
|
|
307
|
+
|
|
308
|
+
panel.addEventListener('keydown', (e) => {
|
|
309
|
+
if (e.key === 'Escape') {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
this.hideLabelModal();
|
|
312
|
+
} else if (e.key === 'Enter') {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
submit();
|
|
315
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
|
|
316
|
+
// Native Cmd/Ctrl+A select-all doesn't reach the input reliably inside a VS Code webview
|
|
317
|
+
// (the same class of platform quirk documented elsewhere in this file - see the keydown
|
|
318
|
+
// accelerator listener below on why Option+letter needs evt.code instead of evt.key), so
|
|
319
|
+
// this does it explicitly rather than relying on the browser default.
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
input.select();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
input.focus();
|
|
326
|
+
input.select();
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
hideLabelModal() {
|
|
330
|
+
document.getElementById('label-modal')?.remove();
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
// Edit Command's modal - near-identical to showLabelModal above, except a blank command is
|
|
334
|
+
// rejected client-side (never even POSTed) rather than treated as "clear" the way a blank label
|
|
335
|
+
// is, since a knot has to have *some* command text. A command matching a sibling's is no longer
|
|
336
|
+
// a rejection (service.ts's setCommand merges the two knots together instead - see tree.ts's
|
|
337
|
+
// mergeSiblingInto). Success (204) relies on the session's own 'change' broadcast to refresh
|
|
338
|
+
// the knot's response (service.ts's setCommand replays it under the corrected text) - this
|
|
339
|
+
// modal never touches #skein-app directly, same as the label modal.
|
|
340
|
+
showCommandModal(knotId, currentCommand) {
|
|
341
|
+
this.hideCommandModal();
|
|
342
|
+
closeAllDropdowns();
|
|
343
|
+
|
|
344
|
+
const overlay = document.createElement('div');
|
|
345
|
+
overlay.id = 'command-modal';
|
|
346
|
+
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
|
|
347
|
+
|
|
348
|
+
const panel = document.createElement('div');
|
|
349
|
+
panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
|
|
350
|
+
panel.setAttribute('role', 'dialog');
|
|
351
|
+
panel.setAttribute('aria-modal', 'true');
|
|
352
|
+
panel.setAttribute('tabindex', '-1');
|
|
353
|
+
|
|
354
|
+
const header = document.createElement('div');
|
|
355
|
+
header.className = 'px-6 py-4 border-b border-base-200';
|
|
356
|
+
const heading = document.createElement('h3');
|
|
357
|
+
heading.className = 'text-lg font-medium text-base-content';
|
|
358
|
+
heading.textContent = 'Edit Command';
|
|
359
|
+
header.appendChild(heading);
|
|
360
|
+
|
|
361
|
+
const body = document.createElement('div');
|
|
362
|
+
body.className = 'px-6 py-4';
|
|
363
|
+
|
|
364
|
+
const errorEl = document.createElement('div');
|
|
365
|
+
errorEl.className = 'alert alert-error text-sm mb-3 hidden';
|
|
366
|
+
errorEl.setAttribute('role', 'alert');
|
|
367
|
+
errorEl.setAttribute('aria-live', 'assertive');
|
|
368
|
+
body.appendChild(errorEl);
|
|
369
|
+
|
|
370
|
+
const input = document.createElement('input');
|
|
371
|
+
input.type = 'text';
|
|
372
|
+
input.className = 'input input-bordered w-full font-mono';
|
|
373
|
+
input.value = currentCommand || '';
|
|
374
|
+
input.placeholder = 'Command';
|
|
375
|
+
input.setAttribute('aria-label', 'Command');
|
|
376
|
+
body.appendChild(input);
|
|
377
|
+
|
|
378
|
+
const buttonRow = document.createElement('div');
|
|
379
|
+
buttonRow.className = 'flex justify-end gap-2 mt-4';
|
|
380
|
+
|
|
381
|
+
const cancelBtn = document.createElement('button');
|
|
382
|
+
cancelBtn.type = 'button';
|
|
383
|
+
cancelBtn.className = 'btn btn-neutral';
|
|
384
|
+
cancelBtn.textContent = 'Cancel';
|
|
385
|
+
cancelBtn.addEventListener('click', () => this.hideCommandModal());
|
|
386
|
+
|
|
387
|
+
const okBtn = document.createElement('button');
|
|
388
|
+
okBtn.type = 'button';
|
|
389
|
+
okBtn.className = 'btn btn-primary';
|
|
390
|
+
okBtn.textContent = 'OK';
|
|
391
|
+
const submit = async () => {
|
|
392
|
+
errorEl.classList.add('hidden');
|
|
393
|
+
if (input.value.trim() === '') {
|
|
394
|
+
errorEl.textContent = 'Command cannot be blank.';
|
|
395
|
+
errorEl.classList.remove('hidden');
|
|
396
|
+
input.focus();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
let res;
|
|
400
|
+
try {
|
|
401
|
+
res = await fetch('/actions/set-command', {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: { 'Content-Type': 'application/json' },
|
|
404
|
+
body: JSON.stringify({ knotId, command: input.value })
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
errorEl.textContent = 'Failed to reach the server.';
|
|
408
|
+
errorEl.classList.remove('hidden');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (res.status === 204) {
|
|
412
|
+
this.hideCommandModal();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
errorEl.textContent = 'Failed to save command.';
|
|
416
|
+
errorEl.classList.remove('hidden');
|
|
417
|
+
input.focus();
|
|
418
|
+
input.select();
|
|
419
|
+
};
|
|
420
|
+
okBtn.addEventListener('click', submit);
|
|
421
|
+
|
|
422
|
+
buttonRow.append(cancelBtn, okBtn);
|
|
423
|
+
body.appendChild(buttonRow);
|
|
424
|
+
|
|
425
|
+
panel.append(header, body);
|
|
426
|
+
overlay.appendChild(panel);
|
|
427
|
+
document.body.appendChild(overlay);
|
|
428
|
+
|
|
429
|
+
panel.addEventListener('keydown', (e) => {
|
|
430
|
+
if (e.key === 'Escape') {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
this.hideCommandModal();
|
|
433
|
+
} else if (e.key === 'Enter') {
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
submit();
|
|
436
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
input.select();
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
input.focus();
|
|
443
|
+
input.select();
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
hideCommandModal() {
|
|
447
|
+
document.getElementById('command-modal')?.remove();
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
// Insert Parent's modal - near-identical to showCommandModal above (same field, same
|
|
451
|
+
// Escape/Enter/Cmd+A keydown wiring), with three deliberate differences: the field always
|
|
452
|
+
// starts blank (it's the new knot's own command, not a rename of the existing one, so there's
|
|
453
|
+
// no "current" text to pre-fill or select-all on open), the heading/placeholder/endpoint are
|
|
454
|
+
// its own, and success moves the active knot (service.ts's handleInsertParent broadcasts its
|
|
455
|
+
// own resetAndFocusCommandInput) rather than just refreshing the edited knot in place.
|
|
456
|
+
showInsertParentModal(knotId) {
|
|
457
|
+
this.hideInsertParentModal();
|
|
458
|
+
closeAllDropdowns();
|
|
459
|
+
|
|
460
|
+
const overlay = document.createElement('div');
|
|
461
|
+
overlay.id = 'insert-parent-modal';
|
|
462
|
+
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';
|
|
463
|
+
|
|
464
|
+
const panel = document.createElement('div');
|
|
465
|
+
panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
|
|
466
|
+
panel.setAttribute('role', 'dialog');
|
|
467
|
+
panel.setAttribute('aria-modal', 'true');
|
|
468
|
+
panel.setAttribute('tabindex', '-1');
|
|
469
|
+
|
|
470
|
+
const header = document.createElement('div');
|
|
471
|
+
header.className = 'px-6 py-4 border-b border-base-200';
|
|
472
|
+
const heading = document.createElement('h3');
|
|
473
|
+
heading.className = 'text-lg font-medium text-base-content';
|
|
474
|
+
heading.textContent = 'Insert Parent';
|
|
475
|
+
header.appendChild(heading);
|
|
476
|
+
|
|
477
|
+
const body = document.createElement('div');
|
|
478
|
+
body.className = 'px-6 py-4';
|
|
479
|
+
|
|
480
|
+
const errorEl = document.createElement('div');
|
|
481
|
+
errorEl.className = 'alert alert-error text-sm mb-3 hidden';
|
|
482
|
+
errorEl.setAttribute('role', 'alert');
|
|
483
|
+
errorEl.setAttribute('aria-live', 'assertive');
|
|
484
|
+
body.appendChild(errorEl);
|
|
485
|
+
|
|
486
|
+
const input = document.createElement('input');
|
|
487
|
+
input.type = 'text';
|
|
488
|
+
input.className = 'input input-bordered w-full font-mono';
|
|
489
|
+
input.placeholder = 'New command';
|
|
490
|
+
input.setAttribute('aria-label', 'Command');
|
|
491
|
+
body.appendChild(input);
|
|
492
|
+
|
|
493
|
+
const buttonRow = document.createElement('div');
|
|
494
|
+
buttonRow.className = 'flex justify-end gap-2 mt-4';
|
|
495
|
+
|
|
496
|
+
const cancelBtn = document.createElement('button');
|
|
497
|
+
cancelBtn.type = 'button';
|
|
498
|
+
cancelBtn.className = 'btn btn-neutral';
|
|
499
|
+
cancelBtn.textContent = 'Cancel';
|
|
500
|
+
cancelBtn.addEventListener('click', () => this.hideInsertParentModal());
|
|
501
|
+
|
|
502
|
+
const okBtn = document.createElement('button');
|
|
503
|
+
okBtn.type = 'button';
|
|
504
|
+
okBtn.className = 'btn btn-primary';
|
|
505
|
+
okBtn.textContent = 'OK';
|
|
506
|
+
const submit = async () => {
|
|
507
|
+
errorEl.classList.add('hidden');
|
|
508
|
+
if (input.value.trim() === '') {
|
|
509
|
+
errorEl.textContent = 'Command cannot be blank.';
|
|
510
|
+
errorEl.classList.remove('hidden');
|
|
511
|
+
input.focus();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
let res;
|
|
515
|
+
try {
|
|
516
|
+
res = await fetch('/actions/insert-parent', {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { 'Content-Type': 'application/json' },
|
|
519
|
+
body: JSON.stringify({ knotId, command: input.value })
|
|
520
|
+
});
|
|
521
|
+
} catch {
|
|
522
|
+
errorEl.textContent = 'Failed to reach the server.';
|
|
523
|
+
errorEl.classList.remove('hidden');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (res.status === 204) {
|
|
527
|
+
this.hideInsertParentModal();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
errorEl.textContent = 'Failed to insert parent.';
|
|
531
|
+
errorEl.classList.remove('hidden');
|
|
532
|
+
input.focus();
|
|
533
|
+
input.select();
|
|
534
|
+
};
|
|
535
|
+
okBtn.addEventListener('click', submit);
|
|
536
|
+
|
|
537
|
+
buttonRow.append(cancelBtn, okBtn);
|
|
538
|
+
body.appendChild(buttonRow);
|
|
539
|
+
|
|
540
|
+
panel.append(header, body);
|
|
541
|
+
overlay.appendChild(panel);
|
|
542
|
+
document.body.appendChild(overlay);
|
|
543
|
+
|
|
544
|
+
panel.addEventListener('keydown', (e) => {
|
|
545
|
+
if (e.key === 'Escape') {
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
this.hideInsertParentModal();
|
|
548
|
+
} else if (e.key === 'Enter') {
|
|
549
|
+
e.preventDefault();
|
|
550
|
+
submit();
|
|
551
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
input.select();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
input.focus();
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
hideInsertParentModal() {
|
|
561
|
+
document.getElementById('insert-parent-modal')?.remove();
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Tree/graph pane: SVG connector lines + drag-to-pan.
|
|
566
|
+
// Ported from dialog-tool's own main.js (initTreeGraph/drawTreeArrows) - same element ids
|
|
567
|
+
// (#tree-pane, [data-tree-node-id], [data-parent-id], [data-active-knot]) so this applies
|
|
568
|
+
// unchanged to tree-pane.ts's markup.
|
|
569
|
+
|
|
570
|
+
_treeGraphReady: false,
|
|
571
|
+
_arrowSvg: null,
|
|
572
|
+
_treeGraphZoom: 1,
|
|
573
|
+
_TREE_GRAPH_MIN_ZOOM: 0.2,
|
|
574
|
+
_TREE_GRAPH_MAX_ZOOM: 2.5,
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Applies a zoom factor centered on a point (mouse position for wheel-zoom, viewport center for
|
|
578
|
+
* the +/- buttons) by scaling tree-pane.ts's #tree-pane-content wrapper - #tree-pane itself
|
|
579
|
+
* stays unscaled so it remains a plain, predictable overflow:auto scroll container (its
|
|
580
|
+
* scrollWidth/scrollHeight naturally grow to match the scaled content per the CSS Transforms
|
|
581
|
+
* spec, so panning still works correctly post-zoom). Keeps the point under clientX/clientY
|
|
582
|
+
* visually fixed by solving for the scrollLeft/scrollTop that preserve its position: the
|
|
583
|
+
* transform-origin is the content's own top-left (see tree-pane.ts's origin-top-left class), so
|
|
584
|
+
* a point at contentX/contentY (in unscaled content pixels) always renders at
|
|
585
|
+
* contentX*zoom - scrollLeft, and we want that to equal the same viewport offset before and
|
|
586
|
+
* after the zoom changes.
|
|
587
|
+
*/
|
|
588
|
+
_zoomTreeGraph(factor, clientX, clientY) {
|
|
589
|
+
const pane = document.getElementById('tree-pane');
|
|
590
|
+
const content = document.getElementById('tree-pane-content');
|
|
591
|
+
if (!pane || !content) return;
|
|
592
|
+
|
|
593
|
+
const oldZoom = this._treeGraphZoom;
|
|
594
|
+
const newZoom = Math.min(this._TREE_GRAPH_MAX_ZOOM, Math.max(this._TREE_GRAPH_MIN_ZOOM, oldZoom * factor));
|
|
595
|
+
if (newZoom === oldZoom) return;
|
|
596
|
+
|
|
597
|
+
const rect = pane.getBoundingClientRect();
|
|
598
|
+
const originX = clientX !== undefined ? clientX - rect.left : pane.clientWidth / 2;
|
|
599
|
+
const originY = clientY !== undefined ? clientY - rect.top : pane.clientHeight / 2;
|
|
600
|
+
const contentX = pane.scrollLeft + originX;
|
|
601
|
+
const contentY = pane.scrollTop + originY;
|
|
602
|
+
const ratio = newZoom / oldZoom;
|
|
603
|
+
|
|
604
|
+
this._treeGraphZoom = newZoom;
|
|
605
|
+
content.style.transform = `scale(${newZoom})`;
|
|
606
|
+
pane.scrollLeft = contentX * ratio - originX;
|
|
607
|
+
pane.scrollTop = contentY * ratio - originY;
|
|
608
|
+
requestAnimationFrame(() => this.drawTreeArrows());
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
// The bottom-right zoom buttons (render.ts) - centered on the pane's current viewport middle,
|
|
612
|
+
// since there's no cursor position to zoom toward from a button click.
|
|
613
|
+
zoomTreeGraphIn() {
|
|
614
|
+
this._zoomTreeGraph(1.25);
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
zoomTreeGraphOut() {
|
|
618
|
+
this._zoomTreeGraph(1 / 1.25);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
initTreeGraph() {
|
|
622
|
+
if (this._treeGraphReady) return;
|
|
623
|
+
this._treeGraphReady = true;
|
|
624
|
+
|
|
625
|
+
const pane = document.getElementById('tree-pane');
|
|
626
|
+
if (!pane) return;
|
|
627
|
+
|
|
628
|
+
requestAnimationFrame(() => this.drawTreeArrows());
|
|
629
|
+
const self = this;
|
|
630
|
+
const observer = new MutationObserver(() => {
|
|
631
|
+
observer.disconnect();
|
|
632
|
+
requestAnimationFrame(() => {
|
|
633
|
+
self.drawTreeArrows();
|
|
634
|
+
observer.observe(pane, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-active-knot'] });
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
observer.observe(pane, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-active-knot'] });
|
|
638
|
+
|
|
639
|
+
new ResizeObserver(() => requestAnimationFrame(() => self.drawTreeArrows())).observe(pane);
|
|
640
|
+
|
|
641
|
+
// Drag-to-scroll (pan): clicking the pane background and dragging scrolls the pane. Clicks
|
|
642
|
+
// on interactive descendants (the node pill, the "..." trigger) are left alone so they still
|
|
643
|
+
// fire their own data-on:click handlers. The pill is a div[role="button"] rather than a real
|
|
644
|
+
// <button> (it has to contain the menu's nested <details>), so that's excluded explicitly
|
|
645
|
+
// alongside the genuine form controls.
|
|
646
|
+
//
|
|
647
|
+
// Pointer events + setPointerCapture, not plain mouse events on document: this pane lives
|
|
648
|
+
// inside the skein webview's own iframe, so a document-level mouseup listener never fires at
|
|
649
|
+
// all if the button is released outside that iframe (or outside the VS Code window entirely,
|
|
650
|
+
// e.g. dragging past its edge) - dragging was left stuck true forever, and the pane kept
|
|
651
|
+
// panning on the next mousemove even with the button up (regression reported after the dot-
|
|
652
|
+
// grid background made panning motion visible enough to notice).
|
|
653
|
+
//
|
|
654
|
+
// setPointerCapture alone isn't enough here: it doesn't reliably survive the pointer leaving
|
|
655
|
+
// this webview's own iframe for VS Code's surrounding chrome (a separate frame/document our
|
|
656
|
+
// script has no visibility into at all), so a release out there can still go unseen. Two
|
|
657
|
+
// independent backstops cover that: (1) document's pointerleave - unlike move/up, per spec
|
|
658
|
+
// enter/leave events keep targeting wherever the pointer actually is even while captured, so
|
|
659
|
+
// this reliably fires the instant the cursor exits the iframe, and we proactively end the
|
|
660
|
+
// drag right then (we'd get no further events out there to track anyway); (2) e.buttons on
|
|
661
|
+
// every pointermove we do still receive - a direct, always-accurate read of which buttons are
|
|
662
|
+
// physically down *right now*, independent of whether we ever saw the up/cancel event for
|
|
663
|
+
// this pointer - catches the drag having ended out-of-frame if the cursor re-enters afterward
|
|
664
|
+
// with the button already released.
|
|
665
|
+
let dragging = false;
|
|
666
|
+
let startX = 0;
|
|
667
|
+
let startY = 0;
|
|
668
|
+
let scrollX = 0;
|
|
669
|
+
let scrollY = 0;
|
|
670
|
+
|
|
671
|
+
const stopDragging = () => {
|
|
672
|
+
if (!dragging) return;
|
|
673
|
+
dragging = false;
|
|
674
|
+
pane.style.cursor = '';
|
|
675
|
+
pane.style.userSelect = '';
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
pane.addEventListener('pointerdown', (e) => {
|
|
679
|
+
if (e.button !== 0) return;
|
|
680
|
+
if (e.target.closest('button, a, input, select, textarea, [role="button"], details')) return;
|
|
681
|
+
dragging = true;
|
|
682
|
+
startX = e.clientX;
|
|
683
|
+
startY = e.clientY;
|
|
684
|
+
scrollX = pane.scrollLeft;
|
|
685
|
+
scrollY = pane.scrollTop;
|
|
686
|
+
pane.style.cursor = 'grabbing';
|
|
687
|
+
pane.style.userSelect = 'none';
|
|
688
|
+
pane.setPointerCapture(e.pointerId);
|
|
689
|
+
e.preventDefault();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
pane.addEventListener('pointermove', (e) => {
|
|
693
|
+
if (!dragging) return;
|
|
694
|
+
if ((e.buttons & 1) === 0) {
|
|
695
|
+
stopDragging();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
pane.scrollLeft = scrollX - (e.clientX - startX);
|
|
699
|
+
pane.scrollTop = scrollY - (e.clientY - startY);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
pane.addEventListener('pointerup', stopDragging);
|
|
703
|
+
pane.addEventListener('pointercancel', stopDragging);
|
|
704
|
+
document.addEventListener('pointerleave', stopDragging);
|
|
705
|
+
|
|
706
|
+
// Mouse-wheel zoom, centered on the cursor - preventDefault (and { passive: false }, without
|
|
707
|
+
// which some browsers ignore preventDefault entirely on wheel listeners for scroll-performance
|
|
708
|
+
// reasons) replaces the pane's native wheel-scroll with zoom instead, matching the request:
|
|
709
|
+
// panning is already drag-based, so repurposing plain wheel for zoom doesn't remove any
|
|
710
|
+
// existing way to move around. The exponential factor gives smooth, proportional zoom for
|
|
711
|
+
// both discrete mouse wheels (large deltaY per notch) and fine-grained trackpad scrolling.
|
|
712
|
+
pane.addEventListener('wheel', (e) => {
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
const factor = Math.exp(-e.deltaY * 0.0015);
|
|
715
|
+
self._zoomTreeGraph(factor, e.clientX, e.clientY);
|
|
716
|
+
}, { passive: false });
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
drawTreeArrows() {
|
|
720
|
+
const pane = document.getElementById('tree-pane');
|
|
721
|
+
if (!pane) return;
|
|
722
|
+
|
|
723
|
+
this._arrowSvg?.remove();
|
|
724
|
+
this._arrowSvg = null;
|
|
725
|
+
|
|
726
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
727
|
+
Object.assign(svg.style, {
|
|
728
|
+
position: 'absolute',
|
|
729
|
+
top: '0',
|
|
730
|
+
left: '0',
|
|
731
|
+
width: pane.scrollWidth + 'px',
|
|
732
|
+
height: pane.scrollHeight + 'px',
|
|
733
|
+
overflow: 'visible',
|
|
734
|
+
pointerEvents: 'none'
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
738
|
+
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
|
739
|
+
marker.setAttribute('id', 'tree-arrow');
|
|
740
|
+
marker.setAttribute('markerWidth', '6');
|
|
741
|
+
marker.setAttribute('markerHeight', '6');
|
|
742
|
+
marker.setAttribute('refX', '5');
|
|
743
|
+
marker.setAttribute('refY', '3');
|
|
744
|
+
marker.setAttribute('orient', 'auto');
|
|
745
|
+
const tip = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
746
|
+
tip.setAttribute('d', 'M0,0 L0,6 L6,3 z');
|
|
747
|
+
tip.setAttribute('fill', 'currentColor');
|
|
748
|
+
marker.appendChild(tip);
|
|
749
|
+
defs.appendChild(marker);
|
|
750
|
+
svg.appendChild(defs);
|
|
751
|
+
|
|
752
|
+
const paneRect = pane.getBoundingClientRect();
|
|
753
|
+
const scrollLeft = pane.scrollLeft;
|
|
754
|
+
const scrollTop = pane.scrollTop;
|
|
755
|
+
const nodes = {};
|
|
756
|
+
pane.querySelectorAll('[data-tree-node-id]').forEach((el) => {
|
|
757
|
+
const id = el.getAttribute('data-tree-node-id');
|
|
758
|
+
const pid = el.getAttribute('data-parent-id'); // absent on root
|
|
759
|
+
const r = el.getBoundingClientRect();
|
|
760
|
+
nodes[id] = {
|
|
761
|
+
parentId: pid || null,
|
|
762
|
+
cx: r.left - paneRect.left + scrollLeft + r.width / 2,
|
|
763
|
+
top: r.top - paneRect.top + scrollTop,
|
|
764
|
+
bottom: r.bottom - paneRect.top + scrollTop
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
for (const n of Object.values(nodes)) {
|
|
769
|
+
if (!n.parentId || !nodes[n.parentId]) continue;
|
|
770
|
+
const p = nodes[n.parentId];
|
|
771
|
+
const x1 = p.cx;
|
|
772
|
+
const y1 = p.bottom;
|
|
773
|
+
const x2 = n.cx;
|
|
774
|
+
const y2 = n.top;
|
|
775
|
+
// Elbow connector: straight down from the parent, a sharp turn to horizontal at the
|
|
776
|
+
// midpoint, then a slightly curved turn back to vertical into the child.
|
|
777
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
778
|
+
if (x1 === x2) {
|
|
779
|
+
path.setAttribute('d', `M${x1},${y1} L${x2},${y2}`);
|
|
780
|
+
} else {
|
|
781
|
+
const ymid = (y1 + y2) / 2;
|
|
782
|
+
const dir = x2 > x1 ? 1 : -1;
|
|
783
|
+
const r = Math.min(10, Math.abs(x2 - x1) / 2, Math.max(0, y2 - ymid));
|
|
784
|
+
const xTurn = x2 - dir * r;
|
|
785
|
+
path.setAttribute(
|
|
786
|
+
'd',
|
|
787
|
+
`M${x1},${y1} L${x1},${ymid} L${xTurn},${ymid} Q${x2},${ymid} ${x2},${ymid + r} L${x2},${y2}`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
path.setAttribute('stroke', 'currentColor');
|
|
791
|
+
path.setAttribute('stroke-opacity', '0.35');
|
|
792
|
+
path.setAttribute('stroke-width', '1.5');
|
|
793
|
+
path.setAttribute('fill', 'none');
|
|
794
|
+
path.setAttribute('marker-end', 'url(#tree-arrow)');
|
|
795
|
+
svg.appendChild(path);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
this._arrowSvg = svg;
|
|
799
|
+
pane.appendChild(svg);
|
|
800
|
+
|
|
801
|
+
const activeId = pane.getAttribute('data-active-knot');
|
|
802
|
+
if (activeId) {
|
|
803
|
+
pane.querySelector(`[data-tree-node-id="${activeId}"]`)?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
// Tree pane drag-to-resize. Ported from dialog-tool's own main.js, same element ids
|
|
809
|
+
// (#tree-pane-handle, #tree-pane-outer) as render.ts's renderApp.
|
|
810
|
+
|
|
811
|
+
_treePaneResizeReady: false,
|
|
812
|
+
|
|
813
|
+
initTreePaneResize() {
|
|
814
|
+
if (this._treePaneResizeReady) return;
|
|
815
|
+
this._treePaneResizeReady = true;
|
|
816
|
+
|
|
817
|
+
const handle = document.getElementById('tree-pane-handle');
|
|
818
|
+
const pane = document.getElementById('tree-pane-outer');
|
|
819
|
+
if (!handle || !pane) return;
|
|
820
|
+
|
|
821
|
+
let currentWidth = localStorage.getItem('treePaneWidth') || null;
|
|
822
|
+
if (currentWidth) pane.style.width = currentWidth;
|
|
823
|
+
|
|
824
|
+
new MutationObserver(() => {
|
|
825
|
+
if (currentWidth && pane.style.width !== currentWidth) {
|
|
826
|
+
pane.style.width = currentWidth;
|
|
827
|
+
}
|
|
828
|
+
}).observe(pane, { attributes: true, attributeFilter: ['style'] });
|
|
829
|
+
|
|
830
|
+
handle.addEventListener('mousedown', (startEvt) => {
|
|
831
|
+
startEvt.preventDefault();
|
|
832
|
+
const startX = startEvt.clientX;
|
|
833
|
+
const startWidth = pane.getBoundingClientRect().width;
|
|
834
|
+
|
|
835
|
+
const onMove = (e) => {
|
|
836
|
+
const delta = e.clientX - startX;
|
|
837
|
+
const maxWidth = Math.floor(window.innerWidth * 0.8);
|
|
838
|
+
currentWidth = Math.min(maxWidth, Math.max(160, startWidth + delta)) + 'px';
|
|
839
|
+
pane.style.width = currentWidth;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const onUp = () => {
|
|
843
|
+
document.removeEventListener('mousemove', onMove);
|
|
844
|
+
document.removeEventListener('mouseup', onUp);
|
|
845
|
+
document.body.style.cursor = '';
|
|
846
|
+
document.body.style.userSelect = '';
|
|
847
|
+
if (currentWidth) localStorage.setItem('treePaneWidth', currentWidth);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
document.body.style.cursor = 'col-resize';
|
|
851
|
+
document.body.style.userSelect = 'none';
|
|
852
|
+
document.addEventListener('mousemove', onMove);
|
|
853
|
+
document.addEventListener('mouseup', onUp);
|
|
854
|
+
});
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
// The search box's own ArrowUp/ArrowDown navigation (render.ts's renderSearchBox/
|
|
858
|
+
// renderSearchResults - SEARCH_RESULT_KEYDOWN calls this from both the input and every result
|
|
859
|
+
// button). Without this, ArrowDown/ArrowUp inside the results dropdown just did the browser's
|
|
860
|
+
// default thing - scrolled the (overflow-y-auto) list - instead of moving between results, since
|
|
861
|
+
// there's no native way to make plain buttons in a list behave like a real listbox. current is
|
|
862
|
+
// whichever element the keydown fired on (el, via Datastar); direction is 1 (down) or -1 (up).
|
|
863
|
+
// Clamps at the last result rather than wrapping - going past the first one refocuses the input,
|
|
864
|
+
// a normal combobox convention - so there's always an obvious way back to typing.
|
|
865
|
+
navigateSearchResults(current, direction) {
|
|
866
|
+
const results = Array.from(document.querySelectorAll('[data-search-result]'));
|
|
867
|
+
if (results.length === 0) return;
|
|
868
|
+
|
|
869
|
+
const currentIndex = results.indexOf(current);
|
|
870
|
+
const nextIndex = currentIndex + direction;
|
|
871
|
+
if (nextIndex < 0) {
|
|
872
|
+
document.getElementById('skein-search-input')?.focus();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
results[Math.min(results.length - 1, nextIndex)]?.focus();
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
// The per-knot actions menu (knot-menu.ts's renderKnotMenu) needs almost no client-side code:
|
|
879
|
+
// it's a native <details class="dropdown dropdown-right"> inline in each knot's own markup,
|
|
880
|
+
// opened by posting to /actions/open-graph-menu or /actions/open-transcript-menu (a plain
|
|
881
|
+
// Datastar data-on:click on the trigger, like everything else in this UI) and positioned by plain
|
|
882
|
+
// daisyUI .dropdown/.dropdown-content CSS - the server decides which knot's menu is open in
|
|
883
|
+
// each pane separately (session.ts's graphMenuId/transcriptMenuId) and sets the native `open`
|
|
884
|
+
// attribute directly in the re-rendered markup, plus toggles the dropdown-content's own
|
|
885
|
+
// popover="manual" via a data-effect (see knot-menu.ts's doc comment for why it's a real
|
|
886
|
+
// top-layer popover, and why "manual" rather than the default "auto"). "manual" means nothing
|
|
887
|
+
// ever closes it but that explicit toggle - not outside clicks, not Escape - so the listener
|
|
888
|
+
// below is what actually provides the outside-click-to-close a user expects.
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// Closes every open per-knot dropdown, both visually (removing `open`/hiding the Popover, for
|
|
892
|
+
// instant feedback) and server-side (POST /actions/close-menus keeps session.ts's graphMenuId/
|
|
893
|
+
// transcriptMenuId in sync, so a later unrelated SSE patch doesn't re-open it). Shared by the
|
|
894
|
+
// outside-click listener below and sk.showLabelModal, which needs the same cleanup when its
|
|
895
|
+
// trigger was a menu item inside a still-open dropdown.
|
|
896
|
+
function closeAllDropdowns() {
|
|
897
|
+
const openMenus = document.querySelectorAll('details.dropdown[open]');
|
|
898
|
+
if (openMenus.length === 0) return;
|
|
899
|
+
openMenus.forEach((details) => {
|
|
900
|
+
details.removeAttribute('open');
|
|
901
|
+
details.querySelector('[popover]')?.togglePopover(false);
|
|
902
|
+
});
|
|
903
|
+
fetch('/actions/close-menus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Dismisses the search box's results dropdown the same way Escape does (clears the query - see
|
|
907
|
+
// render.ts's renderSearchBox) - mirrors closeAllDropdowns' own direct-DOM-update-plus-raw-fetch
|
|
908
|
+
// shape, since a plain click listener has no Datastar action/signal access of its own. Setting
|
|
909
|
+
// input.value directly (rather than going through Datastar) is fine here: the POST below tells
|
|
910
|
+
// the server the query is now blank, and the next SSE patch re-renders the input from that
|
|
911
|
+
// server-side truth anyway, same as every other patched element.
|
|
912
|
+
function dismissSearch() {
|
|
913
|
+
const input = document.getElementById('skein-search-input');
|
|
914
|
+
if (!input || input.value === '') return;
|
|
915
|
+
input.value = '';
|
|
916
|
+
fetch('/actions/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ searchQuery: '' }) });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Clicking outside the open dropdown closes it; clicking outside the search box dismisses its
|
|
920
|
+
// results the same way. Skips anything inside a .dropdown/#skein-search-box entirely - a click on
|
|
921
|
+
// a trigger, menu item, or search result is already handled by its own data-on:click.
|
|
922
|
+
document.addEventListener('click', (evt) => {
|
|
923
|
+
if (!evt.target.closest('.dropdown')) closeAllDropdowns();
|
|
924
|
+
if (!evt.target.closest('#skein-search-box')) dismissSearch();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// Keyboard accelerators - mirrors dialog-tool's own Cmd/Option-based scheme (doc/skein.md),
|
|
929
|
+
// adapted to dialog-ide's actual routes. One capturing document-level keydown listener,
|
|
930
|
+
// intentionally global rather than scoped to "focus is outside a text field": the command input
|
|
931
|
+
// auto-refocuses after nearly every action (sk.resetAndFocusCommandInput), so excluding INPUT/
|
|
932
|
+
// TEXTAREA focus would make every shortcut below fire almost never, and none of these modifier
|
|
933
|
+
// combos (Cmd, Option) are part of normal command typing anyway - same reasoning undo/redo
|
|
934
|
+
// already used. Suppressed while the progress modal is up (a replay is already in flight; its own
|
|
935
|
+
// Cancel button is the only control that makes sense then).
|
|
936
|
+
//
|
|
937
|
+
// Knot-scoped shortcuts (Option+letter, no Shift) act on whichever knot is currently active, not
|
|
938
|
+
// whichever knot's menu happens to be open/hovered - read from #tree-pane's data-active-knot, the
|
|
939
|
+
// same attribute drawTreeArrows already relies on. Root-only guards (id 0 can't be locked,
|
|
940
|
+
// labeled, or deleted) mirror knot-menu.ts's own menuItemClass(isRoot) disabling - skipping
|
|
941
|
+
// server-side here rather than letting session.ts throw into an unhandled 500.
|
|
942
|
+
|
|
943
|
+
function postAction(path, body) {
|
|
944
|
+
fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function activeKnotId() {
|
|
948
|
+
const raw = document.getElementById('tree-pane')?.getAttribute('data-active-knot');
|
|
949
|
+
return raw ? Number(raw) : null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function spineLeafId() {
|
|
953
|
+
const raw = document.querySelector('nav')?.getAttribute('data-spine-leaf-id');
|
|
954
|
+
return raw ? Number(raw) : null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ⌥L's target: the knot's current label, read off the "Edit Label" menu button's own
|
|
958
|
+
// data-current-label attribute (knot-menu.ts) rather than requiring the dropdown to be open -
|
|
959
|
+
// that attribute exists in the DOM regardless of the <details>'s open/closed state.
|
|
960
|
+
function currentLabelOf(knotId) {
|
|
961
|
+
return document.querySelector(`#knot-${knotId} [data-current-label]`)?.dataset.currentLabel ?? '';
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ⌥E's target: the knot's current command, read off the "Edit Command" menu button's own
|
|
965
|
+
// data-current-command attribute (knot-menu.ts) - same convention as currentLabelOf above.
|
|
966
|
+
function currentCommandOf(knotId) {
|
|
967
|
+
return document.querySelector(`#knot-${knotId} [data-current-command]`)?.dataset.currentCommand ?? '';
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
document.addEventListener('keydown', (evt) => {
|
|
971
|
+
if (
|
|
972
|
+
document.getElementById('progress-modal') ||
|
|
973
|
+
document.getElementById('label-modal') ||
|
|
974
|
+
document.getElementById('command-modal') ||
|
|
975
|
+
document.getElementById('insert-parent-modal')
|
|
976
|
+
) return;
|
|
977
|
+
|
|
978
|
+
const mod = evt.metaKey || evt.ctrlKey;
|
|
979
|
+
const opt = evt.altKey;
|
|
980
|
+
// Cmd/Ctrl never recompose a letter key, so evt.key (plain 's'/'z') is fine for the mod branch
|
|
981
|
+
// below. Option does recompose it on macOS - Option+R types "®", not "r" - so evt.key would
|
|
982
|
+
// never match the letters below and the shortcut would silently fall through to inserting that
|
|
983
|
+
// composed character into whatever's focused instead of calling preventDefault(). evt.code (the
|
|
984
|
+
// physical key position, e.g. "KeyR") is layout/composition-independent and unaffected by
|
|
985
|
+
// Option, so every opt-branch check below matches on that instead.
|
|
986
|
+
const key = evt.key.toLowerCase();
|
|
987
|
+
const code = evt.code;
|
|
988
|
+
|
|
989
|
+
// Command Palette - VS Code's webview-forwarding bridge only reaches the outer webview shell
|
|
990
|
+
// (getWebviewHtml in extension.ts), not this nested localhost iframe where keydowns actually
|
|
991
|
+
// land, so there's no when-clause that can catch this directly. Instead, relay it up via
|
|
992
|
+
// postMessage; the shell re-dispatches a synthetic keydown on itself, which the bridge *can*
|
|
993
|
+
// see and forward on to workbench.action.showCommands (bound in package.json's
|
|
994
|
+
// contributes.keybindings, scoped to activeWebviewPanelId == 'dialogIdeSkein'). Deliberately
|
|
995
|
+
// narrow (just this one combo) rather than relaying every unhandled key - the same modifier
|
|
996
|
+
// combos without Shift (Cmd/Ctrl+A/C/V/X/Z) have native text-editing meaning in whatever's
|
|
997
|
+
// focused, which relaying would break.
|
|
998
|
+
if (mod && evt.shiftKey && !opt && code === 'KeyA') {
|
|
999
|
+
evt.preventDefault();
|
|
1000
|
+
window.parent.postMessage(
|
|
1001
|
+
{ type: 'forward-keydown', key: 'a', code, metaKey: evt.metaKey, ctrlKey: evt.ctrlKey, shiftKey: true, altKey: false },
|
|
1002
|
+
'*'
|
|
1003
|
+
);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Tree-wide.
|
|
1008
|
+
if (mod && !opt && key === 's') {
|
|
1009
|
+
evt.preventDefault();
|
|
1010
|
+
postAction('/actions/save');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (mod && !opt && key === 'z') {
|
|
1014
|
+
evt.preventDefault();
|
|
1015
|
+
postAction(evt.shiftKey ? '/actions/redo' : '/actions/undo');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (opt && evt.shiftKey && code === 'KeyR') {
|
|
1019
|
+
evt.preventDefault();
|
|
1020
|
+
postAction('/actions/replay-all');
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (opt && evt.shiftKey && code === 'KeyB') {
|
|
1024
|
+
evt.preventDefault();
|
|
1025
|
+
const knotId = spineLeafId();
|
|
1026
|
+
if (knotId !== null) postAction('/actions/bless-changes', { knotId });
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (opt && !evt.shiftKey && code === 'KeyF') {
|
|
1030
|
+
evt.preventDefault();
|
|
1031
|
+
document.getElementById('skein-search-input')?.focus();
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// First Knot / Last Knot - jump straight to root / the selected spine's leaf. Unlike every
|
|
1035
|
+
// other shortcut here, these (and the four plain-arrow ones below) don't need activeKnotId() -
|
|
1036
|
+
// session.ts's navigateSpine reads the current active knot and computes the target itself, so
|
|
1037
|
+
// there's nothing for the client to look up first.
|
|
1038
|
+
if (opt && evt.shiftKey && code === 'ArrowUp') {
|
|
1039
|
+
evt.preventDefault();
|
|
1040
|
+
postAction('/actions/navigate-spine', { direction: 'first' });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (opt && evt.shiftKey && code === 'ArrowDown') {
|
|
1044
|
+
evt.preventDefault();
|
|
1045
|
+
postAction('/actions/navigate-spine', { direction: 'last' });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Knot-scoped - act on the active knot.
|
|
1050
|
+
if (opt && !evt.shiftKey) {
|
|
1051
|
+
const knotId = activeKnotId();
|
|
1052
|
+
if (knotId === null) return;
|
|
1053
|
+
|
|
1054
|
+
if (code === 'KeyB') {
|
|
1055
|
+
evt.preventDefault();
|
|
1056
|
+
postAction('/actions/bless-knot', { knotId });
|
|
1057
|
+
} else if (code === 'KeyR') {
|
|
1058
|
+
evt.preventDefault();
|
|
1059
|
+
postAction('/actions/replay-to', { knotId });
|
|
1060
|
+
} else if (code === 'KeyA') {
|
|
1061
|
+
evt.preventDefault();
|
|
1062
|
+
postAction('/actions/new-child', { knotId });
|
|
1063
|
+
} else if (code === 'KeyT') {
|
|
1064
|
+
// Trace - root has no parent to replay to, so its own Trace action traces startup instead
|
|
1065
|
+
// (same isRoot branch knot-menu.ts's Trace item uses). Doesn't check whether this knot was
|
|
1066
|
+
// reached via a keystroke prompt first (unlike knot-menu.ts's up-front disabling) - session.
|
|
1067
|
+
// ts's traceKnot itself already refuses that case safely, same as every other accelerator
|
|
1068
|
+
// here that doesn't pre-validate a knot's specific state before firing.
|
|
1069
|
+
evt.preventDefault();
|
|
1070
|
+
if (knotId === 0) {
|
|
1071
|
+
postAction('/actions/trace-startup');
|
|
1072
|
+
} else {
|
|
1073
|
+
postAction('/actions/trace-knot', { knotId });
|
|
1074
|
+
}
|
|
1075
|
+
} else if (code === 'KeyK' && knotId !== 0) {
|
|
1076
|
+
evt.preventDefault();
|
|
1077
|
+
postAction('/actions/toggle-lock', { knotId });
|
|
1078
|
+
} else if (code === 'KeyD' && knotId !== 0) {
|
|
1079
|
+
evt.preventDefault();
|
|
1080
|
+
postAction('/actions/delete-knot', { knotId });
|
|
1081
|
+
} else if (code === 'KeyL' && knotId !== 0) {
|
|
1082
|
+
evt.preventDefault();
|
|
1083
|
+
sk.showLabelModal(knotId, currentLabelOf(knotId));
|
|
1084
|
+
} else if (code === 'KeyE' && knotId !== 0) {
|
|
1085
|
+
evt.preventDefault();
|
|
1086
|
+
sk.showCommandModal(knotId, currentCommandOf(knotId));
|
|
1087
|
+
} else if (code === 'KeyX') {
|
|
1088
|
+
// Toggle Expand - the nav graph's ▾/▸ chevron (tree-pane.ts), otherwise mouse-only.
|
|
1089
|
+
evt.preventDefault();
|
|
1090
|
+
postAction('/actions/toggle-tree-node', { knotId });
|
|
1091
|
+
} else if (code === 'ArrowUp') {
|
|
1092
|
+
// Parent Knot.
|
|
1093
|
+
evt.preventDefault();
|
|
1094
|
+
postAction('/actions/navigate-spine', { direction: 'up' });
|
|
1095
|
+
} else if (code === 'ArrowDown') {
|
|
1096
|
+
// Child Knot (whichever child is currently on the spine).
|
|
1097
|
+
evt.preventDefault();
|
|
1098
|
+
postAction('/actions/navigate-spine', { direction: 'down' });
|
|
1099
|
+
} else if (code === 'ArrowLeft') {
|
|
1100
|
+
// Previous Sibling (alphabetical order, matching the nav graph).
|
|
1101
|
+
evt.preventDefault();
|
|
1102
|
+
postAction('/actions/navigate-spine', { direction: 'left' });
|
|
1103
|
+
} else if (code === 'ArrowRight') {
|
|
1104
|
+
// Next Sibling.
|
|
1105
|
+
evt.preventDefault();
|
|
1106
|
+
postAction('/actions/navigate-spine', { direction: 'right' });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
});
|