pi-weave 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global keyboard map (weave-workspace §11 P4).
|
|
3
|
+
*
|
|
4
|
+
* > Global keys: `⌘K` search, `⌘1/2/3` focus column, `/` filter tree, `g` fit
|
|
5
|
+
* > graph, `?` help, `Esc` clear selection. Vim-ish `j/k` in the tree.
|
|
6
|
+
* > *Exit: the whole workspace is drivable without a mouse.*
|
|
7
|
+
*
|
|
8
|
+
* One pure function, {@link shellKey}, maps an **event descriptor** and a
|
|
9
|
+
* **context** to an action or to `null`. Nothing here listens, nothing here
|
|
10
|
+
* calls `preventDefault`, and nothing here knows what a `KeyboardEvent` is —
|
|
11
|
+
* {@link KeyDescriptor} is five booleans and a string. `keys.ts` does the
|
|
12
|
+
* subscribing and `Shell.tsx` does the dispatching, which is what makes the
|
|
13
|
+
* whole keymap coverable without a DOM (§10).
|
|
14
|
+
*
|
|
15
|
+
* ## `null` means "the browser keeps it", and it is the important case
|
|
16
|
+
*
|
|
17
|
+
* This is `treeKey`'s `handled: false` contract, promoted to the document.
|
|
18
|
+
* The stakes are higher here: `treeKey` sits on one column, while this
|
|
19
|
+
* handler sees **every** keystroke in the workspace. A global handler that
|
|
20
|
+
* swallows Tab traps keyboard users; one that swallows ⌘R breaks reload; one
|
|
21
|
+
* that fires `g` while a user is typing "graph" into the filter box makes
|
|
22
|
+
* text entry impossible. So the default is `null` and every claim is narrow:
|
|
23
|
+
*
|
|
24
|
+
* | | Claimed when |
|
|
25
|
+
* | --- | --- |
|
|
26
|
+
* | `⌘K` / `Ctrl K` | always — a modifier shortcut is unambiguous even mid-word |
|
|
27
|
+
* | `⌘1` `⌘2` `⌘3` | always |
|
|
28
|
+
* | `⌘E` `⌘S` | always — the editor's two keys (§11 P5.4) |
|
|
29
|
+
* | `Esc` | an overlay is open, or something is selected. Never otherwise |
|
|
30
|
+
* | `/` `g` `?` | **only** when focus is not in a text field and no overlay is open |
|
|
31
|
+
*
|
|
32
|
+
* `⌘S` is claimed **even while typing**, which is the one place this map
|
|
33
|
+
* deliberately overrides the browser. It has to be: the browser's `⌘S` is
|
|
34
|
+
* "save this page as HTML", which in a note editor is never what the user
|
|
35
|
+
* meant, and the moment they most want to save is the moment their cursor is
|
|
36
|
+
* in the textarea. `⌘E` is claimed on the same grounds — the browser has no
|
|
37
|
+
* default for it, and toggling out of the editor is a thing to do *from*
|
|
38
|
+
* inside the editor. Neither fires when an overlay is open, because the
|
|
39
|
+
* palette owns every key but `Escape`.
|
|
40
|
+
*
|
|
41
|
+
* `j` / `k` are not here at all: they are tree-scoped, so they live in
|
|
42
|
+
* `tree.model.ts`'s `normalizeTreeKey` where the tree's own handler applies
|
|
43
|
+
* them — a global `j` would move the tree selection while the user is looking
|
|
44
|
+
* at the graph.
|
|
45
|
+
*
|
|
46
|
+
* ## While an overlay is open, the global map is one key wide
|
|
47
|
+
*
|
|
48
|
+
* The palette contains a text input and its own listbox navigation, and the
|
|
49
|
+
* help sheet is a trap the user must be able to leave. Anything beyond
|
|
50
|
+
* `Escape` reaching the document from inside a modal is a bug — most sharply
|
|
51
|
+
* `⌘K`, which would otherwise re-open the palette on top of itself.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import type { ColumnId } from "./layout.model";
|
|
55
|
+
import type { OverlayId } from "./shell.model";
|
|
56
|
+
|
|
57
|
+
// --- the event, without the DOM ---------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A keystroke, reduced to what a decision needs.
|
|
61
|
+
*
|
|
62
|
+
* `typing` rather than a target element: whether focus sits in a text field
|
|
63
|
+
* is the only property of the target this map cares about, and passing the
|
|
64
|
+
* element would drag `HTMLElement` into a module the root `tsconfig.json`
|
|
65
|
+
* project compiles.
|
|
66
|
+
*/
|
|
67
|
+
export interface KeyDescriptor {
|
|
68
|
+
/** `KeyboardEvent.key` — `"k"`, `"Escape"`, `"/"`, `"?"`. */
|
|
69
|
+
readonly key: string;
|
|
70
|
+
readonly ctrl: boolean;
|
|
71
|
+
readonly meta: boolean;
|
|
72
|
+
readonly shift: boolean;
|
|
73
|
+
readonly alt: boolean;
|
|
74
|
+
/** Focus is in an `<input>`, `<textarea>`, `<select>` or contenteditable. */
|
|
75
|
+
readonly typing: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the shell knows that changes what a key means. */
|
|
79
|
+
export interface KeyContext {
|
|
80
|
+
readonly overlay: OverlayId;
|
|
81
|
+
/** Whether §1.3's `selectedId` holds anything. Decides what `Esc` does. */
|
|
82
|
+
readonly hasSelection: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- actions ----------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Everything a global key can ask the shell to do.
|
|
89
|
+
*
|
|
90
|
+
* A discriminated union rather than callbacks, so the map is *data* the tests
|
|
91
|
+
* can assert equality against — P4's exit criterion is "every action
|
|
92
|
+
* reachable by keyboard has a tested mapping", and a table of callbacks is
|
|
93
|
+
* not something you can write that assertion about.
|
|
94
|
+
*/
|
|
95
|
+
export type ShellAction =
|
|
96
|
+
| { readonly type: "openSearch" }
|
|
97
|
+
| { readonly type: "openHelp" }
|
|
98
|
+
| { readonly type: "closeOverlay" }
|
|
99
|
+
| { readonly type: "focusColumn"; readonly column: ColumnId }
|
|
100
|
+
/** Focus the tree's filter box — `/`. */
|
|
101
|
+
| { readonly type: "filterTree" }
|
|
102
|
+
| { readonly type: "fitGraph" }
|
|
103
|
+
| { readonly type: "clearSelection" }
|
|
104
|
+
/** `⌘E` — toggle the note column between read and edit (§11 P5.4). */
|
|
105
|
+
| { readonly type: "toggleEdit" }
|
|
106
|
+
/** `⌘S` — save the open draft. */
|
|
107
|
+
| { readonly type: "saveNote" };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The command-key letters, as data.
|
|
111
|
+
*
|
|
112
|
+
* A table for the same reason {@link COLUMN_DIGITS} is one: "every editor key
|
|
113
|
+
* has a mapping" becomes a test over this object rather than three
|
|
114
|
+
* hand-written cases, and the help sheet is generated from it so it cannot
|
|
115
|
+
* document a key the code does not implement.
|
|
116
|
+
*/
|
|
117
|
+
export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
118
|
+
k: { type: "openSearch" },
|
|
119
|
+
e: { type: "toggleEdit" },
|
|
120
|
+
s: { type: "saveNote" },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `⌘1/2/3` → column, in §1.2's left-to-right order.
|
|
125
|
+
*
|
|
126
|
+
* A table rather than an `if` chain so the digits and the columns cannot
|
|
127
|
+
* drift apart, and so "there is a shortcut for every column" is a test over
|
|
128
|
+
* `COLUMNS` rather than three hand-written cases.
|
|
129
|
+
*/
|
|
130
|
+
export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a modifier combination counts as "the platform's command key".
|
|
134
|
+
*
|
|
135
|
+
* Meta **or** Control, never both, and never with Alt or Shift. Accepting
|
|
136
|
+
* either is what makes one keymap work on both platforms without the model
|
|
137
|
+
* knowing which one it is on; rejecting the combinations keeps `⌥⌘K` and
|
|
138
|
+
* `⇧⌘K` — which browsers and extensions bind — out of our hands.
|
|
139
|
+
*/
|
|
140
|
+
export function isCommand(event: KeyDescriptor): boolean {
|
|
141
|
+
return event.ctrl !== event.meta && !event.alt && !event.shift;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether a bare-letter shortcut may fire.
|
|
146
|
+
*
|
|
147
|
+
* No modifiers of any kind. Shift is deliberately *not* excluded: `?` is
|
|
148
|
+
* Shift+`/` on most layouts, and `KeyboardEvent.key` has already resolved
|
|
149
|
+
* that to `"?"`, so testing Shift here would make the help key unreachable on
|
|
150
|
+
* exactly the keyboards it was designed for.
|
|
151
|
+
*/
|
|
152
|
+
export function isBare(event: KeyDescriptor): boolean {
|
|
153
|
+
return !event.ctrl && !event.meta && !event.alt;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The bare-letter shortcuts, as data.
|
|
158
|
+
*
|
|
159
|
+
* `j` and `k` are absent on purpose — see the module header.
|
|
160
|
+
*/
|
|
161
|
+
export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
162
|
+
"/": { type: "filterTree" },
|
|
163
|
+
g: { type: "fitGraph" },
|
|
164
|
+
"?": { type: "openHelp" },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Map a keystroke to an action, or to `null` to leave it to the browser.
|
|
169
|
+
*
|
|
170
|
+
* Ordered deliberately: the overlay gate first (a modal narrows the whole map
|
|
171
|
+
* to one key), then the modifier shortcuts (which fire even mid-word), then
|
|
172
|
+
* `Escape`, then the bare letters (which do not).
|
|
173
|
+
*/
|
|
174
|
+
export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | null {
|
|
175
|
+
if (ctx.overlay !== null) return event.key === "Escape" ? { type: "closeOverlay" } : null;
|
|
176
|
+
|
|
177
|
+
if (isCommand(event)) {
|
|
178
|
+
// Lower-cased because ⌘K with caps lock on reports `"K"`, and a shortcut
|
|
179
|
+
// that stops working under caps lock is a bug report nobody can reproduce.
|
|
180
|
+
const letter = COMMAND_KEYS[event.key.toLowerCase()];
|
|
181
|
+
if (letter !== undefined) return letter;
|
|
182
|
+
const column = COLUMN_DIGITS[event.key];
|
|
183
|
+
return column === undefined ? null : { type: "focusColumn", column };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Only claimed when there is a selection to clear. With nothing selected,
|
|
187
|
+
// `Esc` belongs to whatever else the browser or a future dialog wants it
|
|
188
|
+
// for, and swallowing it silently would be the kind of key theft this
|
|
189
|
+
// module exists to avoid.
|
|
190
|
+
if (event.key === "Escape") return ctx.hasSelection ? { type: "clearSelection" } : null;
|
|
191
|
+
|
|
192
|
+
if (!isBare(event) || event.typing) return null;
|
|
193
|
+
return BARE_KEYS[event.key] ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- the typing guard -------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Element names that swallow a bare letter.
|
|
200
|
+
*
|
|
201
|
+
* `SELECT` is included, and it is the one people forget: a `<select>` does
|
|
202
|
+
* type-ahead, so `g` inside the graph column's depth control should jump to
|
|
203
|
+
* an option beginning with "g" rather than refit the graph. The workspace has
|
|
204
|
+
* exactly one such control today (`[depth 1 ▾]`) and that is enough.
|
|
205
|
+
*/
|
|
206
|
+
export const TEXT_ENTRY_TAGS: readonly string[] = ["INPUT", "TEXTAREA", "SELECT"];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether focus is somewhere a bare letter must be left alone.
|
|
210
|
+
*
|
|
211
|
+
* Takes the tag name and the contenteditable flag rather than an element, so
|
|
212
|
+
* it is pure and so this module names no DOM type. `null` — no target at all,
|
|
213
|
+
* which a synthetic event can produce — is not typing.
|
|
214
|
+
*/
|
|
215
|
+
export function isTextEntry(tagName: string | null, contentEditable: boolean): boolean {
|
|
216
|
+
if (contentEditable) return true;
|
|
217
|
+
return tagName !== null && TEXT_ENTRY_TAGS.includes(tagName.toUpperCase());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The slice of an event target this module reads.
|
|
222
|
+
*
|
|
223
|
+
* Both members optional, which is what lets the platform's `EventTarget` —
|
|
224
|
+
* which has neither — satisfy it structurally. A `window` or a `document`
|
|
225
|
+
* target therefore reads as "not typing", which is correct.
|
|
226
|
+
*/
|
|
227
|
+
export interface KeyTarget {
|
|
228
|
+
readonly tagName?: string | undefined;
|
|
229
|
+
readonly isContentEditable?: boolean | undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The slice of `KeyboardEvent` the subscription reads.
|
|
234
|
+
*
|
|
235
|
+
* Structural, so the platform's satisfies it without a cast and a fake is an
|
|
236
|
+
* object literal — the same port shape `EventSourceLike` and `HttpResponse`
|
|
237
|
+
* use, for the same reason: there is no DOM test environment (§10).
|
|
238
|
+
*/
|
|
239
|
+
export interface KeyboardEventLike {
|
|
240
|
+
readonly key: string;
|
|
241
|
+
readonly ctrlKey: boolean;
|
|
242
|
+
readonly metaKey: boolean;
|
|
243
|
+
readonly shiftKey: boolean;
|
|
244
|
+
readonly altKey: boolean;
|
|
245
|
+
readonly target: KeyTarget | null;
|
|
246
|
+
preventDefault(): void;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Reduce a platform event to a {@link KeyDescriptor}. */
|
|
250
|
+
export function describeKey(event: KeyboardEventLike): KeyDescriptor {
|
|
251
|
+
return {
|
|
252
|
+
key: event.key,
|
|
253
|
+
ctrl: event.ctrlKey,
|
|
254
|
+
meta: event.metaKey,
|
|
255
|
+
shift: event.shiftKey,
|
|
256
|
+
alt: event.altKey,
|
|
257
|
+
typing: isTextEntry(event.target?.tagName ?? null, event.target?.isContentEditable === true),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- where an action points ---------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `⌘1/2/3` → the element to focus in that column.
|
|
265
|
+
*
|
|
266
|
+
* Selectors rather than refs, and the reason is the breakpoint: below 1100 px
|
|
267
|
+
* the graph column does not exist and below 800 px the tree does not either,
|
|
268
|
+
* so a ref would be `null` for a column that is legitimately absent and the
|
|
269
|
+
* shell would need a branch per column to cope. A `querySelector` that finds
|
|
270
|
+
* nothing is already the same answer, expressed once.
|
|
271
|
+
*
|
|
272
|
+
* Each target is the column's *content*, not its `<section>`: focusing the
|
|
273
|
+
* section would put the ring around the whole pane and leave the arrow keys
|
|
274
|
+
* pointing at nothing.
|
|
275
|
+
*/
|
|
276
|
+
export const COLUMN_FOCUS_SELECTORS: Readonly<Record<ColumnId, string>> = {
|
|
277
|
+
tree: ".weave-col-tree .weave-rows",
|
|
278
|
+
note: ".weave-col-note .weave-note-body",
|
|
279
|
+
graph: ".weave-col-graph .weave-graph-canvas",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** `/` → the tree's filter box. */
|
|
283
|
+
export const TREE_FILTER_SELECTOR = ".weave-col-tree .weave-filter";
|
|
284
|
+
|
|
285
|
+
/** The slice of an element {@link focusable} produces. */
|
|
286
|
+
export interface Focusable {
|
|
287
|
+
focus(): void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Narrow an unknown `querySelector` result to something focusable.
|
|
292
|
+
*
|
|
293
|
+
* The port is typed `unknown` rather than `Element` for a compiler reason
|
|
294
|
+
* with an architectural upside. `Element` has no `focus()`, so a port
|
|
295
|
+
* declaring `querySelector(s): Focusable | null` is *not* satisfied by the
|
|
296
|
+
* real `document` in either direction and would need a cast at the one call
|
|
297
|
+
* site — which is exactly where a cast is least welcome. Taking `unknown` and
|
|
298
|
+
* checking here keeps `document` structurally valid, keeps this module free
|
|
299
|
+
* of DOM types, and turns "the element vanished at this breakpoint" into a
|
|
300
|
+
* tested branch rather than a `TypeError`.
|
|
301
|
+
*/
|
|
302
|
+
export function focusable(value: unknown): Focusable | null {
|
|
303
|
+
const candidate = value as { focus?: unknown } | null | undefined;
|
|
304
|
+
return typeof candidate?.focus === "function" ? (candidate as Focusable) : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The slice of `document` {@link focusSelector} needs. */
|
|
308
|
+
export interface QueryHost {
|
|
309
|
+
querySelector(selector: string): unknown;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Focus the first match, and report whether there was one.
|
|
314
|
+
*
|
|
315
|
+
* A miss is **normal**, not an error: below 1100 px the graph column does not
|
|
316
|
+
* exist and below 800 px neither does the tree, so `⌘3` on a narrow window
|
|
317
|
+
* legitimately finds nothing. Returning `false` rather than throwing is what
|
|
318
|
+
* lets the caller stay a one-liner, and the boolean is what the test asserts.
|
|
319
|
+
*/
|
|
320
|
+
export function focusSelector(host: QueryHost, selector: string): boolean {
|
|
321
|
+
const element = focusable(host.querySelector(selector));
|
|
322
|
+
if (element === null) return false;
|
|
323
|
+
element.focus();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- performing an action --------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The four capabilities an action needs, injected.
|
|
331
|
+
*
|
|
332
|
+
* Named capabilities rather than a component's `setState` closures, so
|
|
333
|
+
* {@link runShellAction} — which is a seven-armed switch, and therefore
|
|
334
|
+
* exactly the kind of thing §10 forbids putting in a `.tsx` — is testable
|
|
335
|
+
* with an object literal that records calls.
|
|
336
|
+
*/
|
|
337
|
+
export interface ShellEffects {
|
|
338
|
+
setOverlay(overlay: OverlayId): void;
|
|
339
|
+
/** Focus the first element matching a selector. */
|
|
340
|
+
focusSelector(selector: string): boolean;
|
|
341
|
+
/** Frame the whole graph — the `[fit]` control's action. */
|
|
342
|
+
fitGraph(): void;
|
|
343
|
+
/** Write `null` to §1.3's `selectedId`. */
|
|
344
|
+
clearSelection(): void;
|
|
345
|
+
/** `⌘E` — dispatch `toggle` into the note editor. */
|
|
346
|
+
toggleEdit(): void;
|
|
347
|
+
/** `⌘S` — dispatch `save` into the note editor. */
|
|
348
|
+
saveNote(): void;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Perform an action. Total over {@link ShellAction}. */
|
|
352
|
+
export function runShellAction(action: ShellAction, fx: ShellEffects): void {
|
|
353
|
+
switch (action.type) {
|
|
354
|
+
case "openSearch":
|
|
355
|
+
return fx.setOverlay("search");
|
|
356
|
+
case "openHelp":
|
|
357
|
+
return fx.setOverlay("help");
|
|
358
|
+
case "closeOverlay":
|
|
359
|
+
return fx.setOverlay(null);
|
|
360
|
+
case "focusColumn":
|
|
361
|
+
// The boolean is deliberately dropped: a column that is not on screen
|
|
362
|
+
// at this breakpoint is a miss with nothing to report to.
|
|
363
|
+
return void fx.focusSelector(COLUMN_FOCUS_SELECTORS[action.column]);
|
|
364
|
+
case "filterTree":
|
|
365
|
+
return void fx.focusSelector(TREE_FILTER_SELECTOR);
|
|
366
|
+
case "fitGraph":
|
|
367
|
+
return fx.fitGraph();
|
|
368
|
+
case "clearSelection":
|
|
369
|
+
return fx.clearSelection();
|
|
370
|
+
case "toggleEdit":
|
|
371
|
+
return fx.toggleEdit();
|
|
372
|
+
case "saveNote":
|
|
373
|
+
return fx.saveNote();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- the help sheet -----------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/** One row of the `?` overlay. */
|
|
380
|
+
export interface KeyHelpEntry {
|
|
381
|
+
/** Rendered combination, with `⌘` or `Ctrl` already resolved. */
|
|
382
|
+
readonly combo: string;
|
|
383
|
+
readonly what: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A titled group of rows. */
|
|
387
|
+
export interface KeyHelpGroup {
|
|
388
|
+
readonly title: string;
|
|
389
|
+
readonly entries: readonly KeyHelpEntry[];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The help sheet's contents.
|
|
394
|
+
*
|
|
395
|
+
* Derived from the same constants the map is built from wherever it can be —
|
|
396
|
+
* `COLUMN_DIGITS` drives the three column rows — because a help overlay that
|
|
397
|
+
* documents a key the code does not implement is worse than no help overlay.
|
|
398
|
+
* The rows that cannot be derived (the arrows, which belong to `treeKey` and
|
|
399
|
+
* `searchKey`) are asserted against those modules by the test suite instead.
|
|
400
|
+
*
|
|
401
|
+
* @param cmd the platform's command-key spelling, from `searchShortcut`'s
|
|
402
|
+
* `looksApple` — so the sheet says `⌘K` on a Mac and `Ctrl K` elsewhere,
|
|
403
|
+
* rather than teaching half its readers a key they do not have.
|
|
404
|
+
*/
|
|
405
|
+
export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
406
|
+
return [
|
|
407
|
+
{
|
|
408
|
+
title: "Global",
|
|
409
|
+
entries: [
|
|
410
|
+
{ combo: `${cmd}K`, what: "Search notes and the repository" },
|
|
411
|
+
...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({ combo: `${cmd}${digit}`, what: `Focus the ${column} column` })),
|
|
412
|
+
{ combo: "?", what: "This help" },
|
|
413
|
+
{ combo: "Esc", what: "Clear the selection, or close an overlay" },
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
title: "Tree",
|
|
418
|
+
entries: [
|
|
419
|
+
{ combo: "/", what: "Filter the tree" },
|
|
420
|
+
{ combo: "j / ↓", what: "Next row" },
|
|
421
|
+
{ combo: "k / ↑", what: "Previous row" },
|
|
422
|
+
{ combo: "→", what: "Expand, or step into" },
|
|
423
|
+
{ combo: "←", what: "Collapse, or step out" },
|
|
424
|
+
{ combo: "Home / End", what: "First / last row" },
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
title: "Note",
|
|
429
|
+
entries: [
|
|
430
|
+
{ combo: `${cmd}E`, what: "Toggle read / edit" },
|
|
431
|
+
{ combo: `${cmd}S`, what: "Save the open draft" },
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
title: "Graph",
|
|
436
|
+
entries: [{ combo: "g", what: "Fit the whole graph" }],
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
title: "Search",
|
|
440
|
+
entries: [
|
|
441
|
+
{ combo: "↑ / ↓", what: "Move through results" },
|
|
442
|
+
{ combo: "↵", what: "Open the highlighted result" },
|
|
443
|
+
{ combo: "Esc", what: "Close the palette" },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The help dialog's accessible name and heading. */
|
|
450
|
+
export const HELP_TITLE = "Keyboard shortcuts";
|
|
451
|
+
|
|
452
|
+
/** The help dialog's footer hint. */
|
|
453
|
+
export const HELP_HINT = "esc close";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global key subscription (weave-workspace §11 P4, §10).
|
|
3
|
+
*
|
|
4
|
+
* `keys.model.ts` decides what a keystroke means; this attaches one listener
|
|
5
|
+
* and performs the answer. Four lines of it are the listener and the rest is
|
|
6
|
+
* dispatch, which is deliberate — the same split `viewport.ts` uses, and for
|
|
7
|
+
* the same reason: a subscription written inline in a `useEffect` is one no
|
|
8
|
+
* test can reach.
|
|
9
|
+
*
|
|
10
|
+
* ## One listener, on the document, at the bubble phase
|
|
11
|
+
*
|
|
12
|
+
* Not capture. The bubble phase runs *after* the element under focus has had
|
|
13
|
+
* the event, so the tree's own `onKeyDown` claims `ArrowDown` before this
|
|
14
|
+
* ever sees it and the palette claims `Enter` before this does. That ordering
|
|
15
|
+
* is what lets both handlers stay simple: neither needs to know the other
|
|
16
|
+
* exists, because the local one goes first and the global one is the
|
|
17
|
+
* fallback. Capture would invert it and every column-level key would need an
|
|
18
|
+
* exception here.
|
|
19
|
+
*
|
|
20
|
+
* `preventDefault` is called **only** for a keystroke that produced an
|
|
21
|
+
* action. Everything else — Tab, ⌘R, ⌘L, typing — passes through untouched,
|
|
22
|
+
* which is `treeKey`'s `handled: false` contract at the document level.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { KeyContext, KeyboardEventLike, ShellAction } from "./keys.model";
|
|
26
|
+
import { describeKey, shellKey } from "./keys.model";
|
|
27
|
+
|
|
28
|
+
/** The slice of `document` this module subscribes to. */
|
|
29
|
+
export interface KeyHost {
|
|
30
|
+
addEventListener(type: "keydown", listener: (event: KeyboardEventLike) => void): void;
|
|
31
|
+
removeEventListener(type: "keydown", listener: (event: KeyboardEventLike) => void): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What {@link watchKeys} needs. */
|
|
35
|
+
export interface KeyOptions {
|
|
36
|
+
/** Read fresh per keystroke — the overlay and the selection both move. */
|
|
37
|
+
context: () => KeyContext;
|
|
38
|
+
run: (action: ShellAction) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Listen for global shortcuts. Returns an unsubscribe.
|
|
43
|
+
*
|
|
44
|
+
* The context is a **thunk**, not a value. A listener registered at mount
|
|
45
|
+
* outlives every render, so a captured `{overlay, hasSelection}` would answer
|
|
46
|
+
* with the state as it was at mount — the palette would never see itself as
|
|
47
|
+
* open, and `⌘K` would stack overlays. The same `live.current` reasoning
|
|
48
|
+
* `Shell.tsx` and `Graph.tsx` apply to their long-lived handlers.
|
|
49
|
+
*/
|
|
50
|
+
export function watchKeys(host: KeyHost, opts: KeyOptions): () => void {
|
|
51
|
+
const listener = (event: KeyboardEventLike): void => {
|
|
52
|
+
const action = shellKey(describeKey(event), opts.context());
|
|
53
|
+
if (action === null) return;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
opts.run(action);
|
|
56
|
+
};
|
|
57
|
+
host.addEventListener("keydown", listener);
|
|
58
|
+
return () => host.removeEventListener("keydown", listener);
|
|
59
|
+
}
|