pi-weave 0.1.8 → 0.1.10
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 +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -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/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- 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 +1 -1
- package/src/web/client/dist/app.js +272 -113
- package/src/web/client/graph/Graph.tsx +112 -22
- package/src/web/client/graph/column.model.ts +52 -94
- package/src/web/client/graph/dynamics.ts +181 -0
- package/src/web/client/graph/graph.model.ts +113 -34
- package/src/web/client/graph/positions.ts +58 -13
- package/src/web/client/graph/renderer.ts +89 -5
- package/src/web/client/graph/scheme.ts +51 -6
- package/src/web/client/main.tsx +12 -0
- package/src/web/client/note/Editor.tsx +16 -9
- package/src/web/client/note/Note.tsx +40 -7
- package/src/web/client/note/note.model.ts +1 -1
- package/src/web/client/search/SearchPalette.tsx +11 -2
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +13 -0
- package/src/web/client/shell/Header.tsx +25 -1
- package/src/web/client/shell/Shell.tsx +76 -2
- package/src/web/client/shell/deeplink.model.ts +61 -0
- package/src/web/client/shell/focus.model.ts +14 -6
- package/src/web/client/shell/keys.model.ts +27 -3
- package/src/web/client/shell/layout.model.ts +12 -38
- package/src/web/client/shell/theme.model.ts +147 -0
- package/src/web/client/shell/theme.ts +289 -79
- package/src/web/client/state.ts +22 -0
- package/src/web/client/tree/Tree.tsx +11 -2
- package/src/web/client/tree/tree.model.ts +12 -1
- package/src/web/client/workspace.ts +78 -6
- package/src/web/server/page.ts +6 -4
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +393 -307
- package/src/web/shared/logo.ts +11 -0
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import DOMPurify from "dompurify";
|
|
27
|
+
import { useMemo } from "preact/hooks";
|
|
27
28
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
28
29
|
import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
|
|
29
30
|
import { Editor, EditorBar } from "./Editor";
|
|
@@ -74,28 +75,60 @@ function Header({ view }: { view: NoteHeaderView }) {
|
|
|
74
75
|
export function Note(props: NoteProps) {
|
|
75
76
|
const note = props.note?.note ?? null;
|
|
76
77
|
const empty = noteEmptyMessage(props.selectedId, note);
|
|
77
|
-
if (note === null) return <p class="weave-note-empty">{empty}</p>;
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// Both hook calls sit before the empty-return so the hook order cannot
|
|
80
|
+
// depend on whether a note is loaded. The memoization is cheap insurance:
|
|
81
|
+
// one marked parse + DOMPurify pass + O(nodes) index per *body or vault*
|
|
82
|
+
// instead of per shell render (every editor keystroke and divider pixel
|
|
83
|
+
// re-renders the shell). The instance the render builds —
|
|
84
|
+
// `markdownRenderer(index)` inside `renderNote` — stays per-call by design;
|
|
85
|
+
// only the *result* is memoized.
|
|
86
|
+
const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
|
|
87
|
+
const html = useMemo(
|
|
88
|
+
() => (note === null || index === null ? "" : renderNote(DOMPurify, note.body, index)),
|
|
89
|
+
[note, index],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
|
|
93
|
+
|
|
80
94
|
// Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
|
|
81
95
|
// is what decides whether the textarea renders, and reading it twice off
|
|
82
96
|
// `props` would need a non-null assertion at the second read.
|
|
83
97
|
const toolbar = props.toolbar;
|
|
84
98
|
const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
|
|
99
|
+
// Keyed on the slug, not the body digest: the article is the scroll
|
|
100
|
+
// container, so an in-place swap would open every note at the previous
|
|
101
|
+
// note's scroll offset. A key change remounts it, and a fresh container
|
|
102
|
+
// starts at the top — no scroll-API effect to untest. The provenance
|
|
103
|
+
// class is the page's spine: the left rule takes the source's colour,
|
|
104
|
+
// which is how the desk says who wrote what a glance away from the text.
|
|
105
|
+
const header = noteHeader(note, props.now);
|
|
85
106
|
return (
|
|
86
|
-
<article class=
|
|
87
|
-
<Header view={
|
|
107
|
+
<article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
|
|
108
|
+
<Header view={header} />
|
|
88
109
|
{toolbar === null ? null : <EditorBar toolbar={toolbar} {...shell} />}
|
|
89
110
|
{toolbar !== null && toolbar.editing ? (
|
|
90
111
|
<Editor toolbar={toolbar} {...shell} />
|
|
91
112
|
) : (
|
|
92
113
|
<div
|
|
93
114
|
class="weave-note-body"
|
|
115
|
+
// Programmatic focus target for `⌘2`: without a `tabindex`,
|
|
116
|
+
// `focusSelector`'s `.focus()` is a silent no-op. `-1` keeps it out
|
|
117
|
+
// of the Tab order (the workspace moves by `j/k` and `⌘1/2/3`, and
|
|
118
|
+
// Tab must stay the user's).
|
|
119
|
+
tabIndex={-1}
|
|
94
120
|
onClick={(event) => {
|
|
95
121
|
// A wikilink carries no href, so nothing is navigating; this only
|
|
96
|
-
// has to route the click onto the §1.3 context bus.
|
|
122
|
+
// has to route the click onto the §1.3 context bus. Anywhere else
|
|
123
|
+
// on the page *is* the edit affordance: a click on the prose opens
|
|
124
|
+
// the editor. The two never fight, because the link check reads
|
|
125
|
+
// the exact element the click landed on.
|
|
97
126
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
98
|
-
if (target !== null)
|
|
127
|
+
if (target !== null) {
|
|
128
|
+
props.onSelect(target);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (props.toolbar !== null) props.send({ type: "toggle" });
|
|
99
132
|
}}
|
|
100
133
|
onKeyDown={(event) => {
|
|
101
134
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
@@ -105,7 +138,7 @@ export function Note(props: NoteProps) {
|
|
|
105
138
|
props.onSelect(target);
|
|
106
139
|
}}
|
|
107
140
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
108
|
-
dangerouslySetInnerHTML={{ __html:
|
|
141
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
109
142
|
/>
|
|
110
143
|
)}
|
|
111
144
|
</article>
|
|
@@ -729,7 +729,7 @@ export function tagLabel(tag: string): string {
|
|
|
729
729
|
*/
|
|
730
730
|
export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
|
|
731
731
|
if (note !== null) return null;
|
|
732
|
-
if (selectedId === null) return "
|
|
732
|
+
if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
|
|
733
733
|
if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
|
|
734
734
|
// A module, a file, a package, the git state: real nodes with no prose
|
|
735
735
|
// behind them. The context rail beside this column is where their
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `useState`, one memo and three handlers. §10's rule.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useMemo, useState } from "preact/hooks";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
11
11
|
import type { GraphPayload } from "../../shared/wire";
|
|
12
12
|
import { useFocusTrap } from "../shell/FocusTrap";
|
|
13
13
|
import type { SearchRowView } from "./search.model";
|
|
@@ -45,6 +45,15 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
45
45
|
const [state, setState] = useState(initialSearchState);
|
|
46
46
|
const search = useMemo(() => createSearch({ ...props.ports, onChange: setState }), []);
|
|
47
47
|
const trap = useFocusTrap();
|
|
48
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
49
|
+
// Focus the input, not the container the trap starts on: the palette's
|
|
50
|
+
// whole surface is its query, and opening it to a container that only
|
|
51
|
+
// swallows (and does not forward) keystrokes makes the user click before
|
|
52
|
+
// they can type. Runs after the trap's own effect — hooks run in order,
|
|
53
|
+
// and the trap is created first — so this is the final word. The input
|
|
54
|
+
// carries the dialog's `aria-label`, so the announcement the
|
|
55
|
+
// container-focus exists for still happens.
|
|
56
|
+
useEffect(() => void input.current?.focus(), []);
|
|
48
57
|
const model = paletteModel(state, props.graph);
|
|
49
58
|
|
|
50
59
|
const pick = (index: number | null): void => {
|
|
@@ -76,7 +85,7 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
76
85
|
<input
|
|
77
86
|
type="search"
|
|
78
87
|
class="weave-palette-input"
|
|
79
|
-
|
|
88
|
+
ref={input}
|
|
80
89
|
value={state.query}
|
|
81
90
|
placeholder={PALETTE_PLACEHOLDER}
|
|
82
91
|
aria-label={PALETTE_TITLE}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection.storage.ts — the §1.3 context bus's memory across reloads.
|
|
3
|
+
*
|
|
4
|
+
* A manual refresh used to drop the open note: `selectedId` is a Preact
|
|
5
|
+
* signal, and signals do not survive a page load. The layout already
|
|
6
|
+
* persists (`shell/layout.model.ts`), and this is the same trick for the
|
|
7
|
+
* one piece of selection state the user actually misses: which note they
|
|
8
|
+
* were reading.
|
|
9
|
+
*
|
|
10
|
+
* ## Restore needs the graph
|
|
11
|
+
*
|
|
12
|
+
* A saved id is only offered back if it still names a node of the freshly
|
|
13
|
+
* loaded graph — a note deleted in another window must not resurrect as a
|
|
14
|
+
* selection pointing at nothing. That check needs the graph, which arrives
|
|
15
|
+
* after mount, so the restore is deliberately a pure function of
|
|
16
|
+
* `(graph, storage)` the shell calls once the first graph lands, not
|
|
17
|
+
* something this module schedules itself.
|
|
18
|
+
*
|
|
19
|
+
* ## Tier rules (§2)
|
|
20
|
+
*
|
|
21
|
+
* `src/web/client/**`: storage arrives as a two-method port (same shape as
|
|
22
|
+
* `LayoutStorage`) rather than the global, because the real `localStorage`
|
|
23
|
+
* throws in partitioned-storage contexts and a module this small should be
|
|
24
|
+
* testable without a DOM.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { GraphPayload } from "../shared/wire";
|
|
28
|
+
|
|
29
|
+
/** The slice of `Storage` this module needs. */
|
|
30
|
+
export interface SelectionStorage {
|
|
31
|
+
getItem(key: string): string | null;
|
|
32
|
+
setItem(key: string, value: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Versioned like `pi-weave.layout.v1`: an id-shape change gets a new key. */
|
|
36
|
+
export const SELECTION_STORAGE_KEY = "pi-weave.selection.v1";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remember the selection; an empty string records "nothing open".
|
|
40
|
+
* Reports failure as `false` instead of throwing — a quota or
|
|
41
|
+
* partitioned-storage error must never break a selection.
|
|
42
|
+
*/
|
|
43
|
+
export function saveSelection(storage: SelectionStorage, id: string | null): boolean {
|
|
44
|
+
try {
|
|
45
|
+
storage.setItem(SELECTION_STORAGE_KEY, id ?? "");
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The saved selection to restore, or `null`.
|
|
54
|
+
*
|
|
55
|
+
* `graph` gates the offer: the id must still name a node of the current
|
|
56
|
+
* payload, so a note deleted elsewhere is quietly not restored. Reads are
|
|
57
|
+
* failure-absorbing for the same reason writes are.
|
|
58
|
+
*/
|
|
59
|
+
export function restoreSelection(graph: GraphPayload | null, storage: SelectionStorage): string | null {
|
|
60
|
+
if (graph === null) return null;
|
|
61
|
+
let raw: string | null;
|
|
62
|
+
try {
|
|
63
|
+
raw = storage.getItem(SELECTION_STORAGE_KEY);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (raw === null || raw === "") return null;
|
|
68
|
+
return graph.model.nodes.some((node) => node.id === raw) ? raw : null;
|
|
69
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { Fragment } from "preact";
|
|
26
26
|
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
27
27
|
import { Graph } from "../graph/Graph";
|
|
28
|
+
import type { ColorScheme } from "../graph/graph.model";
|
|
28
29
|
import type { PositionStorage } from "../graph/positions";
|
|
29
30
|
import type { RendererFactory } from "../graph/renderer";
|
|
30
31
|
import type { SchemeHost } from "../graph/scheme";
|
|
@@ -73,6 +74,16 @@ export interface ColumnsProps {
|
|
|
73
74
|
renderer: RendererFactory;
|
|
74
75
|
storage: PositionStorage;
|
|
75
76
|
host: SchemeHost;
|
|
77
|
+
/**
|
|
78
|
+
* The scheme the shell resolved from the user's theme choice
|
|
79
|
+
* (`shell/theme.model.ts`'s `effectiveScheme`), or `null` to let the column
|
|
80
|
+
* read the OS (`graph/scheme.ts`'s `schemeOf`). The stylesheet carries the
|
|
81
|
+
* choice to everything CSS paints; WebGL cannot read custom properties, so
|
|
82
|
+
* the graph needs the decision handed to it as a value.
|
|
83
|
+
*/
|
|
84
|
+
scheme: ColorScheme | null;
|
|
85
|
+
/** The boot graph fetch failed — see `state.ts`'s `graphFailed`. */
|
|
86
|
+
bootFailed: boolean;
|
|
76
87
|
/** Slot the graph column fills with its `fit`, for the global `g` key. */
|
|
77
88
|
fit: { current: (() => void) | null };
|
|
78
89
|
/**
|
|
@@ -117,6 +128,8 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
|
117
128
|
renderer={props.renderer}
|
|
118
129
|
storage={props.storage}
|
|
119
130
|
host={props.host}
|
|
131
|
+
scheme={props.scheme}
|
|
132
|
+
bootFailed={props.bootFailed}
|
|
120
133
|
fit={props.fit}
|
|
121
134
|
/>
|
|
122
135
|
) : null}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* opened would be a worse lie than the disabled version it replaced.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
|
|
17
|
+
import type { ThemeButtonView } from "./theme.model";
|
|
16
18
|
import type { ConnectionView, HeaderSummary } from "./shell.model";
|
|
17
19
|
import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
|
|
18
20
|
|
|
@@ -23,12 +25,25 @@ export interface HeaderProps {
|
|
|
23
25
|
onRefresh: () => void;
|
|
24
26
|
/** Opens the ⌘K palette. The same action the global key performs. */
|
|
25
27
|
onSearch: () => void;
|
|
28
|
+
/** The theme control's face (glyph + hint), from `shell.model.ts`'s friend in `theme.model.ts`. */
|
|
29
|
+
theme: ThemeButtonView;
|
|
30
|
+
/** Advance the theme choice one step in its cycle. The same action the `t` key performs. */
|
|
31
|
+
onTheme: () => void;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export function Header(props: HeaderProps) {
|
|
29
35
|
return (
|
|
30
36
|
<header class="weave-header">
|
|
31
|
-
<span class="weave-brand"
|
|
37
|
+
<span class="weave-brand">
|
|
38
|
+
<img
|
|
39
|
+
class="weave-brand-mark"
|
|
40
|
+
src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
|
|
41
|
+
alt=""
|
|
42
|
+
width={18}
|
|
43
|
+
height={18}
|
|
44
|
+
/>
|
|
45
|
+
pi-weave
|
|
46
|
+
</span>
|
|
32
47
|
<button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
|
|
33
48
|
<span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
|
|
34
49
|
<kbd>{props.shortcut}</kbd>
|
|
@@ -40,6 +55,15 @@ export function Header(props: HeaderProps) {
|
|
|
40
55
|
</span>
|
|
41
56
|
))}
|
|
42
57
|
</span>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class="weave-theme"
|
|
61
|
+
onClick={props.onTheme}
|
|
62
|
+
title={props.theme.hint}
|
|
63
|
+
aria-label={props.theme.hint}
|
|
64
|
+
>
|
|
65
|
+
{props.theme.glyph}
|
|
66
|
+
</button>
|
|
43
67
|
<button type="button" class="weave-refresh" onClick={props.onRefresh} title="Refetch everything">
|
|
44
68
|
⟳
|
|
45
69
|
</button>
|
|
@@ -25,15 +25,19 @@
|
|
|
25
25
|
|
|
26
26
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
27
27
|
import { fetchJson } from "../api.dom";
|
|
28
|
+
import type { ColorScheme } from "../graph/graph.model";
|
|
29
|
+
import { schemeOf, watchScheme } from "../graph/scheme";
|
|
28
30
|
import { createSigmaRenderer } from "../graph/renderer.dom";
|
|
29
31
|
import { domEventSource } from "../live";
|
|
30
32
|
import { createEditor, watchUnload, type EditorHandle } from "../note/editor.controller";
|
|
31
33
|
import { editorPrompt, editorToolbar, initialEditorState, shouldBlockUnload } from "../note/editor.model";
|
|
32
34
|
import { SearchPalette } from "../search/SearchPalette";
|
|
33
|
-
import {
|
|
35
|
+
import { restoreSelection, saveSelection } from "../selection.storage";
|
|
36
|
+
import { connection, graph, graphFailed, noteBody, selectedId } from "../state";
|
|
34
37
|
import type { WorkspaceHandle } from "../workspace";
|
|
35
38
|
import { observeNotes, select, startWorkspace } from "../workspace";
|
|
36
39
|
import { Columns } from "./Columns";
|
|
40
|
+
import { deeplinkSelection, formatHash } from "./deeplink.model";
|
|
37
41
|
import { dividerHandlers } from "./drag.model";
|
|
38
42
|
import { Header } from "./Header";
|
|
39
43
|
import { HelpOverlay } from "./HelpOverlay";
|
|
@@ -44,8 +48,21 @@ import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.
|
|
|
44
48
|
import { StatusBar } from "./StatusBar";
|
|
45
49
|
import type { OverlayId } from "./shell.model";
|
|
46
50
|
import { connectionView, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
|
|
51
|
+
import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
|
|
52
|
+
import type { ThemeChoice } from "./theme.model";
|
|
47
53
|
import { watchViewport } from "./viewport";
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Write the choice onto `<html>`, so the sheet's attribute branch and the
|
|
57
|
+
* media query agree on who is in charge (see `theme.model.ts`'s header). The
|
|
58
|
+
* one DOM write the theme needs, and it is here rather than in the model for
|
|
59
|
+
* the same reason `applyVars` is: it is an effect, not a decision.
|
|
60
|
+
*/
|
|
61
|
+
function applyThemeAttr(choice: ReturnType<typeof themeAttr>): void {
|
|
62
|
+
if (choice === null) delete document.documentElement.dataset.weaveTheme;
|
|
63
|
+
else document.documentElement.dataset.weaveTheme = choice;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
export interface ShellProps {
|
|
50
67
|
/** From the page bootstrap. Shown in the status bar. */
|
|
51
68
|
cwd: string;
|
|
@@ -60,6 +77,12 @@ export function Shell(props: ShellProps) {
|
|
|
60
77
|
const [overlay, setOverlay] = useState<OverlayId>(null);
|
|
61
78
|
const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
|
|
62
79
|
const [editorState, setEditorState] = useState(initialEditorState);
|
|
80
|
+
// The theme: what the user picked, and what the OS is currently saying. Two
|
|
81
|
+
// states because they answer different questions — `theme` changes on a
|
|
82
|
+
// button press or the `t` key, `systemScheme` on an OS flip while the user
|
|
83
|
+
// is in system mode — and `effectiveScheme` is where the two resolve.
|
|
84
|
+
const [theme, setTheme] = useState<ThemeChoice>(() => loadTheme(localStorage) ?? "system");
|
|
85
|
+
const [systemScheme, setSystemScheme] = useState<ColorScheme>(() => schemeOf(window));
|
|
63
86
|
const workspace = useRef<WorkspaceHandle | null>(null);
|
|
64
87
|
// Filled by the graph column at mount, cleared on unmount. The global `g`
|
|
65
88
|
// key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
|
|
@@ -83,6 +106,37 @@ export function Shell(props: ShellProps) {
|
|
|
83
106
|
return () => handle.stop();
|
|
84
107
|
}, []);
|
|
85
108
|
|
|
109
|
+
// §1.3 continuity: a reload keeps the note you were reading. Saving is
|
|
110
|
+
// gated on the restore decision so the mount-time `null` cannot wipe the
|
|
111
|
+
// saved id before the first graph arrives to validate it against.
|
|
112
|
+
const selectionRestored = useRef(false);
|
|
113
|
+
// The deep link is read once, at mount, into a ref — the URL is an input
|
|
114
|
+
// to boot only, and the hash-write effect below must never clear a link
|
|
115
|
+
// this effect has not read yet.
|
|
116
|
+
const bootHash = useRef(location.hash);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!selectionRestored.current) return;
|
|
119
|
+
saveSelection(localStorage, selectedId.value);
|
|
120
|
+
// replaceState, not pushState: the address bar mirrors the note on
|
|
121
|
+
// screen, and reading three notes is not three history entries. The
|
|
122
|
+
// *string* is the model's (`formatHash`); this is the write itself.
|
|
123
|
+
history.replaceState(null, "", formatHash(selectedId.value));
|
|
124
|
+
}, [selectedId.value]);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (selectionRestored.current || graph.value === null) return;
|
|
127
|
+
selectionRestored.current = true;
|
|
128
|
+
if (selectedId.value !== null) return;
|
|
129
|
+
// A link wins over storage: the address bar is an explicit instruction,
|
|
130
|
+
// the saved note is a habit. A link to a note the graph does not hold is
|
|
131
|
+
// refused (see `deeplink.model.ts`) and continuity falls through.
|
|
132
|
+
const linked = deeplinkSelection(bootHash.current, graph.value);
|
|
133
|
+
if (linked !== null) void select(fetchJson, linked);
|
|
134
|
+
else {
|
|
135
|
+
const saved = restoreSelection(graph.value, localStorage);
|
|
136
|
+
if (saved !== null) void select(fetchJson, saved);
|
|
137
|
+
}
|
|
138
|
+
}, [graph.value]);
|
|
139
|
+
|
|
86
140
|
// Every note that arrives, from any of the three directions it can arrive
|
|
87
141
|
// from (mount, selection, SSE refetch), so the editor can decide whether it
|
|
88
142
|
// is news or an interruption — see `editor.model.ts`'s header.
|
|
@@ -95,6 +149,18 @@ export function Shell(props: ShellProps) {
|
|
|
95
149
|
|
|
96
150
|
useEffect(() => watchViewport(window, setWidth), []);
|
|
97
151
|
|
|
152
|
+
// The theme's two effects: the attribute decides what the sheet paints, the
|
|
153
|
+
// save decides what a reload restores. Both keyed on the choice alone — a
|
|
154
|
+
// system-scheme flip touches neither, because the media query repaints the
|
|
155
|
+
// stylesheet by itself and the shell only re-resolves the graph's scheme.
|
|
156
|
+
useEffect(() => applyThemeAttr(themeAttr(theme)), [theme]);
|
|
157
|
+
useEffect(() => void saveTheme(localStorage, theme), [theme]);
|
|
158
|
+
// Live OS flips, needed only in system mode but harmless always: the
|
|
159
|
+
// stylesheet follows the OS on its own; this is what lets the *graph* (and
|
|
160
|
+
// the header button, which shows the resolved scheme nowhere — it shows the
|
|
161
|
+
// choice) keep up while the choice is "system".
|
|
162
|
+
useEffect(() => watchScheme(window, setSystemScheme), []);
|
|
163
|
+
|
|
98
164
|
useEffect(
|
|
99
165
|
() =>
|
|
100
166
|
watchKeys(document, {
|
|
@@ -111,6 +177,7 @@ export function Shell(props: ShellProps) {
|
|
|
111
177
|
clearSelection: () => editor.send({ type: "navigate", id: null }),
|
|
112
178
|
toggleEdit: () => editor.send({ type: "toggle" }),
|
|
113
179
|
saveNote: () => editor.send({ type: "save" }),
|
|
180
|
+
cycleTheme: () => setTheme((current) => cycleTheme(current)),
|
|
114
181
|
}),
|
|
115
182
|
}),
|
|
116
183
|
[],
|
|
@@ -139,6 +206,8 @@ export function Shell(props: ShellProps) {
|
|
|
139
206
|
shortcut={searchShortcut(looksApple(props.platform))}
|
|
140
207
|
onRefresh={() => workspace.current?.refresh()}
|
|
141
208
|
onSearch={() => setOverlay("search")}
|
|
209
|
+
theme={themeButton(theme)}
|
|
210
|
+
onTheme={() => setTheme((current) => cycleTheme(current))}
|
|
142
211
|
/>
|
|
143
212
|
<Columns
|
|
144
213
|
resolved={resolved}
|
|
@@ -146,6 +215,8 @@ export function Shell(props: ShellProps) {
|
|
|
146
215
|
onMove={drag.onMove}
|
|
147
216
|
onUp={drag.onUp}
|
|
148
217
|
onKey={drag.onKey}
|
|
218
|
+
scheme={effectiveScheme(theme, systemScheme)}
|
|
219
|
+
bootFailed={graphFailed.value}
|
|
149
220
|
graph={graph.value}
|
|
150
221
|
note={noteBody.value}
|
|
151
222
|
selectedId={selectedId.value}
|
|
@@ -180,7 +251,10 @@ export function Shell(props: ShellProps) {
|
|
|
180
251
|
{overlay === "search" ? (
|
|
181
252
|
<SearchPalette
|
|
182
253
|
graph={graph.value}
|
|
183
|
-
|
|
254
|
+
// Through the editor, like every column's `onSelect`: choosing a hit
|
|
255
|
+
// while a draft is dirty must park behind the UNSAVED prompt, not
|
|
256
|
+
// wipe it. This was the one navigation path that skipped the guard.
|
|
257
|
+
onSelect={(id) => editor.send({ type: "navigate", id })}
|
|
184
258
|
onClose={() => setOverlay(null)}
|
|
185
259
|
ports={{ fetch: fetchJson, now: Date.now, delay: (run, ms) => void setTimeout(run, ms) }}
|
|
186
260
|
/>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links: the selection, addressable (weave-workspace UX backlog T5.4).
|
|
3
|
+
*
|
|
4
|
+
* `#note/<slug>` in the location bar is the current selection, both ways:
|
|
5
|
+
* a `location.hash` naming a real node at boot takes priority over the saved
|
|
6
|
+
* *last note* (the link is an explicit instruction, storage is a habit), and
|
|
7
|
+
* every selection change is written back with `history.replaceState` — a
|
|
8
|
+
* shared URL must mean the note on screen, but reading three notes must not
|
|
9
|
+
* put three entries in the back button's history.
|
|
10
|
+
*
|
|
11
|
+
* Two facts decide the shape. First, only *note* nodes deep-link: a
|
|
12
|
+
* repository or file node has no URL-worthy permanence (its id is derived
|
|
13
|
+
* from paths that move), and the note fetch behind a selection 404s for
|
|
14
|
+
* anything else. Second, a hash naming a node that is *not in the graph*
|
|
15
|
+
* must refuse rather than select: a stale link pointing at a deleted note
|
|
16
|
+
* would otherwise land the reader on "Nothing open" with the saved note
|
|
17
|
+
* passed over — parsing without validation would make every dead link hide
|
|
18
|
+
* the workspace's own continuity.
|
|
19
|
+
|
|
20
|
+
* Everything here is a pure function over strings and payloads (§10); the
|
|
21
|
+
* `history.replaceState` call itself is the shell's, because it is an effect.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { GraphPayload } from "../../shared/wire";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The note node a hash names, or `null`.
|
|
28
|
+
*
|
|
29
|
+
* Accepts the two spellings a user might paste — `#note/<slug>` and the
|
|
30
|
+
* node id it encodes (`#note:<slug>` is what the tree's own rows use) — and
|
|
31
|
+
* rejects everything else, including the bare `#` a reader gets from
|
|
32
|
+
* clearing the hash by hand. An empty slug would produce a request for
|
|
33
|
+
* `/api/note/`, which means nothing to anyone.
|
|
34
|
+
*/
|
|
35
|
+
export function hashSelection(hash: string): string | null {
|
|
36
|
+
if (hash === "" || hash === "#") return null;
|
|
37
|
+
const body = hash.slice(1);
|
|
38
|
+
const match = /^note[/:](.+)$/.exec(body);
|
|
39
|
+
return match === null ? null : `note:${match[1]}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The boot selection: a hash that names a node the payload actually holds,
|
|
44
|
+
* or `null`. Validation is *the* point — see the module header.
|
|
45
|
+
*/
|
|
46
|
+
export function deeplinkSelection(hash: string, payload: GraphPayload | null): string | null {
|
|
47
|
+
const wanted = hashSelection(hash);
|
|
48
|
+
if (wanted === null || payload === null) return null;
|
|
49
|
+
return payload.model.nodes.some((node) => node.id === wanted) ? wanted : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The URL fragment for a selection: `#note/<slug>`, or `""` for a cleared
|
|
54
|
+
* selection (an empty string hands `replaceState` the bare URL, clearing the
|
|
55
|
+
* hash). A non-note id formats to `""` too — the address bar describes the
|
|
56
|
+
* *note* being read, nothing else.
|
|
57
|
+
*/
|
|
58
|
+
export function formatHash(selectedId: string | null): string {
|
|
59
|
+
const slug = selectedId === null ? null : selectedId.startsWith("note:") ? selectedId.slice("note:".length) : null;
|
|
60
|
+
return slug === null || slug === "" ? "" : `#note/${slug}`;
|
|
61
|
+
}
|
|
@@ -52,11 +52,18 @@ export const FOCUSABLE_SELECTOR = [
|
|
|
52
52
|
* Where Tab should land next, or `null` to leave the event alone.
|
|
53
53
|
*
|
|
54
54
|
* Returns an **index** rather than an element so the whole thing is testable
|
|
55
|
-
* with an array of numbers if you like.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* with an array of numbers if you like. Two refusal cases, by different
|
|
56
|
+
* reasoning:
|
|
57
|
+
*
|
|
58
|
+
* - `count === 0` — nothing to cycle, and holding the user on a dialog that
|
|
59
|
+
* eats Tab and does nothing with it is strictly worse than an untrapped one
|
|
60
|
+
* they can at least escape.
|
|
61
|
+
* - `count === 1` — there *is* something to cycle, and the modal must not
|
|
62
|
+
* let go: Tab returns the user to the single control, matching the WAI-ARIA
|
|
63
|
+
* expectation that focus stays in an open dialog. (This was once `null`
|
|
64
|
+
* with the zero case's reasoning — "one thing, nowhere to go" — but a
|
|
65
|
+
* palette whose only control is its input would then Tab straight out into
|
|
66
|
+
* the covered workspace, typing into a search box the user cannot see.)
|
|
60
67
|
*
|
|
61
68
|
* @param count how many focusables the dialog contains
|
|
62
69
|
* @param at the index of the currently focused one, or `-1` when focus is on
|
|
@@ -64,7 +71,8 @@ export const FOCUSABLE_SELECTOR = [
|
|
|
64
71
|
* @param backwards Shift+Tab
|
|
65
72
|
*/
|
|
66
73
|
export function trapTarget(count: number, at: number, backwards: boolean): number | null {
|
|
67
|
-
if (count
|
|
74
|
+
if (count === 0) return null;
|
|
75
|
+
if (count === 1) return 0;
|
|
68
76
|
// Focus on the container itself: Tab enters at the top, Shift+Tab at the
|
|
69
77
|
// bottom. Without this the first Tab out of a freshly-opened dialog would
|
|
70
78
|
// compute from -1 and land on the second control, skipping the input.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* | `⌘1` `⌘2` `⌘3` | always |
|
|
28
28
|
* | `⌘E` `⌘S` | always — the editor's two keys (§11 P5.4) |
|
|
29
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 |
|
|
30
|
+
* | `/` `g` `?` `t` | **only** when focus is not in a text field and no overlay is open |
|
|
31
31
|
*
|
|
32
32
|
* `⌘S` is claimed **even while typing**, which is the one place this map
|
|
33
33
|
* deliberately overrides the browser. It has to be: the browser's `⌘S` is
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* `⌘K`, which would otherwise re-open the palette on top of itself.
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
import { COLUMNS, columnsAt } from "./layout.model";
|
|
54
55
|
import type { ColumnId } from "./layout.model";
|
|
55
56
|
import type { OverlayId } from "./shell.model";
|
|
56
57
|
|
|
@@ -104,7 +105,9 @@ export type ShellAction =
|
|
|
104
105
|
/** `⌘E` — toggle the note column between read and edit (§11 P5.4). */
|
|
105
106
|
| { readonly type: "toggleEdit" }
|
|
106
107
|
/** `⌘S` — save the open draft. */
|
|
107
|
-
| { readonly type: "saveNote" }
|
|
108
|
+
| { readonly type: "saveNote" }
|
|
109
|
+
/** `t` — cycle the colour theme: system → light → dark → system. */
|
|
110
|
+
| { readonly type: "cycleTheme" };
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
113
|
* The command-key letters, as data.
|
|
@@ -129,6 +132,18 @@ export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
|
129
132
|
*/
|
|
130
133
|
export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
|
|
131
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The columns a breakpoint can put off screen — the graph at `"medium"`, the
|
|
137
|
+
* tree too at `"narrow"`, never the note. The help sheet's derived rows use it
|
|
138
|
+
* to stay honest about `⌘1`/`⌘3`: at 900 px the `⌘3` command is a documented
|
|
139
|
+
* no-op, and a help line that hides that is the same lie the `disabled`
|
|
140
|
+
* search button once was. Derived from `columnsAt`, not listed, so the caveat
|
|
141
|
+
* cannot drift from the breakpoints the sheet is attached to.
|
|
142
|
+
*/
|
|
143
|
+
const COLLAPSIBLE_COLUMNS: ReadonlySet<ColumnId> = new Set(
|
|
144
|
+
COLUMNS.filter((c) => columnsAt("medium").includes(c) === false || columnsAt("narrow").includes(c) === false),
|
|
145
|
+
);
|
|
146
|
+
|
|
132
147
|
/**
|
|
133
148
|
* Whether a modifier combination counts as "the platform's command key".
|
|
134
149
|
*
|
|
@@ -162,6 +177,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
|
162
177
|
"/": { type: "filterTree" },
|
|
163
178
|
g: { type: "fitGraph" },
|
|
164
179
|
"?": { type: "openHelp" },
|
|
180
|
+
t: { type: "cycleTheme" },
|
|
165
181
|
};
|
|
166
182
|
|
|
167
183
|
/**
|
|
@@ -346,6 +362,8 @@ export interface ShellEffects {
|
|
|
346
362
|
toggleEdit(): void;
|
|
347
363
|
/** `⌘S` — dispatch `save` into the note editor. */
|
|
348
364
|
saveNote(): void;
|
|
365
|
+
/** `t` — advance the user's theme choice by one step in its cycle. */
|
|
366
|
+
cycleTheme(): void;
|
|
349
367
|
}
|
|
350
368
|
|
|
351
369
|
/** Perform an action. Total over {@link ShellAction}. */
|
|
@@ -371,6 +389,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
|
|
|
371
389
|
return fx.toggleEdit();
|
|
372
390
|
case "saveNote":
|
|
373
391
|
return fx.saveNote();
|
|
392
|
+
case "cycleTheme":
|
|
393
|
+
return fx.cycleTheme();
|
|
374
394
|
}
|
|
375
395
|
}
|
|
376
396
|
|
|
@@ -408,8 +428,12 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
|
408
428
|
title: "Global",
|
|
409
429
|
entries: [
|
|
410
430
|
{ combo: `${cmd}K`, what: "Search notes and the repository" },
|
|
411
|
-
...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
|
|
431
|
+
...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({
|
|
432
|
+
combo: `${cmd}${digit}`,
|
|
433
|
+
what: `Focus the ${column} column${COLLAPSIBLE_COLUMNS.has(column) ? " (when on screen)" : ""}`,
|
|
434
|
+
})),
|
|
412
435
|
{ combo: "?", what: "This help" },
|
|
436
|
+
{ combo: "t", what: "Cycle the colour theme (system / light / dark)" },
|
|
413
437
|
{ combo: "Esc", what: "Clear the selection, or close an overlay" },
|
|
414
438
|
],
|
|
415
439
|
},
|