pi-weave 0.3.8 → 0.3.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.
Files changed (80) hide show
  1. package/README.md +21 -17
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -1,9 +1,11 @@
1
- import { useState } from "preact/hooks";
1
+ import { useEffect, useState } from "preact/hooks";
2
2
  import type { ComponentChildren } from "preact";
3
3
  import type { WorkspaceLayout } from "../../shared/workspace";
4
4
  import type { Preferences } from "../../shared/preferences";
5
5
  import { ACCENTS, THEMES } from "../../shared/themes";
6
6
  import type { AccentChoice, PaletteChoice } from "../../shared/themes";
7
+ import { historySettings } from "../api";
8
+ import { fetchJson } from "../api.dom";
7
9
  import { ForceTuner } from "../graph/ForceTuner";
8
10
  import { useFocusTrap } from "./FocusTrap";
9
11
  import { keyHelp } from "./keys.model";
@@ -20,6 +22,23 @@ export function Settings(props: {
20
22
  const [category, setCategory] = useState<typeof CATEGORIES[number]>("General");
21
23
  const [refreshCount, setRefreshCount] = useState(0);
22
24
  const trap = useFocusTrap();
25
+ // Note history lives in the vault, not in workspace preferences, so the
26
+ // agent's writes respect it too. `null` until the server has answered.
27
+ const [history, setHistory] = useState<boolean | null>(null);
28
+ // One change at a time: the control is disabled until the server answers,
29
+ // so a late response can never overwrite a newer choice.
30
+ const [historySaving, setHistorySaving] = useState(false);
31
+ useEffect(() => {
32
+ void historySettings(fetchJson).then((result) => { if (result.ok) setHistory(result.data.enabled); });
33
+ }, []);
34
+ const toggleHistory = (enabled: boolean): void => {
35
+ setHistory(enabled);
36
+ setHistorySaving(true);
37
+ void historySettings(fetchJson, enabled).then((result) => {
38
+ setHistory(result.ok ? result.data.enabled : !enabled);
39
+ setHistorySaving(false);
40
+ });
41
+ };
23
42
  const p = props.layout.preferences;
24
43
  const update = (values: Partial<Preferences>): void => props.onChange({ ...props.layout, preferences: { ...p, ...values } });
25
44
  return <div class="weave-scrim weave-settings-scrim" onClick={props.onClose}>
@@ -55,6 +74,7 @@ export function Settings(props: {
55
74
  <Row name="Spellcheck" hint="Use your browser or device’s spellchecker while editing."><input type="checkbox" checked={p.spellcheck} onChange={(event) => update({ spellcheck: event.currentTarget.checked })} /></Row>
56
75
  <Row name="Readable line length" hint="Keep note text in a comfortable reading column."><input type="checkbox" checked={p.readable} onChange={(event) => update({ readable: event.currentTarget.checked })} /></Row>
57
76
  <Row name="Open notes in editing mode" hint="Newly opened notes start in the editor. Existing drafts are preserved."><input type="checkbox" checked={p.defaultEdit} onChange={(event) => update({ defaultEdit: event.currentTarget.checked })} /></Row>
77
+ <Row name="Note history" hint="Save a copy of a note after every change, by you or your agent, so History can replay how it was written. Turning it off keeps what is already saved."><input type="checkbox" disabled={history === null || historySaving} checked={history ?? false} onChange={(event) => toggleHistory(event.currentTarget.checked)} /></Row>
58
78
  </> : null}
59
79
  {category === "Files" ? <>
60
80
  <Row name="On startup" hint="Choose which tabs appear when you reopen this workspace."><select aria-label="On startup" value={p.startup} onChange={(event) => update({ startup: event.currentTarget.value as Preferences["startup"] })}><option value="restore">Restore open tabs</option><option value="empty">Start with an empty tab</option></select></Row>
@@ -1,16 +1,16 @@
1
1
  /** Shared browser/desktop workspace: one data source, two optional tab groups. */
2
2
  import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "preact/hooks";
3
- import type { GraphPayload, NotePayload } from "../../shared/wire";
3
+ import type { GraphPayload, NotePayload, TrailPayload } from "../../shared/wire";
4
4
  import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, reorderTab, moveTab, navigateTab, openDocument, openWithPreferences, restoreWorkspace, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
5
5
  import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
6
6
  import { fetchJson } from "../api.dom";
7
- import { createNote, fetchGraph, openNote, saveNote } from "../api";
7
+ import { createNote, fetchGraph, fetchHistory, openNote, saveNote } from "../api";
8
8
  import { Graph } from "../graph/Graph";
9
9
  import { schemeOf, watchScheme } from "../graph/scheme";
10
10
  import { createSigmaRenderer } from "../graph/renderer.dom";
11
11
  import { Note } from "../note/Note";
12
12
  import { createDraftStore, mayMutateDrafts, mayRemoveDrafts } from "../note/drafts";
13
- import type { DraftStore } from "../note/drafts";
13
+ import type { DraftStore, SaveNote } from "../note/drafts";
14
14
  import { DISCARD_PROMPT } from "../note/note.model";
15
15
  import { SearchPalette } from "../search/SearchPalette";
16
16
  import { Icon, Tree } from "../tree/Tree";
@@ -18,6 +18,10 @@ import { initialWorkspaceState } from "../state";
18
18
  import { startWorkspace, watchNote } from "../workspace";
19
19
  import type { WorkspaceHandle } from "../workspace";
20
20
  import { ContextRail } from "./ContextRail";
21
+ import { contextCount, contextModel } from "../context/context.model";
22
+ import { startTrailPoll } from "../trail/trail";
23
+ import { TRAIL_TICK_MS, trailLive, trailMarks, trailTurns } from "../trail/trail.model";
24
+ import type { TrailReplay } from "../trail/trail.model";
21
25
  import { ResizeHandle } from "./ResizeHandle";
22
26
  import { deeplinkSelection, formatHash } from "./deeplink.model";
23
27
  import { Settings } from "./Settings";
@@ -28,7 +32,7 @@ import { watchKeys } from "./keys";
28
32
  import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
29
33
  import { StatusBar } from "./StatusBar";
30
34
  import type { OverlayId } from "./shell.model";
31
- import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
35
+ import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, openInSplit, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
32
36
  import { toggleTheme, effectiveScheme, loadTheme, saveTheme, themeButton } from "./theme.model";
33
37
 
34
38
  export interface ShellProps { cwd: string; initialWidth: number; platform: string }
@@ -51,7 +55,7 @@ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | nu
51
55
  /** A tab view can unmount; the shared draft store outlives it. */
52
56
  function DocumentView(props: {
53
57
  preferences: Preferences; tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
54
- onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
58
+ onSelect: (id: string, newTab?: boolean) => void; onSave: SaveNote;
55
59
  now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void; onCreate: (title: string) => Promise<boolean>;
56
60
  }) {
57
61
  const id = tabSelection(props.tab);
@@ -110,7 +114,7 @@ function DocumentView(props: {
110
114
  }}><Note spellcheck={props.preferences.spellcheck} defaultEdit={props.preferences.defaultEdit} note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
111
115
  onSelect={props.onSelect} onOpen={(noteSlug) => void openNote(fetchJson, noteSlug).then((result) => {
112
116
  if (!result.ok) window.alert(result.message); else if (!result.data.opened) window.alert("Could not open the note in an editor.");
113
- })} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
117
+ })} onSave={props.onSave} onHistory={(noteSlug) => fetchHistory(fetchJson, noteSlug)} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
114
118
  }
115
119
 
116
120
  export function Shell(props: ShellProps) {
@@ -119,6 +123,9 @@ export function Shell(props: ShellProps) {
119
123
  const [overlay, setOverlay] = useState<OverlayId>(null);
120
124
  const [contextGraph, setContextGraph] = useState(false);
121
125
  const [now, setNow] = useState(Date.now);
126
+ const [trail, setTrail] = useState<TrailPayload | null>(null);
127
+ const [trailNow, setTrailNow] = useState(Date.now);
128
+ const [replay, setReplay] = useState<TrailReplay | null>(null);
122
129
  const [width, setWidth] = useState(props.initialWidth);
123
130
  const [scheme, setScheme] = useState(() => schemeOf(window));
124
131
  const [ready, setReady] = useState(false);
@@ -129,6 +136,12 @@ export function Shell(props: ShellProps) {
129
136
  const [graphSelection, setGraphSelection] = useState<string | null>(null);
130
137
  const lastViewedDocument = useRef<string | null>(null);
131
138
  const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
139
+ // The graph spotlight's query (`graph/spotlight.model.ts`). Held here, not
140
+ // in the column, so other UI can light the graph up — see `lightUp`.
141
+ const [graphQuery, setGraphQuery] = useState("");
142
+ // What the search palette listed when it lit the graph up, for that query.
143
+ // Typing a different query in the spotlight leaves these behind.
144
+ const [graphFound, setGraphFound] = useState<{ query: string; ids: readonly string[] } | null>(null);
132
145
  const draggedTab = useRef<string | null>(null);
133
146
  const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
134
147
  const [dropPane, setDropPane] = useState<string | null>(null);
@@ -156,6 +169,11 @@ export function Shell(props: ShellProps) {
156
169
  const fullGraphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
157
170
  const contextGraphActive = contextGraph && tab.kind === "document" && !fullGraphVisible;
158
171
  const contextVisible = width < 1050 ? compactContext : layout.contextVisible;
172
+ // Context floats as a card over the page's right margin unless pinned as a column (C1).
173
+ const contextFloating = !layout.contextPinned;
174
+ const contextTotal = useMemo(() => contextCount(contextModel(data.graph, selectedId)), [data.graph, selectedId]);
175
+ const cardWidth = Math.max(240, Math.min(320, layout.contextWidth));
176
+ const showContext = (): void => { if (width < 1050) setCompactContext(true); else setLayout({ ...layout, contextVisible: true }); };
159
177
  const graphVisible = fullGraphVisible || contextGraphActive && contextVisible && documentId !== null;
160
178
  const hasGraph = layout.panes.some((group) => group.tabs.some((entry) => entry.kind === "graph"));
161
179
  const live = useRef({ layout, overlay, selectedId, graphPreviewId, select: (_id: string | null) => {} });
@@ -197,11 +215,27 @@ export function Shell(props: ShellProps) {
197
215
  if (graphClickOpensTab(live.current.graphPreviewId, id)) openGraphNode(id);
198
216
  else previewGraphNode(id);
199
217
  };
218
+ /** The graph sheet's "Open in split": beside the graph, which stays on screen. */
219
+ const openGraphNodeInSplit = (id: string): void => {
220
+ previewGraphNode(null);
221
+ change(openInSplit(layout, id, graphPane?.id));
222
+ };
223
+ /**
224
+ * Open the graph view with `query` lit up in its spotlight. The entry point
225
+ * for other UI (the search palette's "light up in graph"); `""` clears it.
226
+ * `ids` are the palette's results, so a note found by its text lights too.
227
+ */
228
+ const lightUp = (query: string, ids: readonly string[] = []): void => {
229
+ setGraphQuery(query);
230
+ setGraphFound({ query, ids });
231
+ showGraph();
232
+ };
200
233
  live.current = { layout, overlay, selectedId, graphPreviewId, select };
201
234
  const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
202
- const save = async (slug: string, body: string): Promise<boolean> => {
203
- const result = await saveNote(fetchJson, slug, body);
204
- if (!result.ok) { window.alert(result.message); return false; }
235
+ const save: SaveNote = async (slug, body, options) => {
236
+ const result = await saveNote(fetchJson, slug, body, options?.raw === true);
237
+ // An autosave is quiet: the edit bar says "Not saved" instead of an alert per pause.
238
+ if (!result.ok) { if (options?.quiet !== true) window.alert(result.message); return false; }
205
239
  setRevision((value) => value + 1); workspace.current?.refresh(); return true;
206
240
  };
207
241
  const create = async (title: string, paneId: string): Promise<boolean> => {
@@ -272,6 +306,30 @@ export function Shell(props: ShellProps) {
272
306
  }, 350);
273
307
  return () => window.clearTimeout(timer);
274
308
  }, [layout, ready]);
309
+ // The recall trail: its own poll, and a fade clock that runs only while
310
+ // something is glowing (or a replay still has steps to light).
311
+ useEffect(() => startTrailPoll({
312
+ fetch: fetchJson,
313
+ onTrail: (next) => { setTrail(next); setReplay(null); setTrailNow(Date.now()); },
314
+ }), []);
315
+ const trailGlowing = trailLive(trail, trailNow, replay);
316
+ useEffect(() => {
317
+ if (!trailGlowing) {
318
+ if (replay !== null) setReplay(null);
319
+ return;
320
+ }
321
+ const tick = window.setInterval(() => setTrailNow(Date.now()), TRAIL_TICK_MS);
322
+ return () => window.clearInterval(tick);
323
+ }, [trailGlowing]);
324
+ const glows = useMemo(() => trailMarks(trail, trailNow, replay), [trail, trailNow, replay]);
325
+ const turns = useMemo(() => trailTurns(trail, data.graph), [trail, data.graph]);
326
+ // A floating card stays out of the way when it has nothing to say (no
327
+ // selection, no agent turn), and over the graph, whose own sheet is its context.
328
+ const coveredPane = layout.split === "down" ? layout.panes[0] : layout.panes.at(-1);
329
+ const graphUnderCard = coveredPane?.tabs.find((entry) => entry.id === coveredPane.activeTab)?.kind === "graph";
330
+ const contextIdle = contextFloating && (graphUnderCard || selectedId === null && turns.length === 0);
331
+ const contextOpen = contextVisible && !contextIdle;
332
+ const replayTurn = (turn: number): void => { const at = Date.now(); setTrailNow(at); setReplay({ turn, startedAt: at }); };
275
333
  useEffect(() => {
276
334
  const tick = window.setInterval(() => setNow(Date.now()), TICK_MS);
277
335
  const resize = (): void => setWidth(window.innerWidth);
@@ -305,6 +363,10 @@ export function Shell(props: ShellProps) {
305
363
  },
306
364
  closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
307
365
  toggleTheme: () => setLayout((current) => ({ ...current, theme: toggleTheme(current.theme, schemeOf(window), current.preferences) })),
366
+ toggleContext: () => {
367
+ if (window.innerWidth < 1050) setCompactContext((value) => !value);
368
+ else setLayout((current) => ({ ...current, contextVisible: !current.contextVisible }));
369
+ },
308
370
  focusSelector: (selector) => {
309
371
  if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
310
372
  else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
@@ -376,7 +438,9 @@ export function Shell(props: ShellProps) {
376
438
  const renderPane = (group: Pane) => {
377
439
  const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
378
440
  const isActive = group.id === layout.activePane;
379
- return <section key={group.id} class={`weave-pane${isActive ? " weave-pane-active" : ""}${dropPane === group.id ? " weave-pane-drop" : ""}`} data-drop-label={draggedTab.current === null ? "Open note here" : "Move tab here"} aria-label={`Workspace pane ${layout.panes.indexOf(group) + 1}`}
441
+ // The floating card sits over the right-hand pane (both, stacked); its text makes room.
442
+ const underCard = contextFloating && contextOpen && (layout.split === "down" || group === layout.panes.at(-1));
443
+ return <section key={group.id} class={`weave-pane${isActive ? " weave-pane-active" : ""}${dropPane === group.id ? " weave-pane-drop" : ""}${underCard ? " weave-pane-under-card" : ""}`} data-drop-label={draggedTab.current === null ? "Open note here" : "Move tab here"} aria-label={`Workspace pane ${layout.panes.indexOf(group) + 1}`}
380
444
  onDragOver={(event) => tabDragOver(group.id, event)} onDrop={(event) => tabDrop(group.id, event)}
381
445
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
382
446
  hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
@@ -407,7 +471,7 @@ export function Shell(props: ShellProps) {
407
471
  const index = event.key === "Home" ? 0 : event.key === "End" ? group.tabs.length - 1 : (group.tabs.indexOf(entry) + offset + group.tabs.length) % group.tabs.length;
408
472
  const next = group.tabs[index]!; setLayout(activateTab(layout, group.id, next.id));
409
473
  requestAnimationFrame(() => document.getElementById(`tab-${next.id}`)?.focus());
410
- }}><span aria-hidden="true">{entry.kind === "graph" ? "◌" : "▤"}</span><span>{titleOf(entry, data.graph)}</span>
474
+ }}><span aria-hidden="true"><Icon name={entry.kind === "graph" ? "graph" : "note"} class="weave-icon" /></span><span>{titleOf(entry, data.graph)}</span>
411
475
  {drafts.isDirty((tabSelection(entry) ?? "").slice(5)) ? <span class="weave-dirty" aria-label="Unsaved changes">●</span> : null}</button>
412
476
  <button type="button" class="weave-tab-close" aria-label={`Close ${titleOf(entry, data.graph)}`} onClick={() => change(closeTab(layout, group.id, entry.id))}>×</button>
413
477
  </div>)}
@@ -436,23 +500,21 @@ export function Shell(props: ShellProps) {
436
500
  };
437
501
 
438
502
  return <>
439
- <div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%" }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
503
+ <div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%", "--weave-card-width": `${cardWidth}px` }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
440
504
  <nav class="weave-ribbon" aria-label="Workspace tools">
441
- <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
442
- <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
443
- <button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><svg viewBox="0 0 20 20" width={18} height={18} fill="none" stroke="currentColor" stroke-width={1.5} aria-hidden="true"><rect x="2" y="2" width="16" height="16" rx="3" /><circle cx="10" cy="10" r="4" /></svg></button>
444
- <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}>
445
- <svg viewBox="0 0 20 20" width={16} height={16} fill="none" stroke="currentColor" stroke-width={1.7} aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" /></svg>
446
- </button>
505
+ <button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}><Icon name="sidebar" class="weave-icon" /></button>
506
+ <button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}><Icon name="graph" class="weave-icon" /></button>
507
+ <button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><Icon name="noteGraph" class="weave-icon" /></button>
508
+ <button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}><Icon name="search" class="weave-icon" /></button>
447
509
  <span class="weave-ribbon-space" />
448
- <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}>☷</button>
449
- <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}>↻</button>
450
- <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}>{theme.glyph}</button>
451
- <button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}>⚙</button>
510
+ <button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}><Icon name="context" class="weave-icon" /></button>
511
+ <button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}><Icon name="refresh" class="weave-icon" /></button>
512
+ <button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}><Icon name={theme.icon} class="weave-icon" /></button>
513
+ <button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}><Icon name="settings" class="weave-icon" /></button>
452
514
  </nav>
453
515
  <div class="weave-sidebar-dock weave-notes-dock" data-open={layout.treeVisible} inert={!layout.treeVisible} aria-hidden={!layout.treeVisible} style={{ "--weave-sidebar-width": `${Math.min(layout.treeWidth, width - 90)}px` }}><aside class="weave-sidebar weave-sidebar-notes" aria-label="Notes sidebar">
454
516
  <div class="weave-sidebar-heading"><button type="button" aria-pressed={!recentMode} onClick={() => setRecentMode(false)}>Files</button><button type="button" aria-pressed={recentMode} onClick={() => setRecentMode(true)}>Recent</button><button type="button" aria-label="Hide notes sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle notes sidebar"]')?.focus(); setLayout({ ...layout, treeVisible: false }); }}>«</button></div>
455
- {recentMode ? <RecentList visits={visits} graph={data.graph} selectedId={selectedId} onSelect={select} /> : <Tree graph={data.graph} selectedId={selectedId} recentIds={data.recentIds} onSelect={(id, newTab, keepSidebar) => select(id, newTab, layout.activePane, keepSidebar)} onMutate={mayMutate} onRefresh={() => workspace.current?.refresh()} now={now} />}
517
+ {recentMode ? <RecentList visits={visits} graph={data.graph} selectedId={selectedId} onSelect={select} /> : <Tree graph={data.graph} selectedId={selectedId} recentIds={data.recentIds} trail={glows} onSelect={(id, newTab, keepSidebar) => select(id, newTab, layout.activePane, keepSidebar)} onMutate={mayMutate} onRefresh={() => workspace.current?.refresh()} now={now} />}
456
518
  <div class="weave-vault-label"><span>Workspace</span><strong title={props.cwd}>{props.cwd.split(/[\\/]/).filter(Boolean).pop() ?? "Weave"}</strong></div>
457
519
  </aside><ResizeHandle label="Resize notes sidebar" min={180} max={420} value={layout.treeWidth} onChange={(delta) => setLayout((value) => ({ ...value, treeWidth: Math.max(180, Math.min(420, value.treeWidth + delta)) }))} /></div>
458
520
  <main class={`weave-panes weave-split-${layout.split}`} style={layout.panes.length === 2 && width >= 850 ? { [layout.split === "right" ? "gridTemplateColumns" : "gridTemplateRows"]: `minmax(0, ${layout.ratio}fr) 4px minmax(0, ${1 - layout.ratio}fr)` } : {}}>
@@ -461,7 +523,10 @@ export function Shell(props: ShellProps) {
461
523
  setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
462
524
  }} /> : null}{renderPane(group)}</>)}
463
525
  </main>
464
- <div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div>{contextGraphActive ? <section class="weave-context-graph-section" aria-label="Note connections"><div class="weave-context-graph-heading"><strong>Note graph</strong><button type="button" aria-label="Hide note graph" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle note graph"]')?.focus(); setContextGraph(false); }}>×</button></div><div class="weave-context-graph-slot" ref={contextGraphSlot} style={{ height: layout.contextGraphHeight }}>{documentId === null ? <p>Select a note to see its connections.</p> : null}</div><ResizeHandle label="Resize note graph" horizontal min={120} max={800} value={graphBox.height} onChange={(delta) => setLayout((value) => ({ ...value, contextGraphHeight: Math.max(120, Math.min(800, (contextGraphSlot.current?.clientHeight ?? value.contextGraphHeight) + delta)) }))} /></section> : null}<ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} tagHeight={layout.contextTagsHeight} onTagResize={(contextTagsHeight) => setLayout((value) => ({ ...value, contextTagsHeight }))} /></aside></div>
526
+ <div class={`weave-sidebar-dock weave-context-dock${contextFloating ? " weave-context-float" : ""}`} data-open={contextOpen} inert={!contextOpen} aria-hidden={!contextOpen} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading weave-context-heading"><strong>Context</strong><span class="weave-context-count">{contextTotal}</span>
527
+ <button type="button" class="weave-context-pin" aria-pressed={layout.contextPinned} aria-label={layout.contextPinned ? "Float context over the page" : "Dock context as a column"} title={layout.contextPinned ? "Float over the page" : "Dock as a column"} onClick={() => setLayout({ ...layout, contextPinned: !layout.contextPinned })}><Icon name="pin" class="weave-icon" /></button>
528
+ <button type="button" aria-label="Hide context sidebar" title="Fold (])" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>{contextFloating ? <Icon name="chevron" class="weave-icon weave-icon-open" /> : "»"}</button></div>{contextGraphActive ? <section class="weave-context-graph-section" aria-label="Note connections"><div class="weave-context-graph-heading"><strong>Note graph</strong><button type="button" aria-label="Hide note graph" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle note graph"]')?.focus(); setContextGraph(false); }}>×</button></div><div class="weave-context-graph-slot" ref={contextGraphSlot} style={{ height: layout.contextGraphHeight }}>{documentId === null ? <p>Select a note to see its connections.</p> : null}</div><ResizeHandle label="Resize note graph" horizontal min={120} max={800} value={graphBox.height} onChange={(delta) => setLayout((value) => ({ ...value, contextGraphHeight: Math.max(120, Math.min(800, (contextGraphSlot.current?.clientHeight ?? value.contextGraphHeight) + delta)) }))} /></section> : null}<ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} trail={turns} trailTurn={replay?.turn ?? null} onReplay={replayTurn} tagHeight={layout.contextTagsHeight} onTagResize={(contextTagsHeight) => setLayout((value) => ({ ...value, contextTagsHeight }))} /></aside></div>
529
+ {contextFloating && !contextVisible && !contextIdle ? <button type="button" class="weave-context-pill" aria-label={`Show context, ${contextTotal} items`} title="Show context (])" onClick={showContext}><Icon name="context" class="weave-icon" />Context<span class="weave-context-count">{contextTotal}</span></button> : null}
465
530
  {hasGraph || contextGraph ? <div class={`weave-graph-host${contextGraphActive ? " weave-context-graph-host" : ""}`}
466
531
  onDragOver={(event) => { if (!contextGraphActive && graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (!contextGraphActive && graphPane) tabDrop(graphPane.id, event); }}
467
532
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
@@ -469,15 +534,16 @@ export function Shell(props: ShellProps) {
469
534
  onFocusCapture={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
470
535
  aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
471
536
  style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
472
- <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} />
537
+ <Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} trail={glows}
538
+ {...(contextGraphActive ? {} : { query: graphQuery, ...(graphFound?.query === graphQuery ? { found: graphFound.ids } : {}), onQuery: setGraphQuery, onOpenSplit: openGraphNodeInSplit })} now={now} />
473
539
  </div> : null}
474
540
  </div>
475
- <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId)} />
541
+ <div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId, searchShortcut(looksApple(props.platform)))} />
476
542
  {persistError ? <span role="status">Workspace restoration unavailable</span> : null}
477
543
  {drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
478
544
  {width < 850 && layout.panes.length > 1 ? <button type="button" onClick={() => setLayout({ ...layout, activePane: layout.panes.find((group) => group.id !== pane.id)!.id })}>Switch pane</button> : null}
479
545
  </div>
480
- {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
546
+ {overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onLightUp={lightUp} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
481
547
  {overlay === "settings" ? <Settings compact={width <= 650} layout={layout} info={info} cwd={props.cwd} shortcut={searchShortcut(looksApple(props.platform))} onChange={(next) => { change(next); setCompactContext(next.contextVisible); }} onRefresh={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }} onClose={() => setOverlay(null)} /> : null}
482
548
  {overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
483
549
  </>;
@@ -16,6 +16,7 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
16
16
  <span class="weave-status-sel" title="Current item">
17
17
  {model.selection}
18
18
  </span>
19
+ {model.hint === "" ? null : <span class="weave-status-hint">{model.hint}</span>}
19
20
  </footer>
20
21
  );
21
22
  }
@@ -43,7 +43,30 @@ export type IconName =
43
43
  | "entryPoint"
44
44
  | "gitState"
45
45
  | "external"
46
- | "file";
46
+ | "file"
47
+ // Workspace chrome (the ribbon, tabs, the edit bar and the Context card).
48
+ | "sidebar"
49
+ | "graph"
50
+ | "noteGraph"
51
+ | "search"
52
+ | "context"
53
+ | "refresh"
54
+ | "settings"
55
+ | "sun"
56
+ | "moon"
57
+ | "system"
58
+ | "history"
59
+ | "edit"
60
+ | "check"
61
+ | "heading"
62
+ | "bold"
63
+ | "italic"
64
+ | "link"
65
+ | "wikilink"
66
+ | "task"
67
+ | "pin"
68
+ | "close"
69
+ | "folder";
47
70
 
48
71
  /** The box every path is drawn in, and the stroke the sheet's aesthetic asks for. */
49
72
  export const ICON_BOX = 16;
@@ -95,4 +118,48 @@ export const ICONS: Readonly<Record<IconName, IconDef>> = {
95
118
  external: { d: ["M4.5 11.5l7-7", "M6.75 4.5h4.75v4.75"], filled: false },
96
119
  /** A plain repo file, an unfolded page. */
97
120
  file: { d: ["M4.5 2.75h7v10.5h-7Z"], filled: false },
121
+ /** A window with its left panel ruled off: the notes sidebar. */
122
+ sidebar: { d: ["M2.75 3.25h10.5v9.5H2.75Z", "M6.25 3.25v9.5"], filled: false },
123
+ /** Three linked nodes: the graph view. */
124
+ graph: { d: ["M5.5 4a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M13.5 5a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M8.5 12a1.5 1.5 0 1 1-3 0a1.5 1.5 0 1 1 3 0Z", "M5.5 4.3 10.5 4.8M4.6 5.4l1.9 5.2M11.3 6.3 8.3 10.8"], filled: false },
125
+ /** A framed node: the current note's own graph. */
126
+ noteGraph: { d: ["M3.25 2.75h9.5v10.5h-9.5Z", "M10.5 8a2.5 2.5 0 1 1-5 0a2.5 2.5 0 1 1 5 0Z"], filled: false },
127
+ /** A lens. */
128
+ search: { d: ["M11 7a4 4 0 1 1-8 0a4 4 0 1 1 8 0Z", "m10 10 3.5 3.5"], filled: false },
129
+ /** A window with its right panel ruled off: Context. */
130
+ context: { d: ["M2.75 3.25h10.5v9.5H2.75Z", "M9.75 3.25v9.5"], filled: false },
131
+ /** An open loop with its arrowhead. */
132
+ refresh: { d: ["M13 8a5 5 0 1 1-1.5-3.55", "M13 2.75V5.5h-2.75"], filled: false },
133
+ /** A hub with eight spokes. */
134
+ settings: { d: ["M10 8a2 2 0 1 1-4 0a2 2 0 1 1 4 0Z", "M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"], filled: false },
135
+ /** A light theme. */
136
+ sun: { d: ["M10.5 8a2.5 2.5 0 1 1-5 0a2.5 2.5 0 1 1 5 0Z", "M8 1.75V3M8 13v1.25M1.75 8H3M13 8h1.25M3.6 3.6l.9.9M11.5 11.5l.9.9M3.6 12.4l.9-.9M11.5 4.5l.9-.9"], filled: false },
137
+ /** A dark theme. */
138
+ moon: { d: ["M13 9.75A5.25 5.25 0 0 1 6.25 3a5.25 5.25 0 1 0 6.75 6.75Z"], filled: false },
139
+ /** Follow the system: a circle split down the middle. */
140
+ system: { d: ["M13.25 8a5.25 5.25 0 1 1-10.5 0a5.25 5.25 0 1 1 10.5 0Z", "M8 2.75v10.5"], filled: false },
141
+ /** A clock face: the note's history. */
142
+ history: { d: ["M13.25 8a5.25 5.25 0 1 1-10.5 0a5.25 5.25 0 1 1 10.5 0Z", "M8 5v3l2 1.5"], filled: false },
143
+ /** A pencil. */
144
+ edit: { d: ["M10.75 2.75 13.25 5.25 6 12.5H3.5V10Z", "M9.25 4.25l2.5 2.5"], filled: false },
145
+ /** A tick. */
146
+ check: { d: ["m3.25 8.5 3 3 6.5-6.75"], filled: false },
147
+ /** A heading mark. */
148
+ heading: { d: ["M4 3v10M12 3v10M4 8h8"], filled: false },
149
+ /** Bold. */
150
+ bold: { d: ["M4.5 3h4a2.5 2.5 0 0 1 0 5h-4Z", "M4.5 8h4.75a2.5 2.5 0 0 1 0 5H4.5Z"], filled: false },
151
+ /** Italic. */
152
+ italic: { d: ["M7 3h5M4 13h5M10 3 6 13"], filled: false },
153
+ /** Two chain links. */
154
+ link: { d: ["M6.75 9.25l2.5-2.5", "M7.25 4.75 8.5 3.5a2.5 2.5 0 0 1 3.5 3.5l-1.25 1.25", "M8.75 11.25 7.5 12.5a2.5 2.5 0 0 1-3.5-3.5l1.25-1.25"], filled: false },
155
+ /** Square brackets: a [[wikilink]]. */
156
+ wikilink: { d: ["M5.5 3H3.5v10h2", "M10.5 3h2v10h-2"], filled: false },
157
+ /** A ticked box: a task line. */
158
+ task: { d: ["M3 3h10v10H3Z", "m5.5 8 1.75 1.75L10.75 6.25"], filled: false },
159
+ /** A pushpin: dock the Context card. */
160
+ pin: { d: ["M6 2.75h4l-.5 4 2 2v1h-7v-1l2-2Z", "M8 9.75v3.5"], filled: false },
161
+ /** A cross. */
162
+ close: { d: ["m4.25 4.25 7.5 7.5M11.75 4.25l-7.5 7.5"], filled: false },
163
+ /** A folder tab, for the crumb above a note's title. */
164
+ folder: { d: ["M2.75 4.25h3.5l1.25 1.5h5.75v6.5H2.75Z"], filled: false },
98
165
  };
@@ -41,7 +41,7 @@
41
41
  * `⌘K`, which would otherwise re-open the palette on top of itself.
42
42
  */
43
43
 
44
- import { COLUMNS, type ColumnId, type OverlayId } from "./shell.model";
44
+ import type { ColumnId, OverlayId } from "./shell.model";
45
45
 
46
46
  // --- the event, without the DOM ---------------------------------------------------
47
47
 
@@ -62,6 +62,12 @@ export interface KeyDescriptor {
62
62
  readonly alt: boolean;
63
63
  /** Focus is in an `<input>`, `<textarea>`, `<select>` or contenteditable. */
64
64
  readonly typing: boolean;
65
+ /**
66
+ * Focus is in the tree or the graph — the columns that *make* a selection,
67
+ * and so the only places `Esc` may clear one. Read from a note, the same
68
+ * key used to close the note you were reading.
69
+ */
70
+ readonly navigating: boolean;
65
71
  }
66
72
 
67
73
  /** What the shell knows that changes what a key means. */
@@ -93,7 +99,9 @@ export type ShellAction =
93
99
  | { readonly type: "clearSelection" }
94
100
  | { readonly type: "closeTab" }
95
101
  /** `t` — switch between the selected light and dark themes. */
96
- | { readonly type: "toggleTheme" };
102
+ | { readonly type: "toggleTheme" }
103
+ /** `]` — fold or unfold the Context card. */
104
+ | { readonly type: "toggleContext" };
97
105
 
98
106
  /**
99
107
  * The command-key letters, as data.
@@ -149,6 +157,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
149
157
  g: { type: "fitGraph" },
150
158
  "?": { type: "openHelp" },
151
159
  t: { type: "toggleTheme" },
160
+ "]": { type: "toggleContext" },
152
161
  };
153
162
 
154
163
  /**
@@ -172,11 +181,12 @@ export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | n
172
181
  return column === undefined ? null : { type: "focusColumn", column };
173
182
  }
174
183
 
175
- // Only claimed when there is a selection to clear. With nothing selected,
176
- // `Esc` belongs to whatever else the browser or a future dialog wants it
177
- // for, and swallowing it silently would be the kind of key theft this
178
- // module exists to avoid.
179
- if (event.key === "Escape") return ctx.hasSelection ? { type: "clearSelection" } : null;
184
+ // Only claimed when there is a selection to clear, and only from the tree
185
+ // or the graph. With nothing selected, `Esc` belongs to whatever else the
186
+ // browser or a future dialog wants it for, and swallowing it silently would
187
+ // be the kind of key theft this module exists to avoid. From the note
188
+ // column it would close the note being read, which nobody means by it.
189
+ if (event.key === "Escape") return ctx.hasSelection && event.navigating ? { type: "clearSelection" } : null;
180
190
 
181
191
  if (!isBare(event) || event.typing) return null;
182
192
  return BARE_KEYS[event.key] ?? null;
@@ -216,8 +226,13 @@ export function isTextEntry(tagName: string | null, contentEditable: boolean): b
216
226
  export interface KeyTarget {
217
227
  readonly tagName?: string | undefined;
218
228
  readonly isContentEditable?: boolean | undefined;
229
+ /** `Element.closest`; absent on `window` and `document`. */
230
+ closest?(selector: string): unknown;
219
231
  }
220
232
 
233
+ /** The tree and graph columns, where {@link KeyDescriptor.navigating} holds. */
234
+ export const NAVIGATING_SELECTOR = ".weave-sidebar-notes, .weave-graph-host";
235
+
221
236
  /**
222
237
  * The slice of `KeyboardEvent` the subscription reads.
223
238
  *
@@ -246,6 +261,7 @@ export function describeKey(event: KeyboardEventLike): KeyDescriptor {
246
261
  shift: event.shiftKey,
247
262
  alt: event.altKey,
248
263
  typing: isTextEntry(event.target?.tagName ?? null, event.target?.isContentEditable === true),
264
+ navigating: (event.target?.closest?.(NAVIGATING_SELECTOR) ?? null) !== null,
249
265
  };
250
266
  }
251
267
 
@@ -324,6 +340,8 @@ export interface ShellEffects {
324
340
  closeTab(): void;
325
341
  /** `t` — switch between the user's selected light and dark themes. */
326
342
  toggleTheme(): void;
343
+ /** `]` — fold or unfold the Context card (or column). */
344
+ toggleContext(): void;
327
345
  }
328
346
 
329
347
  /** Perform an action. Total over {@link ShellAction}. */
@@ -350,6 +368,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
350
368
  return fx.closeTab();
351
369
  case "toggleTheme":
352
370
  return fx.toggleTheme();
371
+ case "toggleContext":
372
+ return fx.toggleContext();
353
373
  }
354
374
  }
355
375
 
@@ -395,7 +415,8 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
395
415
  })),
396
416
  { combo: "?", what: "This help" },
397
417
  { combo: "t", what: "Switch between selected light and dark themes" },
398
- { combo: "Esc", what: "Clear the selection, or close an overlay" },
418
+ { combo: "]", what: "Fold or unfold Context" },
419
+ { combo: "Esc", what: "Clear the selection from the tree or graph, or close an overlay" },
399
420
  ],
400
421
  },
401
422
  {
@@ -428,6 +449,8 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
428
449
  { combo: "↑ / ↓", what: "Move through results" },
429
450
  { combo: "↵", what: "Open the highlighted result" },
430
451
  { combo: `${cmd}↵`, what: "Open the highlighted result in a new tab" },
452
+ { combo: "Tab / ⇧Tab", what: "Switch between All, Notes, Code and Tags" },
453
+ { combo: "⇧↵", what: "Light up the matches in the graph" },
431
454
  { combo: "Esc", what: "Close the palette" },
432
455
  ],
433
456
  },
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import type { GraphPayload, WireNodeKind } from "../../shared/wire";
18
+ import { activePane, moveTab, openDocument } from "../../shared/workspace";
19
+ import type { WorkspaceLayout } from "../../shared/workspace";
18
20
  import { kindIcon } from "../tree/tree.model";
19
21
  import type { IconName } from "./icons.model";
20
22
  export const NOTE_DRAG_TYPE = "application/x-weave-note";
@@ -37,6 +39,39 @@ export function graphClickOpensTab(previewId: string | null, id: string | null):
37
39
  return id !== null && id === previewId;
38
40
  }
39
41
 
42
+ /**
43
+ * Open `id` beside the graph — the graph sheet's "Open in split".
44
+ *
45
+ * With two panes, the note opens as a new tab in the pane that is *not* the
46
+ * graph's. With one, it opens in a new second pane and the graph's pane keeps
47
+ * the graph on screen: the tab is opened in the graph's pane and then moved,
48
+ * which is the same `moveTab` the tab strip's "Split right" uses, so the new
49
+ * pane is an ordinary one. The split direction is left as the layout has it.
50
+ *
51
+ * The note is shown in the split either way, which is what the button asks
52
+ * for; only which pane is active follows the user's `focusNewTabs`
53
+ * preference. Unchanged when the tab budget is spent.
54
+ */
55
+ export function openInSplit(layout: WorkspaceLayout, id: string, graphPaneId: string | undefined): WorkspaceLayout {
56
+ const graph = layout.panes.find((pane) => pane.id === graphPaneId) ?? activePane(layout);
57
+ const other = layout.panes.find((pane) => pane.id !== graph.id);
58
+ const opened = openDocument(layout, id, { newTab: true, paneId: other?.id ?? graph.id });
59
+ if (opened === layout) return layout;
60
+ let next = opened;
61
+ if (other === undefined) {
62
+ // One pane, so it is the graph's, and the new tab is its last.
63
+ const { lastDocumentTab: _opened, ...pane } = opened.panes[0]!;
64
+ const tab = pane.tabs.at(-1)!;
65
+ // Put the graph back in front of its pane before the new tab leaves it, so
66
+ // `moveTab` keeps the graph active there instead of the tab beside it.
67
+ const restored: WorkspaceLayout = { ...opened, panes: [
68
+ { ...pane, activeTab: graph.activeTab, ...(graph.lastDocumentTab === undefined ? {} : { lastDocumentTab: graph.lastDocumentTab }) },
69
+ ] };
70
+ next = moveTab(restored, graph.id, tab.id);
71
+ }
72
+ return layout.preferences.focusNewTabs ? next : { ...next, activePane: graph.id };
73
+ }
74
+
40
75
  export interface RecentEntry {
41
76
  readonly id: string;
42
77
  readonly label: string;
@@ -78,7 +113,10 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
78
113
  */
79
114
  export interface StatusBarModel {
80
115
  readonly cwd: string;
116
+ /** The selection as a reader names it: a note's file path, not its node id. */
81
117
  readonly selection: string;
118
+ /** The quiet hint at the far end, "⌘K search"; empty when none is given. */
119
+ readonly hint: string;
82
120
  }
83
121
 
84
122
  /** The `—` shown where a value is genuinely absent, not zero. */
@@ -88,13 +126,22 @@ export const NO_VALUE = "—";
88
126
  export function statusBarModel(
89
127
  cwd: string,
90
128
  selectedId: string | null,
129
+ shortcut = "",
91
130
  ): StatusBarModel {
92
131
  return {
93
132
  cwd: cwd === "" ? NO_VALUE : cwd,
94
- selection: selectedId ?? "nothing selected",
133
+ selection: selectionLabel(selectedId),
134
+ hint: shortcut === "" ? "" : `${shortcut} search`,
95
135
  };
96
136
  }
97
137
 
138
+ /** `note:projects/launch` reads as the file it is, `projects/launch.md`. */
139
+ function selectionLabel(selectedId: string | null): string {
140
+ if (selectedId === null) return "nothing selected";
141
+ if (selectedId.startsWith("note:") && selectedId.length > "note:".length) return `${selectedId.slice("note:".length)}.md`;
142
+ return selectedId;
143
+ }
144
+
98
145
  /**
99
146
  * How often the shell re-renders on its own, in ms.
100
147
  *
@@ -60,6 +60,8 @@ export function themeAttr(choice: ThemeChoice): PaletteChoice | null {
60
60
 
61
61
  export interface ThemeButtonView {
62
62
  readonly glyph: "●" | "◐" | "○";
63
+ /** The ribbon's icon for the same state. */
64
+ readonly icon: "system" | "sun" | "moon";
63
65
  readonly label: string;
64
66
  readonly hint: string;
65
67
  }
@@ -71,6 +73,7 @@ function themeName(choice: ThemeChoice): string {
71
73
  export function themeButton(choice: ThemeChoice, systemScheme: ColorScheme = "dark", pair: ThemePair = DEFAULT_PREFERENCES): ThemeButtonView {
72
74
  return {
73
75
  glyph: choice === "system" ? "◐" : THEMES[choice].scheme === "light" ? "○" : "●",
76
+ icon: choice === "system" ? "system" : THEMES[choice].scheme === "light" ? "sun" : "moon",
74
77
  label: themeName(choice),
75
78
  hint: `Colour theme: ${themeName(choice)} — click for ${themeName(toggleTheme(choice, systemScheme, pair))}`,
76
79
  };