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.
- package/README.md +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- 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:
|
|
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
|
|
203
|
-
const result = await saveNote(fetchJson, slug, body);
|
|
204
|
-
|
|
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
|
-
|
|
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"
|
|
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 })}
|
|
442
|
-
<button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}
|
|
443
|
-
<button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><
|
|
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 })}
|
|
449
|
-
<button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}
|
|
450
|
-
<button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}
|
|
451
|
-
<button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}
|
|
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=
|
|
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
|
</>;
|
|
@@ -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 {
|
|
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
|
|
176
|
-
// `Esc` belongs to whatever else the
|
|
177
|
-
// for, and swallowing it silently would
|
|
178
|
-
// module exists to avoid.
|
|
179
|
-
|
|
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: "
|
|
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
|
|
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
|
};
|