pi-weave 0.3.9 → 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 +5 -2
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +392 -153
- package/src/web/client/graph/Graph.tsx +143 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -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/Shell.tsx +61 -19
- 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 +11 -1
- 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 +252 -47
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -18,6 +18,7 @@ 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";
|
|
21
22
|
import { startTrailPoll } from "../trail/trail";
|
|
22
23
|
import { TRAIL_TICK_MS, trailLive, trailMarks, trailTurns } from "../trail/trail.model";
|
|
23
24
|
import type { TrailReplay } from "../trail/trail.model";
|
|
@@ -31,7 +32,7 @@ import { watchKeys } from "./keys";
|
|
|
31
32
|
import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
|
|
32
33
|
import { StatusBar } from "./StatusBar";
|
|
33
34
|
import type { OverlayId } from "./shell.model";
|
|
34
|
-
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";
|
|
35
36
|
import { toggleTheme, effectiveScheme, loadTheme, saveTheme, themeButton } from "./theme.model";
|
|
36
37
|
|
|
37
38
|
export interface ShellProps { cwd: string; initialWidth: number; platform: string }
|
|
@@ -135,6 +136,12 @@ export function Shell(props: ShellProps) {
|
|
|
135
136
|
const [graphSelection, setGraphSelection] = useState<string | null>(null);
|
|
136
137
|
const lastViewedDocument = useRef<string | null>(null);
|
|
137
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);
|
|
138
145
|
const draggedTab = useRef<string | null>(null);
|
|
139
146
|
const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
|
|
140
147
|
const [dropPane, setDropPane] = useState<string | null>(null);
|
|
@@ -162,6 +169,11 @@ export function Shell(props: ShellProps) {
|
|
|
162
169
|
const fullGraphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
|
|
163
170
|
const contextGraphActive = contextGraph && tab.kind === "document" && !fullGraphVisible;
|
|
164
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 }); };
|
|
165
177
|
const graphVisible = fullGraphVisible || contextGraphActive && contextVisible && documentId !== null;
|
|
166
178
|
const hasGraph = layout.panes.some((group) => group.tabs.some((entry) => entry.kind === "graph"));
|
|
167
179
|
const live = useRef({ layout, overlay, selectedId, graphPreviewId, select: (_id: string | null) => {} });
|
|
@@ -203,11 +215,27 @@ export function Shell(props: ShellProps) {
|
|
|
203
215
|
if (graphClickOpensTab(live.current.graphPreviewId, id)) openGraphNode(id);
|
|
204
216
|
else previewGraphNode(id);
|
|
205
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
|
+
};
|
|
206
233
|
live.current = { layout, overlay, selectedId, graphPreviewId, select };
|
|
207
234
|
const mayMutate = (slugs: readonly string[]): boolean => mayMutateDrafts(drafts, slugs, () => window.confirm("Discard unsaved changes to the affected notes before changing files?"));
|
|
208
235
|
const save: SaveNote = async (slug, body, options) => {
|
|
209
236
|
const result = await saveNote(fetchJson, slug, body, options?.raw === true);
|
|
210
|
-
|
|
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; }
|
|
211
239
|
setRevision((value) => value + 1); workspace.current?.refresh(); return true;
|
|
212
240
|
};
|
|
213
241
|
const create = async (title: string, paneId: string): Promise<boolean> => {
|
|
@@ -295,6 +323,12 @@ export function Shell(props: ShellProps) {
|
|
|
295
323
|
}, [trailGlowing]);
|
|
296
324
|
const glows = useMemo(() => trailMarks(trail, trailNow, replay), [trail, trailNow, replay]);
|
|
297
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;
|
|
298
332
|
const replayTurn = (turn: number): void => { const at = Date.now(); setTrailNow(at); setReplay({ turn, startedAt: at }); };
|
|
299
333
|
useEffect(() => {
|
|
300
334
|
const tick = window.setInterval(() => setNow(Date.now()), TICK_MS);
|
|
@@ -329,6 +363,10 @@ export function Shell(props: ShellProps) {
|
|
|
329
363
|
},
|
|
330
364
|
closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
|
|
331
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
|
+
},
|
|
332
370
|
focusSelector: (selector) => {
|
|
333
371
|
if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
|
|
334
372
|
else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
|
|
@@ -400,7 +438,9 @@ export function Shell(props: ShellProps) {
|
|
|
400
438
|
const renderPane = (group: Pane) => {
|
|
401
439
|
const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
|
|
402
440
|
const isActive = group.id === layout.activePane;
|
|
403
|
-
|
|
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}`}
|
|
404
444
|
onDragOver={(event) => tabDragOver(group.id, event)} onDrop={(event) => tabDrop(group.id, event)}
|
|
405
445
|
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
|
|
406
446
|
hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
|
|
@@ -431,7 +471,7 @@ export function Shell(props: ShellProps) {
|
|
|
431
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;
|
|
432
472
|
const next = group.tabs[index]!; setLayout(activateTab(layout, group.id, next.id));
|
|
433
473
|
requestAnimationFrame(() => document.getElementById(`tab-${next.id}`)?.focus());
|
|
434
|
-
}}><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>
|
|
435
475
|
{drafts.isDirty((tabSelection(entry) ?? "").slice(5)) ? <span class="weave-dirty" aria-label="Unsaved changes">●</span> : null}</button>
|
|
436
476
|
<button type="button" class="weave-tab-close" aria-label={`Close ${titleOf(entry, data.graph)}`} onClick={() => change(closeTab(layout, group.id, entry.id))}>×</button>
|
|
437
477
|
</div>)}
|
|
@@ -460,19 +500,17 @@ export function Shell(props: ShellProps) {
|
|
|
460
500
|
};
|
|
461
501
|
|
|
462
502
|
return <>
|
|
463
|
-
<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); }}>
|
|
464
504
|
<nav class="weave-ribbon" aria-label="Workspace tools">
|
|
465
|
-
<button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}
|
|
466
|
-
<button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}
|
|
467
|
-
<button type="button" aria-label="Toggle note graph" title="Note graph" aria-pressed={contextGraphActive && contextVisible} onClick={toggleContextGraph}><
|
|
468
|
-
<button type="button" aria-label="Search notes and repository" title={searchHint(searchShortcut(looksApple(props.platform)))} onClick={() => setOverlay("search")}>
|
|
469
|
-
<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>
|
|
470
|
-
</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>
|
|
471
509
|
<span class="weave-ribbon-space" />
|
|
472
|
-
<button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}
|
|
473
|
-
<button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}
|
|
474
|
-
<button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}
|
|
475
|
-
<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>
|
|
476
514
|
</nav>
|
|
477
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">
|
|
478
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>
|
|
@@ -485,7 +523,10 @@ export function Shell(props: ShellProps) {
|
|
|
485
523
|
setLayout((value) => ({ ...value, ratio: Math.max(.2, Math.min(.8, value.ratio + delta / (extent ?? 800))) }));
|
|
486
524
|
}} /> : null}{renderPane(group)}</>)}
|
|
487
525
|
</main>
|
|
488
|
-
<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}
|
|
489
530
|
{hasGraph || contextGraph ? <div class={`weave-graph-host${contextGraphActive ? " weave-context-graph-host" : ""}`}
|
|
490
531
|
onDragOver={(event) => { if (!contextGraphActive && graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (!contextGraphActive && graphPane) tabDrop(graphPane.id, event); }}
|
|
491
532
|
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
|
|
@@ -493,15 +534,16 @@ export function Shell(props: ShellProps) {
|
|
|
493
534
|
onFocusCapture={() => { if (!contextGraphActive && graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }}
|
|
494
535
|
aria-hidden={!graphVisible} inert={!graphVisible} role={contextGraphActive ? "region" : undefined} aria-label={contextGraphActive ? "Note graph" : undefined}
|
|
495
536
|
style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
|
|
496
|
-
<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}
|
|
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} />
|
|
497
539
|
</div> : null}
|
|
498
540
|
</div>
|
|
499
|
-
<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)))} />
|
|
500
542
|
{persistError ? <span role="status">Workspace restoration unavailable</span> : null}
|
|
501
543
|
{drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
|
|
502
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}
|
|
503
545
|
</div>
|
|
504
|
-
{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}
|
|
505
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}
|
|
506
548
|
{overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
|
|
507
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
|
};
|
|
@@ -99,7 +99,9 @@ export type ShellAction =
|
|
|
99
99
|
| { readonly type: "clearSelection" }
|
|
100
100
|
| { readonly type: "closeTab" }
|
|
101
101
|
/** `t` — switch between the selected light and dark themes. */
|
|
102
|
-
| { readonly type: "toggleTheme" }
|
|
102
|
+
| { readonly type: "toggleTheme" }
|
|
103
|
+
/** `]` — fold or unfold the Context card. */
|
|
104
|
+
| { readonly type: "toggleContext" };
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
107
|
* The command-key letters, as data.
|
|
@@ -155,6 +157,7 @@ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
|
|
|
155
157
|
g: { type: "fitGraph" },
|
|
156
158
|
"?": { type: "openHelp" },
|
|
157
159
|
t: { type: "toggleTheme" },
|
|
160
|
+
"]": { type: "toggleContext" },
|
|
158
161
|
};
|
|
159
162
|
|
|
160
163
|
/**
|
|
@@ -337,6 +340,8 @@ export interface ShellEffects {
|
|
|
337
340
|
closeTab(): void;
|
|
338
341
|
/** `t` — switch between the user's selected light and dark themes. */
|
|
339
342
|
toggleTheme(): void;
|
|
343
|
+
/** `]` — fold or unfold the Context card (or column). */
|
|
344
|
+
toggleContext(): void;
|
|
340
345
|
}
|
|
341
346
|
|
|
342
347
|
/** Perform an action. Total over {@link ShellAction}. */
|
|
@@ -363,6 +368,8 @@ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
|
|
|
363
368
|
return fx.closeTab();
|
|
364
369
|
case "toggleTheme":
|
|
365
370
|
return fx.toggleTheme();
|
|
371
|
+
case "toggleContext":
|
|
372
|
+
return fx.toggleContext();
|
|
366
373
|
}
|
|
367
374
|
}
|
|
368
375
|
|
|
@@ -408,6 +415,7 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
|
408
415
|
})),
|
|
409
416
|
{ combo: "?", what: "This help" },
|
|
410
417
|
{ combo: "t", what: "Switch between selected light and dark themes" },
|
|
418
|
+
{ combo: "]", what: "Fold or unfold Context" },
|
|
411
419
|
{ combo: "Esc", what: "Clear the selection from the tree or graph, or close an overlay" },
|
|
412
420
|
],
|
|
413
421
|
},
|
|
@@ -441,6 +449,8 @@ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
|
|
|
441
449
|
{ combo: "↑ / ↓", what: "Move through results" },
|
|
442
450
|
{ combo: "↵", what: "Open the highlighted result" },
|
|
443
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" },
|
|
444
454
|
{ combo: "Esc", what: "Close the palette" },
|
|
445
455
|
],
|
|
446
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
|
};
|