pi-weave 0.1.7 → 0.1.8
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 +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- 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/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- 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 +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -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 +279 -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/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 +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -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 +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -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 +206 -0
- package/src/web/shared/layout.ts +497 -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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The palette's effects: a timer, a fetch, and nothing else
|
|
3
|
+
* (weave-workspace §10, P4).
|
|
4
|
+
*
|
|
5
|
+
* `search.model.ts` decides *when* to issue a request and *whether* a
|
|
6
|
+
* response may be applied; it cannot arm a timer or call `fetch` without
|
|
7
|
+
* ceasing to be pure. This module is the other half — a controller that owns
|
|
8
|
+
* exactly two capabilities, both injected:
|
|
9
|
+
*
|
|
10
|
+
* - a **clock** (`now`) and a **scheduler** (`delay`), so the 140 ms debounce
|
|
11
|
+
* is exercised by a fake in microseconds rather than waited out;
|
|
12
|
+
* - a **`FetchLike`**, the same port `api.ts` takes, so no DOM is involved.
|
|
13
|
+
*
|
|
14
|
+
* The result is that the palette's entire asynchronous behaviour — debounce,
|
|
15
|
+
* re-arm, stale-response rejection, failure — is covered by ordinary unit
|
|
16
|
+
* tests, and `SearchPalette.tsx` is a `useState`, an effect and three
|
|
17
|
+
* handlers. That is §10's split applied to the one part of the workspace that
|
|
18
|
+
* is genuinely concurrent.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the controller and not the component owns the sequence number
|
|
21
|
+
*
|
|
22
|
+
* A component could hold `seq` in a ref and compare on arrival. It would also
|
|
23
|
+
* be re-created by every hot reload, remounted by every breakpoint change,
|
|
24
|
+
* and untestable. Keeping the counter inside {@link SearchState}, where the
|
|
25
|
+
* reducer can reason about it, means "an older response must not overwrite a
|
|
26
|
+
* newer one" is a property with a test rather than a comment.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { FetchLike } from "../api";
|
|
30
|
+
import { fetchSearch } from "../api";
|
|
31
|
+
import type { SearchEvent, SearchState, SearchTransition } from "./search.model";
|
|
32
|
+
import { initialSearchState, reduceSearch } from "./search.model";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Arms a one-shot timer and returns nothing.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately *not* returning a cancel handle: the debounce in
|
|
38
|
+
* `reduceSearch` is a re-arming chain precisely so that nothing ever needs
|
|
39
|
+
* cancelling, and a cancel handle here would invite a second, competing
|
|
40
|
+
* mechanism. `(fn, ms) => void setTimeout(fn, ms)` at the call site; a queue
|
|
41
|
+
* a test drains by hand in a test.
|
|
42
|
+
*/
|
|
43
|
+
export type Scheduler = (run: () => void, ms: number) => void;
|
|
44
|
+
|
|
45
|
+
/** What {@link createSearch} needs. Every capability injected. */
|
|
46
|
+
export interface SearchOptions {
|
|
47
|
+
fetch: FetchLike;
|
|
48
|
+
/** Epoch ms. `Date.now` in the browser. */
|
|
49
|
+
now: () => number;
|
|
50
|
+
delay: Scheduler;
|
|
51
|
+
/** Called after every state change, so the component can re-render. */
|
|
52
|
+
onChange: (state: SearchState) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A running palette controller. */
|
|
56
|
+
export interface SearchHandle {
|
|
57
|
+
/** Current state. Read at render. */
|
|
58
|
+
state(): SearchState;
|
|
59
|
+
/** The text changed. */
|
|
60
|
+
setQuery(query: string): void;
|
|
61
|
+
/** Move the cursor — pointer hover, or a key the model resolved to an index. */
|
|
62
|
+
setCursor(cursor: number): void;
|
|
63
|
+
/** The palette closed: disarm the debounce. */
|
|
64
|
+
dismiss(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the controller.
|
|
69
|
+
*
|
|
70
|
+
* One function, one loop: every event goes through {@link dispatch}, which
|
|
71
|
+
* reduces, publishes, and then performs whichever of the two effects the
|
|
72
|
+
* transition asked for. There is no other path that mutates state, which is
|
|
73
|
+
* what makes "a stale response cannot win" enforceable — the check lives in
|
|
74
|
+
* the reducer and every response arrives through here.
|
|
75
|
+
*/
|
|
76
|
+
export function createSearch(opts: SearchOptions): SearchHandle {
|
|
77
|
+
let state = initialSearchState();
|
|
78
|
+
|
|
79
|
+
const dispatch = (event: SearchEvent): void => {
|
|
80
|
+
const next: SearchTransition = reduceSearch(state, event);
|
|
81
|
+
state = next.state;
|
|
82
|
+
opts.onChange(state);
|
|
83
|
+
|
|
84
|
+
if (next.schedule !== null) opts.delay(() => dispatch({ type: "tick", now: opts.now() }), next.schedule);
|
|
85
|
+
|
|
86
|
+
const request = next.request;
|
|
87
|
+
if (request === null) return;
|
|
88
|
+
// Fire and forget: `api.ts` returns failures as values, so there is
|
|
89
|
+
// nothing to reject and nothing to catch. `void` documents the floating
|
|
90
|
+
// promise rather than hiding it — the same shape `workspace.ts` uses.
|
|
91
|
+
void (async () => {
|
|
92
|
+
const result = await fetchSearch(opts.fetch, request.query);
|
|
93
|
+
dispatch(
|
|
94
|
+
result.ok
|
|
95
|
+
? { type: "response", seq: request.seq, query: request.query, hits: result.data.hits }
|
|
96
|
+
: { type: "failed", seq: request.seq },
|
|
97
|
+
);
|
|
98
|
+
})();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
state: () => state,
|
|
103
|
+
setQuery: (query) => dispatch({ type: "query", query, now: opts.now() }),
|
|
104
|
+
setCursor: (cursor) => dispatch({ type: "cursor", cursor }),
|
|
105
|
+
dismiss: () => dispatch({ type: "dismiss" }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-column grid, its dividers and the context rail
|
|
3
|
+
* (weave-workspace §1.2).
|
|
4
|
+
*
|
|
5
|
+
* The one interesting line is the `useLayoutEffect`: column widths reach the
|
|
6
|
+
* DOM as **custom properties**, written through `applyVars` →
|
|
7
|
+
* `style.setProperty`. That is the CSSOM path, which `style-src 'nonce-…'`
|
|
8
|
+
* does not govern — see `cssvars.ts` for the verified reasoning — and it
|
|
9
|
+
* leaves the actual `grid-template-columns` rule in the nonce'd stylesheet
|
|
10
|
+
* where it can be read.
|
|
11
|
+
*
|
|
12
|
+
* `useLayoutEffect` rather than `useEffect` so the widths land before paint;
|
|
13
|
+
* with `useEffect` the first frame renders at whatever the CSS fallback says
|
|
14
|
+
* and then jumps.
|
|
15
|
+
*
|
|
16
|
+
* The column/divider pairing is {@link columnSlots}, not an index check here:
|
|
17
|
+
* it is breakpoint-sensitive and therefore worth a test.
|
|
18
|
+
*
|
|
19
|
+
* All three columns are built as of P3, so {@link Column} dispatches on the
|
|
20
|
+
* column id. That ternary chain is the one piece of branching in the file and
|
|
21
|
+
* it is a *routing* decision, not a product one: each arm is a bare element,
|
|
22
|
+
* and everything those components then decide lives in their own `.model.ts`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Fragment } from "preact";
|
|
26
|
+
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
27
|
+
import { Graph } from "../graph/Graph";
|
|
28
|
+
import type { PositionStorage } from "../graph/positions";
|
|
29
|
+
import type { RendererFactory } from "../graph/renderer";
|
|
30
|
+
import type { SchemeHost } from "../graph/scheme";
|
|
31
|
+
import type { EditorEvent, EditorPrompt, EditorToolbar } from "../note/editor.model";
|
|
32
|
+
import { Note } from "../note/Note";
|
|
33
|
+
import { Tree } from "../tree/Tree";
|
|
34
|
+
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
35
|
+
import { applyVars } from "./cssvars";
|
|
36
|
+
import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
|
|
37
|
+
import { columnVars } from "./layout.model";
|
|
38
|
+
import { ContextRail } from "./ContextRail";
|
|
39
|
+
import { Divider } from "./Divider";
|
|
40
|
+
import type { ColumnSlot } from "./shell.model";
|
|
41
|
+
import { columnSlots, emptyStateFor } from "./shell.model";
|
|
42
|
+
|
|
43
|
+
export interface ColumnsProps {
|
|
44
|
+
resolved: readonly ResolvedColumn[];
|
|
45
|
+
onDown: (divider: DividerId, clientX: number, pointerId: number) => void;
|
|
46
|
+
onMove: (clientX: number) => void;
|
|
47
|
+
onUp: () => void;
|
|
48
|
+
onKey: (divider: DividerId, key: string) => void;
|
|
49
|
+
/** The §1.3 context bus, as the columns see it. */
|
|
50
|
+
graph: GraphPayload | null;
|
|
51
|
+
note: NotePayload | null;
|
|
52
|
+
selectedId: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* The §1.3 context bus.
|
|
55
|
+
*
|
|
56
|
+
* Takes `string | null` because the graph can *clear* the selection — a
|
|
57
|
+
* click on empty stage — and no other column can. `workspace.ts`'s `select`
|
|
58
|
+
* has always accepted `null`; this prop was merely narrower than the thing
|
|
59
|
+
* behind it. The tree, the note column and the rail pass a `string`, which
|
|
60
|
+
* a handler accepting the wider type takes without a cast.
|
|
61
|
+
*/
|
|
62
|
+
onSelect: (id: string | null) => void;
|
|
63
|
+
/** Epoch ms for relative times, read once per render by the shell. */
|
|
64
|
+
now: number;
|
|
65
|
+
/**
|
|
66
|
+
* The graph column's three injected ports (§7.5, §10).
|
|
67
|
+
*
|
|
68
|
+
* Threaded through rather than imported by `Graph.tsx` for the reason
|
|
69
|
+
* `api.dom.ts` exists: `createSigmaRenderer` imports sigma, which is a
|
|
70
|
+
* `ReferenceError` outside a browser, so the module that names it must stay
|
|
71
|
+
* reachable only from a `.tsx` entry point.
|
|
72
|
+
*/
|
|
73
|
+
renderer: RendererFactory;
|
|
74
|
+
storage: PositionStorage;
|
|
75
|
+
host: SchemeHost;
|
|
76
|
+
/** Slot the graph column fills with its `fit`, for the global `g` key. */
|
|
77
|
+
fit: { current: (() => void) | null };
|
|
78
|
+
/**
|
|
79
|
+
* The note editor's view model (§11 P5).
|
|
80
|
+
*
|
|
81
|
+
* Derived by the shell from the one `EditorHandle`, because the editor's
|
|
82
|
+
* state outlives the note column's mount: below 800 px the column can be
|
|
83
|
+
* unmounted by a resize, and an editor owned by the component would lose
|
|
84
|
+
* an unsaved draft to a window drag.
|
|
85
|
+
*/
|
|
86
|
+
toolbar: EditorToolbar | null;
|
|
87
|
+
prompt: EditorPrompt | null;
|
|
88
|
+
draft: string;
|
|
89
|
+
send: (event: EditorEvent) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One column: a titled region and whichever surface fills it. */
|
|
93
|
+
function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
94
|
+
const copy = emptyStateFor(id);
|
|
95
|
+
return (
|
|
96
|
+
<section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
|
|
97
|
+
<h2 class="weave-col-title">{copy.title}</h2>
|
|
98
|
+
{id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} now={props.now} /> : null}
|
|
99
|
+
{id === "note" ? (
|
|
100
|
+
<Note
|
|
101
|
+
note={props.note}
|
|
102
|
+
graph={props.graph}
|
|
103
|
+
selectedId={props.selectedId}
|
|
104
|
+
onSelect={props.onSelect}
|
|
105
|
+
now={props.now}
|
|
106
|
+
toolbar={props.toolbar}
|
|
107
|
+
prompt={props.prompt}
|
|
108
|
+
draft={props.draft}
|
|
109
|
+
send={props.send}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
{id === "graph" ? (
|
|
113
|
+
<Graph
|
|
114
|
+
graph={props.graph}
|
|
115
|
+
selectedId={props.selectedId}
|
|
116
|
+
onSelect={props.onSelect}
|
|
117
|
+
renderer={props.renderer}
|
|
118
|
+
storage={props.storage}
|
|
119
|
+
host={props.host}
|
|
120
|
+
fit={props.fit}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
{id === "graph" ? <ContextRail graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} /> : null}
|
|
124
|
+
</section>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A column and, where one follows it, its divider. */
|
|
129
|
+
function Slot({ slot, props }: { slot: ColumnSlot; props: ColumnsProps }) {
|
|
130
|
+
const divider = slot.divider;
|
|
131
|
+
return (
|
|
132
|
+
<Fragment>
|
|
133
|
+
<Column id={slot.column.id} props={props} />
|
|
134
|
+
{divider === null ? null : (
|
|
135
|
+
<Divider
|
|
136
|
+
id={divider}
|
|
137
|
+
label={`Resize ${slot.column.id} column`}
|
|
138
|
+
onDown={(x, pointerId) => props.onDown(divider, x, pointerId)}
|
|
139
|
+
onMove={props.onMove}
|
|
140
|
+
onUp={props.onUp}
|
|
141
|
+
onKey={(key) => props.onKey(divider, key)}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
</Fragment>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function Columns(props: ColumnsProps) {
|
|
149
|
+
const grid = useRef<HTMLDivElement | null>(null);
|
|
150
|
+
useLayoutEffect(() => {
|
|
151
|
+
applyVars(grid.current, columnVars(props.resolved));
|
|
152
|
+
}, [props.resolved]);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div class="weave-grid" ref={grid} data-columns={props.resolved.length}>
|
|
156
|
+
{columnSlots(props.resolved).map((slot) => (
|
|
157
|
+
<Slot key={slot.column.id} slot={slot} props={props} />
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context rail beneath the graph column (weave-workspace §1.1, §1.2, P2.5).
|
|
3
|
+
*
|
|
4
|
+
* LINKS, BACKLINKS, TAGS and MENTIONS for whatever is selected — §1.1's
|
|
5
|
+
* "bring related information into the current view", with every entry
|
|
6
|
+
* clickable and nothing requiring a navigation to see.
|
|
7
|
+
*
|
|
8
|
+
* Props in, JSX out. `context.model.ts` decides which groups exist, which
|
|
9
|
+
* rows they hold, what each row is labelled and what it selects; this renders
|
|
10
|
+
* the result.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ContextGroup, ContextRow, TagGroupRow } from "../context/context.model";
|
|
14
|
+
import { contextModel } from "../context/context.model";
|
|
15
|
+
import type { GraphPayload } from "../../shared/wire";
|
|
16
|
+
import { CONTEXT_EMPTY } from "./shell.model";
|
|
17
|
+
|
|
18
|
+
export interface ContextRailProps {
|
|
19
|
+
graph: GraphPayload | null;
|
|
20
|
+
selectedId: string | null;
|
|
21
|
+
onSelect: (id: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Row({ row, onSelect }: { row: ContextRow; onSelect: () => void }) {
|
|
25
|
+
return (
|
|
26
|
+
<li class={`weave-ctx-row${row.selected ? " weave-row-on" : ""}`}>
|
|
27
|
+
<button type="button" class="weave-ctx-link" onClick={onSelect}>
|
|
28
|
+
<span class="weave-kind" aria-hidden="true">
|
|
29
|
+
{row.kindGlyph}
|
|
30
|
+
</span>
|
|
31
|
+
<span class={`weave-prov weave-prov-${row.provenance ?? "none"}`} title={row.provenanceTitle}>
|
|
32
|
+
{row.provenanceGlyph}
|
|
33
|
+
</span>
|
|
34
|
+
<span class="weave-label">{row.label}</span>
|
|
35
|
+
</button>
|
|
36
|
+
</li>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Group({ group, onSelect }: { group: ContextGroup; onSelect: (id: string) => void }) {
|
|
41
|
+
return (
|
|
42
|
+
<section class="weave-ctx-group">
|
|
43
|
+
<h4 class="weave-ctx-heading">{group.heading}</h4>
|
|
44
|
+
<ul class="weave-ctx-rows">
|
|
45
|
+
{group.rows.map((row) => (
|
|
46
|
+
<Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Tag({ tag, onSelect }: { tag: TagGroupRow; onSelect: (id: string) => void }) {
|
|
54
|
+
return (
|
|
55
|
+
<li class="weave-ctx-tag">
|
|
56
|
+
<span class="weave-tag">#{tag.tag}</span>
|
|
57
|
+
<ul class="weave-ctx-rows">
|
|
58
|
+
{tag.siblings.map((row) => (
|
|
59
|
+
<Row key={row.id} row={row} onSelect={() => onSelect(row.target)} />
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
</li>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ContextRail(props: ContextRailProps) {
|
|
67
|
+
const model = contextModel(props.graph, props.selectedId);
|
|
68
|
+
return (
|
|
69
|
+
<div class="weave-rail" aria-label={CONTEXT_EMPTY.title}>
|
|
70
|
+
<h2 class="weave-col-title">{CONTEXT_EMPTY.title}</h2>
|
|
71
|
+
{model.empty === null ? null : <p class="weave-ctx-empty">{model.empty}</p>}
|
|
72
|
+
{model.groups.map((group) => (
|
|
73
|
+
<Group key={group.heading} group={group} onSelect={props.onSelect} />
|
|
74
|
+
))}
|
|
75
|
+
{model.tags.length === 0 ? null : (
|
|
76
|
+
<section class="weave-ctx-group">
|
|
77
|
+
<h4 class="weave-ctx-heading">TAGS</h4>
|
|
78
|
+
<ul class="weave-ctx-tags">
|
|
79
|
+
{model.tags.map((tag) => (
|
|
80
|
+
<Tag key={tag.tag} tag={tag} onSelect={props.onSelect} />
|
|
81
|
+
))}
|
|
82
|
+
</ul>
|
|
83
|
+
</section>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A drag handle between two columns (weave-workspace §1.2).
|
|
3
|
+
*
|
|
4
|
+
* All four handlers forward straight to callbacks the shell supplies; the
|
|
5
|
+
* arithmetic is in `drag.model.ts` and `layout.model.ts`. The ARIA role is
|
|
6
|
+
* `separator` with `tabindex={0}` so the divider is reachable and operable
|
|
7
|
+
* from the keyboard, which §11's P4 requires of the whole workspace and which
|
|
8
|
+
* is far cheaper to do now than to retrofit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DividerId } from "./layout.model";
|
|
12
|
+
|
|
13
|
+
export interface DividerProps {
|
|
14
|
+
id: DividerId;
|
|
15
|
+
label: string;
|
|
16
|
+
onDown: (clientX: number, pointerId: number) => void;
|
|
17
|
+
onMove: (clientX: number) => void;
|
|
18
|
+
onUp: () => void;
|
|
19
|
+
onKey: (key: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Divider(props: DividerProps) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
class="weave-divider"
|
|
26
|
+
role="separator"
|
|
27
|
+
aria-orientation="vertical"
|
|
28
|
+
aria-label={props.label}
|
|
29
|
+
tabIndex={0}
|
|
30
|
+
onPointerDown={(event) => {
|
|
31
|
+
// Capture, so a fast drag that outruns the 1 px handle keeps
|
|
32
|
+
// delivering moves here instead of to whatever is under the pointer.
|
|
33
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
34
|
+
props.onDown(event.clientX, event.pointerId);
|
|
35
|
+
}}
|
|
36
|
+
onPointerMove={(event) => props.onMove(event.clientX)}
|
|
37
|
+
onPointerUp={(event) => {
|
|
38
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
39
|
+
props.onUp();
|
|
40
|
+
}}
|
|
41
|
+
onKeyDown={(event) => props.onKey(event.key)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM half of the focus trap (weave-workspace P4, §10).
|
|
3
|
+
*
|
|
4
|
+
* `focus.model.ts` decides *which* index Tab should land on; this hook does
|
|
5
|
+
* the three things that require a real document and cannot be decided:
|
|
6
|
+
* remember what was focused, focus the dialog, and put focus back on unmount.
|
|
7
|
+
* There is no branch here that `trapTarget` and `restoreFocus` do not already
|
|
8
|
+
* own — the `?? null`s are DOM nullability, not decisions.
|
|
9
|
+
*
|
|
10
|
+
* Isolated in a `.tsx` for `renderer.dom.ts`'s reason: it names
|
|
11
|
+
* `document.activeElement` and `HTMLElement`, which do not exist in the root
|
|
12
|
+
* `tsconfig.json` project, so anything importing it can only be compiled by
|
|
13
|
+
* `tsconfig.web.json`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useRef } from "preact/hooks";
|
|
17
|
+
import { FOCUSABLE_SELECTOR, isTrapTab, restoreFocus, trapTarget } from "./focus.model";
|
|
18
|
+
|
|
19
|
+
/** What {@link useFocusTrap} hands back to the dialog. */
|
|
20
|
+
export interface FocusTrapHandle {
|
|
21
|
+
/** Put on the dialog element. */
|
|
22
|
+
readonly ref: { current: HTMLElement | null };
|
|
23
|
+
/** Call from the dialog's `onKeyDown`. Returns whether it consumed the key. */
|
|
24
|
+
onKeyDown(event: KeyboardEvent): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Trap focus inside an element while it is mounted, and restore it after.
|
|
29
|
+
*
|
|
30
|
+
* The dialog itself is focused on mount rather than its first control,
|
|
31
|
+
* deliberately: a screen reader announces the dialog's `aria-label` and role
|
|
32
|
+
* on arrival, which a jump straight to the input would skip. `trapTarget`
|
|
33
|
+
* handles the resulting `at === -1` explicitly for exactly this case.
|
|
34
|
+
*/
|
|
35
|
+
export function useFocusTrap(): FocusTrapHandle {
|
|
36
|
+
const ref = useRef<HTMLElement | null>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const previous = document.activeElement;
|
|
40
|
+
ref.current?.focus();
|
|
41
|
+
return () => void restoreFocus(previous instanceof HTMLElement ? previous : null);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ref,
|
|
46
|
+
onKeyDown(event) {
|
|
47
|
+
if (!isTrapTab(event.key, event.ctrlKey, event.metaKey, event.altKey)) return false;
|
|
48
|
+
const items = [...(ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])];
|
|
49
|
+
const target = trapTarget(items.length, items.indexOf(document.activeElement as HTMLElement), event.shiftKey);
|
|
50
|
+
if (target === null) return false;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
items[target]?.focus();
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header bar (weave-workspace §1.2).
|
|
3
|
+
*
|
|
4
|
+
* Title, the `⌘K` search button, the `vault:N · repo:… · N nodes` summary,
|
|
5
|
+
* the refresh button and the connection dot. Every string and every tone
|
|
6
|
+
* comes from `shell.model.ts`.
|
|
7
|
+
*
|
|
8
|
+
* The search control was `disabled` through P1–P3 because search was P4 and
|
|
9
|
+
* an affordance that looks live but is not would be the dishonest option. P4
|
|
10
|
+
* landed, so it is live — and it is a `<button>`, not an `<input>`: the
|
|
11
|
+
* palette owns the only text field in the workspace, and a header box you
|
|
12
|
+
* could type into whose contents were then thrown away when the overlay
|
|
13
|
+
* opened would be a worse lie than the disabled version it replaced.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ConnectionView, HeaderSummary } from "./shell.model";
|
|
17
|
+
import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
|
|
18
|
+
|
|
19
|
+
export interface HeaderProps {
|
|
20
|
+
summary: HeaderSummary;
|
|
21
|
+
connection: ConnectionView;
|
|
22
|
+
shortcut: string;
|
|
23
|
+
onRefresh: () => void;
|
|
24
|
+
/** Opens the ⌘K palette. The same action the global key performs. */
|
|
25
|
+
onSearch: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Header(props: HeaderProps) {
|
|
29
|
+
return (
|
|
30
|
+
<header class="weave-header">
|
|
31
|
+
<span class="weave-brand">🧵 pi-weave</span>
|
|
32
|
+
<button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
|
|
33
|
+
<span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
|
|
34
|
+
<kbd>{props.shortcut}</kbd>
|
|
35
|
+
</button>
|
|
36
|
+
<span class="weave-summary">
|
|
37
|
+
{summaryParts(props.summary).map((part) => (
|
|
38
|
+
<span key={part} class="weave-summary-part">
|
|
39
|
+
{part}
|
|
40
|
+
</span>
|
|
41
|
+
))}
|
|
42
|
+
</span>
|
|
43
|
+
<button type="button" class="weave-refresh" onClick={props.onRefresh} title="Refetch everything">
|
|
44
|
+
⟳
|
|
45
|
+
</button>
|
|
46
|
+
<span class={`weave-conn weave-conn-${props.connection.tone}`} title={props.connection.hint}>
|
|
47
|
+
<span class="weave-conn-dot" aria-hidden="true">
|
|
48
|
+
●
|
|
49
|
+
</span>
|
|
50
|
+
{props.connection.label}
|
|
51
|
+
</span>
|
|
52
|
+
</header>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `?` keyboard-shortcut sheet (weave-workspace §11 P4).
|
|
3
|
+
*
|
|
4
|
+
* Props in, JSX out. The rows come from `keyHelp` in `keys.model.ts` — the
|
|
5
|
+
* same module the map itself is built from, so the sheet cannot document a
|
|
6
|
+
* key the code does not implement — and the trap is `useFocusTrap`. There is
|
|
7
|
+
* no branch in this file.
|
|
8
|
+
*
|
|
9
|
+
* `Escape` is not handled here: the global listener already answers it with
|
|
10
|
+
* `closeOverlay` whenever an overlay is open, and a second handler claiming
|
|
11
|
+
* the same key is how a dialog ends up closing twice.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useFocusTrap } from "./FocusTrap";
|
|
15
|
+
import type { KeyHelpGroup } from "./keys.model";
|
|
16
|
+
import { HELP_HINT, HELP_TITLE, keyHelp } from "./keys.model";
|
|
17
|
+
|
|
18
|
+
export interface HelpOverlayProps {
|
|
19
|
+
/** `⌘K` or `Ctrl K`, from `searchShortcut`. */
|
|
20
|
+
shortcut: string;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Group({ group }: { group: KeyHelpGroup }) {
|
|
25
|
+
return (
|
|
26
|
+
<div>
|
|
27
|
+
<p class="weave-key-group">{group.title}</p>
|
|
28
|
+
{group.entries.map((entry) => (
|
|
29
|
+
<div key={entry.combo} class="weave-key-row">
|
|
30
|
+
<kbd class="weave-key-combo">{entry.combo}</kbd>
|
|
31
|
+
<span class="weave-key-what">{entry.what}</span>
|
|
32
|
+
</div>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function HelpOverlay(props: HelpOverlayProps) {
|
|
39
|
+
const trap = useFocusTrap();
|
|
40
|
+
// `⌘K` reduced to `⌘` / `Ctrl `: the sheet composes its own combinations.
|
|
41
|
+
const cmd = props.shortcut.slice(0, -1);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div class="weave-scrim" onClick={props.onClose}>
|
|
45
|
+
<div
|
|
46
|
+
class="weave-help"
|
|
47
|
+
role="dialog"
|
|
48
|
+
aria-modal="true"
|
|
49
|
+
aria-label={HELP_TITLE}
|
|
50
|
+
tabIndex={-1}
|
|
51
|
+
ref={trap.ref as { current: HTMLDivElement | null }}
|
|
52
|
+
onClick={(event) => event.stopPropagation()}
|
|
53
|
+
onKeyDown={(event) => void trap.onKeyDown(event as unknown as KeyboardEvent)}
|
|
54
|
+
>
|
|
55
|
+
<h2 class="weave-help-title">{HELP_TITLE}</h2>
|
|
56
|
+
<div class="weave-keys">
|
|
57
|
+
{keyHelp(cmd).map((group) => (
|
|
58
|
+
<Group key={group.title} group={group} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
<p class="weave-help-foot">
|
|
62
|
+
<button type="button" class="weave-chip" onClick={props.onClose}>
|
|
63
|
+
close
|
|
64
|
+
</button>
|
|
65
|
+
<span class="weave-help-hint">{HELP_HINT}</span>
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|