pi-weave 0.2.4 → 0.3.1

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 (62) hide show
  1. package/README.md +67 -37
  2. package/package.json +12 -3
  3. package/skills/weave-explore/SKILL.md +17 -33
  4. package/skills/weave-notepad/SKILL.md +57 -120
  5. package/skills/weave-notepad/references/link-repair.md +25 -73
  6. package/src/core/frontmatter.ts +3 -3
  7. package/src/core/graph/model.ts +2 -2
  8. package/src/core/graph/wikilinks.ts +1 -1
  9. package/src/core/index.ts +25 -1
  10. package/src/core/noteAction.ts +343 -0
  11. package/src/core/repoAction.ts +84 -0
  12. package/src/core/sessions.ts +80 -1
  13. package/src/core/summaries.ts +16 -0
  14. package/src/core/types.ts +1 -1
  15. package/src/core/workspace.ts +1 -1
  16. package/src/core/workspaceCommands.ts +227 -0
  17. package/src/opencode/commands.ts +138 -0
  18. package/src/opencode/index.ts +88 -0
  19. package/src/opencode/rpc.ts +37 -0
  20. package/src/opencode/tools.ts +43 -0
  21. package/src/opencode/tui.ts +96 -0
  22. package/src/opencode/v1.ts +161 -0
  23. package/src/pi/sessionScan.ts +4 -28
  24. package/src/pi/summarize.ts +10 -25
  25. package/src/pi/tools/noteTool.ts +7 -406
  26. package/src/pi/tools/repoTool.ts +8 -75
  27. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  28. package/src/pi/viewer/web/run.ts +16 -77
  29. package/src/web/client/dist/app.js +210 -178
  30. package/src/web/client/graph/Graph.tsx +18 -0
  31. package/src/web/client/graph/column.model.ts +19 -2
  32. package/src/web/client/graph/graph.model.ts +7 -5
  33. package/src/web/client/graph/positions.ts +1 -1
  34. package/src/web/client/graph/renderer.ts +9 -1
  35. package/src/web/client/note/Note.tsx +66 -91
  36. package/src/web/client/note/drafts.ts +172 -0
  37. package/src/web/client/note/note.model.ts +2 -34
  38. package/src/web/client/search/SearchPalette.tsx +9 -8
  39. package/src/web/client/shell/ContextRail.tsx +8 -9
  40. package/src/web/client/shell/Shell.tsx +371 -254
  41. package/src/web/client/shell/StatusBar.tsx +1 -1
  42. package/src/web/client/shell/focus.model.ts +2 -2
  43. package/src/web/client/shell/keys.model.ts +26 -31
  44. package/src/web/client/shell/keys.ts +1 -0
  45. package/src/web/client/shell/shell.model.ts +31 -107
  46. package/src/web/client/shell/theme.model.ts +4 -4
  47. package/src/web/client/shell/theme.ts +12 -93
  48. package/src/web/client/shell/workspace.css.ts +117 -0
  49. package/src/web/client/tree/Tree.tsx +43 -14
  50. package/src/web/client/tree/tree.model.ts +19 -0
  51. package/src/web/client/workspace.ts +19 -1
  52. package/src/web/server/controller.ts +79 -0
  53. package/src/web/server/routes.ts +34 -3
  54. package/src/web/server/server.ts +5 -0
  55. package/src/web/server/workspace-state.ts +121 -0
  56. package/src/web/shared/workspace.ts +270 -0
  57. package/src/web/client/selection.storage.ts +0 -69
  58. package/src/web/client/shell/Columns.tsx +0 -139
  59. package/src/web/client/shell/Divider.tsx +0 -15
  60. package/src/web/client/shell/Header.tsx +0 -119
  61. package/src/web/client/shell/drag.model.ts +0 -35
  62. package/src/web/client/shell/layout.model.ts +0 -132
@@ -0,0 +1,117 @@
1
+ /** Tabbed workspace furniture; the graph keeps its existing palette and renderer. */
2
+ export const WORKSPACE_CSS = `
3
+ .weave-workbench{position:relative;display:flex;min-height:0;overflow:hidden;background:var(--weave-page)}
4
+ .weave-workbench button{font:inherit;color:inherit;cursor:pointer}
5
+ .weave-workbench button:focus-visible,.weave-workbench summary:focus-visible{outline:2px solid var(--weave-accent);outline-offset:-2px}
6
+ .weave-workbench button:disabled{opacity:.35;cursor:default}
7
+ .weave-ribbon{width:44px;flex:none;display:flex;align-items:center;flex-direction:column;gap:8px;padding:12px 0;background:var(--weave-panel);border-right:1px solid var(--weave-line)}
8
+ .weave-ribbon button{width:32px;height:32px;border:0;border-radius:var(--weave-radius);background:transparent;font-size:var(--weave-px-display);color:var(--weave-dim)}
9
+ .weave-ribbon button:hover,.weave-ribbon button[aria-pressed=true]{color:var(--weave-accent);background:var(--weave-new)}
10
+ .weave-ribbon-space{flex:1}
11
+ .weave-sidebar{flex:none;min-width:0;display:flex;flex-direction:column;background:var(--weave-panel);overflow:hidden}
12
+ .weave-sidebar-dock{display:flex;flex:none;min-width:0;width:calc(var(--weave-sidebar-width) + 4px);overflow:hidden;opacity:1;visibility:visible;transition:width 200ms ease,opacity 160ms ease,visibility 0s}
13
+ .weave-sidebar-dock[data-open=false]{width:0;opacity:0;visibility:hidden;transition:width 200ms ease,opacity 160ms ease,visibility 0s 200ms}
14
+ .weave-sidebar-dock>.weave-sidebar{width:var(--weave-sidebar-width);transition:transform 200ms ease}
15
+ .weave-sidebar-dock[data-open=false]>.weave-sidebar{transform:translateX(-16px)}
16
+ .weave-context-dock[data-open=false]>.weave-sidebar{transform:translateX(16px)}
17
+ .weave-sidebar-heading{display:flex;align-items:center;gap:8px;height:46px;flex:none;padding:0 12px;color:var(--weave-dim);font-size:var(--weave-px-row)}
18
+ .weave-sidebar-heading button{border:0;background:none;padding:7px 6px;border-radius:var(--weave-radius);color:var(--weave-dim)}
19
+ .weave-sidebar-heading button[aria-pressed=true]{color:var(--weave-fg);background:var(--weave-raise)}
20
+ .weave-sidebar-heading button:last-child{margin-left:auto}
21
+ .weave-sidebar .weave-tree-controls{padding:4px 12px 10px;gap:5px;flex-wrap:wrap;border:0}
22
+ .weave-sidebar .weave-filter{height:29px;flex-basis:100%;background:var(--weave-bg);border-radius:var(--weave-radius)}
23
+ .weave-sidebar .weave-row{min-height:32px;border-radius:var(--weave-radius);font-size:var(--weave-px-base)}
24
+ .weave-sidebar [role=tree]{padding:0 8px}
25
+ .weave-sidebar .weave-meta{font-size:var(--weave-px-caption);opacity:.65}
26
+ .weave-sidebar .weave-tree-count{border:0;padding:8px 14px}
27
+ .weave-vault-label{margin-top:auto;padding:14px 16px;border-top:1px solid var(--weave-line);display:flex;flex-direction:column;gap:4px;overflow:hidden}
28
+ .weave-vault-label span{font-size:var(--weave-px-caption);color:var(--weave-faint);text-transform:uppercase;letter-spacing:.08em}
29
+ .weave-vault-label strong{font-size:var(--weave-px-row);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
30
+ .weave-recents{display:flex;flex-direction:column;gap:3px;padding:6px 10px;overflow:auto}
31
+ .weave-recents button{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:none;border:0;padding:9px;border-radius:var(--weave-radius);overflow:hidden}
32
+ .weave-recents button:hover{background:var(--weave-new)}
33
+ .weave-recents button[aria-current=page]{background:var(--weave-raise);color:var(--weave-fg)}
34
+ .weave-recents .weave-recent-label{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
35
+ .weave-workspace-divider{flex:none;width:4px;background:var(--weave-panel);cursor:col-resize;touch-action:none;z-index:3}
36
+ .weave-workspace-divider:hover,.weave-workspace-divider:focus-visible{background:var(--weave-accent)}
37
+ .weave-workspace-divider[aria-orientation=horizontal]{width:auto;height:4px;cursor:row-resize}
38
+ .weave-panes{display:grid;flex:1;min-width:0;min-height:0;overflow:hidden}
39
+ .weave-pane{min-width:0;min-height:0;display:grid;grid-template-rows:42px 36px minmax(0,1fr);overflow:hidden;background:var(--weave-page)}
40
+ .weave-pane[hidden]{display:none}
41
+ .weave-pane-drop{position:relative}
42
+ .weave-pane-drop::after{content:attr(data-drop-label);position:absolute;inset:4px;z-index:20;display:grid;place-items:center;pointer-events:none;border:2px dashed var(--weave-accent);border-radius:var(--weave-radius);color:var(--weave-accent);background:var(--weave-new)}
43
+ .weave-tabs{display:flex;align-items:stretch;min-width:0;overflow-x:auto;background:var(--weave-panel);gap:3px;padding:5px 6px 0;border-bottom:1px solid var(--weave-line);scrollbar-width:thin}
44
+ .weave-tab{display:flex;align-items:center;min-width:90px;max-width:210px;border-radius:var(--weave-radius) var(--weave-radius) 0 0;color:var(--weave-dim);flex-shrink:0;border-bottom:2px solid transparent}
45
+ .weave-tab-active{background:var(--weave-page);color:var(--weave-fg)}
46
+ .weave-pane-active .weave-tab-active{border-bottom-color:var(--weave-accent)}
47
+ .weave-tab button{border:0;background:none}
48
+ .weave-tab [role=tab]{display:flex;align-items:center;gap:8px;text-align:left;padding:8px 9px;min-width:0;height:100%;font-size:var(--weave-px-row)}
49
+ .weave-tab [role=tab]>span:nth-child(2){white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
50
+ .weave-tab [role=tab]>span:first-child{color:var(--weave-faint)}
51
+ .weave-tab .weave-tab-close{margin-right:4px;padding:2px 5px;font-size:var(--weave-px-subhead);color:var(--weave-faint)}
52
+ .weave-tab-close:hover{color:var(--weave-fg);background:var(--weave-raise)}
53
+ .weave-dirty{font-size:var(--weave-px-prov);color:var(--weave-accent)}
54
+ .weave-new-tab{border:0;background:none;padding:4px 10px;font-size:var(--weave-px-display)!important;color:var(--weave-dim)!important}
55
+ .weave-pane-toolbar{display:flex;align-items:center;gap:5px;padding:0 10px;border-bottom:1px solid var(--weave-line);color:var(--weave-dim);font-size:var(--weave-px-row)}
56
+ .weave-pane-toolbar>button{width:26px;height:26px;border:0;background:none;border-radius:var(--weave-radius)}
57
+ .weave-pane-toolbar>button:hover{background:var(--weave-new)}
58
+ .weave-breadcrumb{flex:1;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;padding:0 8px;font-size:var(--weave-px-ui)}
59
+ .weave-pane-menu{position:relative}
60
+ .weave-pane-menu>summary{list-style:none;cursor:pointer;padding:5px 10px;font-size:var(--weave-px-subhead)}
61
+ .weave-pane-menu>summary::-webkit-details-marker{display:none}
62
+ .weave-pane-menu>div{position:absolute;right:0;top:100%;z-index:12;width:185px;padding:5px;display:flex;flex-direction:column;background:var(--weave-panel);border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius)}
63
+ .weave-pane-menu button{text-align:left;border:0;background:none;padding:9px 10px;font-size:var(--weave-px-row)}
64
+ .weave-pane-menu button:hover{background:var(--weave-new)}
65
+ .weave-pane-content{min-width:0;min-height:0;display:flex;overflow:hidden;position:relative}
66
+ .weave-document{display:flex;min-width:0;min-height:0;flex:1}
67
+ .weave-document .weave-note{width:100%;border-left:0}
68
+ .weave-document .weave-note-head{position:static;padding:28px max(24px,calc((100% - 760px)/2)) 18px;border:0}
69
+ .weave-document .weave-note-title{font-size:var(--weave-px-display);letter-spacing:-.03em;margin-bottom:12px}
70
+ .weave-document .weave-note-body{padding:10px max(24px,calc((100% - 760px)/2)) 64px;font-size:var(--weave-px-subhead);line-height:1.8}
71
+ .weave-document .weave-note-body h1{font-size:var(--weave-px-display);line-height:1.4;margin-bottom:18px}
72
+ .weave-document .weave-note-body h2{font-size:var(--weave-px-title);margin-top:28px}
73
+ .weave-document .weave-note-editor{padding:14px 28px;min-height:220px;flex:1}
74
+ .weave-document .weave-note-tags{margin-top:12px}
75
+ .weave-document .weave-note-edit,.weave-document .weave-note-save{padding:5px 10px;border-radius:var(--weave-radius)}
76
+ .weave-welcome{align-self:center;margin:auto;padding:35px;max-width:560px;color:var(--weave-dim);line-height:1.7}
77
+ .weave-welcome-mark{font-size:var(--weave-px-display);color:var(--weave-accent)}
78
+ .weave-welcome h1{font-size:var(--weave-px-display);line-height:1.4;letter-spacing:-.025em;color:var(--weave-fg);font-weight:550}
79
+ .weave-welcome p{font-size:var(--weave-px-base);margin:14px 0 24px}
80
+ .weave-welcome>div{display:flex;gap:12px;flex-wrap:wrap}
81
+ .weave-welcome button{border:1px solid var(--weave-line-strong);background:var(--weave-panel);padding:9px 13px;border-radius:var(--weave-radius);font-size:var(--weave-px-row)}
82
+ .weave-welcome button:hover{border-color:var(--weave-accent)}
83
+ .weave-welcome kbd{color:var(--weave-faint);padding-left:16px}
84
+ .weave-sidebar-context .weave-rail{flex:1;border:0;overflow:auto;padding:6px 12px}
85
+ .weave-sidebar-context .weave-ctx-link{padding-top:7px;padding-bottom:7px}
86
+ .weave-graph-host{position:absolute;z-index:2;display:flex;background:var(--weave-bg)}
87
+ .weave-graph-host>.weave-graph{width:100%;height:100%;flex:1}
88
+ .weave-graph-preview{pointer-events:none;position:absolute;z-index:2;right:8px;bottom:64px;width:min(280px,calc(100% - 16px));box-sizing:border-box;display:flex;flex-direction:column;gap:8px;padding:10px 34px 10px 10px;background:var(--weave-panel);border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);box-shadow:0 6px 22px #0003}
89
+ .weave-graph-preview-info{display:flex;align-items:center;gap:8px;min-width:0}
90
+ .weave-graph-preview-copy{display:flex;flex-direction:column;min-width:0;gap:2px}
91
+ .weave-graph-preview-kind{font-size:var(--weave-px-caption);color:var(--weave-faint);text-transform:capitalize}
92
+ .weave-graph-preview-title{font-size:var(--weave-px-row);font-weight:550;line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}
93
+ .weave-graph-preview-close{pointer-events:auto;position:absolute;right:5px;top:5px;width:24px;height:24px;border:0;border-radius:var(--weave-radius);background:transparent;font-size:18px;color:var(--weave-dim)}
94
+ .weave-graph-preview-close:hover{background:var(--weave-new);color:var(--weave-fg)}
95
+ .weave-graph-preview>.weave-chip{pointer-events:auto;align-self:flex-start;max-width:100%;white-space:normal;text-align:left}
96
+ .weave-footer{display:flex;align-items:center;min-width:0;background:var(--weave-panel);border-top:1px solid var(--weave-line);font-size:var(--weave-px-ui);color:var(--weave-faint)}
97
+ .weave-footer>.weave-status{flex:1;min-width:0;border:0}
98
+ .weave-footer>span{padding:4px 10px;white-space:nowrap}
99
+ .weave-footer>button{font:inherit;color:var(--weave-fg);background:none;border:0;padding:7px 12px;cursor:pointer}
100
+ @media(max-width:1049px){
101
+ .weave-context-dock{position:absolute;right:0;top:0;bottom:0;z-index:10;width:var(--weave-sidebar-width)}
102
+ .weave-sidebar-context{border-left:1px solid var(--weave-line-strong)}
103
+ .weave-context-dock>.weave-workspace-divider{display:none}
104
+ }
105
+ @media(max-width:849px){
106
+ .weave-notes-dock{position:absolute;left:44px;top:0;bottom:0;z-index:10;width:var(--weave-sidebar-width);box-shadow:8px 0 24px #0002}
107
+ .weave-sidebar-notes{border-right:1px solid var(--weave-line-strong)}
108
+ .weave-notes-dock>.weave-workspace-divider{display:none}
109
+ .weave-workbench>.weave-workspace-divider{display:none}
110
+ .weave-panes{grid-template-columns:minmax(0,1fr)!important;grid-template-rows:minmax(0,1fr)!important}
111
+ .weave-document .weave-note-head{padding:20px}
112
+ .weave-document .weave-note-body{padding:10px 20px 40px}
113
+ }
114
+ @media(prefers-reduced-motion:reduce){
115
+ .weave-sidebar-dock,.weave-sidebar-dock[data-open=false],.weave-sidebar-dock>.weave-sidebar{transition:none}
116
+ }
117
+ `;
@@ -1,5 +1,6 @@
1
1
  /** The vault and repository tree column. */
2
2
 
3
+ import { NOTE_DRAG_TYPE } from "../shell/shell.model";
3
4
  import { useLayoutEffect, useRef, useState } from "preact/hooks";
4
5
  import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
5
6
  import { fetchJson } from "../api.dom";
@@ -20,7 +21,6 @@ import {
20
21
  deleteNeedsConfirmation,
21
22
  depthVar,
22
23
  dropFolder,
23
- expand,
24
24
  initialTreeView,
25
25
  newFolderParent,
26
26
  newFolderPath,
@@ -33,6 +33,8 @@ import {
33
33
  rowCountLabel,
34
34
  rowViews,
35
35
  rowsFor,
36
+ revealFolder,
37
+ affectedNoteSlugs,
36
38
  setQuery,
37
39
  toggleExpanded,
38
40
  toggleInternals,
@@ -44,12 +46,12 @@ export interface TreeProps {
44
46
  graph: GraphPayload | null;
45
47
  selectedId: string | null;
46
48
  recentIds: ReadonlySet<string>;
47
- onSelect: (id: string) => void;
49
+ onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
48
50
  /**
49
51
  * Ask before a mutation that would invalidate an open draft. `false` means
50
52
  * keep editing: the mutation must not run.
51
53
  */
52
- onMutate: () => boolean;
54
+ onMutate: (slugs: readonly string[]) => boolean;
53
55
  onRefresh: () => void;
54
56
  now: number;
55
57
  }
@@ -74,7 +76,7 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
74
76
  );
75
77
  }
76
78
 
77
- function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: () => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
79
+ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
78
80
  const folder = dropFolder(view.id);
79
81
  return (
80
82
  <li
@@ -88,10 +90,10 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
88
90
  aria-selected={view.selected}
89
91
  aria-expanded={view.hasKids ? view.expanded : undefined}
90
92
  style={depthVar(view.depth)}
91
- onClick={onSelect}
93
+ onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}
92
94
  draggable={view.id.startsWith("note:")}
93
95
  aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
94
- onDragStart={(event) => event.dataTransfer?.setData("text/plain", view.id)}
96
+ onDragStart={(event) => { event.dataTransfer?.setData("text/plain", view.id); event.dataTransfer?.setData(NOTE_DRAG_TYPE, view.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; }}
95
97
  onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
96
98
  onDrop={folder === undefined ? undefined : (event) => { event.preventDefault(); onDrop(event.dataTransfer?.getData("text/plain") ?? ""); }}
97
99
  onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); onMenu(event.clientX, event.clientY); }}
@@ -161,6 +163,14 @@ export function Tree(props: TreeProps) {
161
163
  * what makes a mis-click cheap.
162
164
  */
163
165
  const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number; armed?: boolean } | null>(null);
166
+ const root = useRef<HTMLDivElement | null>(null);
167
+ useLayoutEffect(() => {
168
+ if (menu !== null) root.current?.querySelector<HTMLButtonElement>(".weave-menu button")?.focus();
169
+ }, [menu?.id]);
170
+ const closeMenu = (): void => {
171
+ setMenu(null);
172
+ root.current?.querySelector<HTMLButtonElement>(".weave-tree-actions")?.focus();
173
+ };
164
174
  const [editing, setEditing] = useState<{ id: string; label: string; value: string } | null>(null);
165
175
  /**
166
176
  * The parent path a pending "New folder" will be created under, or `null`.
@@ -190,15 +200,16 @@ export function Tree(props: TreeProps) {
190
200
  // gesture `rename` already uses, and it shows *where* the folder will
191
201
  // land, which a prompt cannot.
192
202
  const parent = newFolderParent(target);
193
- if (parent !== "") setState((current) => expand(current, `vfolder:${parent}`));
203
+ setState((current) => revealFolder(current, parent));
194
204
  setDraft(parent);
195
205
  setEditing({ id: DRAFT_FOLDER_ID, label: "", value: "" });
196
206
  } else if (action === "rename") {
207
+ setState((current) => revealFolder(current, target.path.split("/").slice(0, -1).join("/")));
197
208
  setEditing({ id: menu.id, label: menu.label, value: menu.label });
198
209
  } else if (target.type !== "vault") {
199
210
  // Before the request: asking after a delete is asking about a file that
200
211
  // is already gone, and "keep editing" would strand the draft on a 404.
201
- if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
212
+ if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
202
213
  await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
203
214
  }
204
215
  };
@@ -247,24 +258,32 @@ export function Tree(props: TreeProps) {
247
258
  // is the least useful moment to ask. Renaming back is one more rename.
248
259
  // An open draft is a different question; `onMutate` asks that one.
249
260
  if (target !== null && name && name !== current.label) {
250
- if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
261
+ if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
251
262
  void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
252
263
  }
253
264
  };
254
265
 
255
266
  return (
256
- <div class="weave-tree" onKeyDown={(event) => {
267
+ <div class="weave-tree" ref={root} onKeyDown={(event) => {
268
+ if (menu !== null && event.key === "Escape") {
269
+ event.preventDefault(); event.stopPropagation(); closeMenu(); return;
270
+ }
257
271
  const target = event.target as KeyTarget;
258
272
  const next = treeKey(rows, state, props.selectedId, event.key, isTextEntry(target?.tagName ?? null, target?.isContentEditable === true));
259
273
  if (!next.handled) return;
260
274
  event.preventDefault();
261
275
  setState(next.state);
262
- if (next.selectedId !== null) props.onSelect(next.selectedId);
276
+ if (next.selectedId !== null) props.onSelect(next.selectedId, false, true);
263
277
  }}>
264
278
  <div class="weave-tree-controls">
265
279
  <input type="search" class="weave-filter" value={state.query} placeholder={FILTER_PLACEHOLDER} aria-label={FILTER_LABEL} title={FILTER_HINT} onInput={(event) => setState(setQuery(state, event.currentTarget.value))} />
266
280
  <button type="button" class="weave-chip" title={provenanceHint(state.provFilter)} onClick={() => setState(cycleProvenance(state))}>◧ {provenanceLabel(state.provFilter)}</button>
267
281
  <button type="button" class="weave-chip" title={internalsHint(state.showInternals)} onClick={() => setState(toggleInternals(state))}>◧ {internalsLabel(state.showInternals)}</button>
282
+ <button type="button" class="weave-chip weave-tree-actions" aria-label="File actions" title="File actions for the selected item" onClick={(event) => {
283
+ const id = props.selectedId !== null && mutableTreeRow(props.selectedId) !== null ? props.selectedId : "vault";
284
+ const at = event.currentTarget.getBoundingClientRect();
285
+ setMenu({ id, label: props.graph?.model.nodes.find((node) => node.id === id)?.label ?? "Vault", x: at.left, y: at.bottom });
286
+ }}>···</button>
268
287
  </div>
269
288
  {empty === null ? (
270
289
  <ul
@@ -278,7 +297,7 @@ export function Tree(props: TreeProps) {
278
297
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
279
298
  }}
280
299
  >
281
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { if (dragged === props.selectedId && !props.onMutate()) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
300
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
282
301
  </ul>
283
302
  ) : <p class="weave-tree-empty">{empty}</p>}
284
303
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -286,8 +305,18 @@ export function Tree(props: TreeProps) {
286
305
  const target = mutableTreeRow(menu.id)!;
287
306
  return (
288
307
  <>
289
- <button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={() => setMenu(null)} />
290
- <div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }}>
308
+ <button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={closeMenu} />
309
+ <div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }} onKeyDown={(event) => {
310
+ if (event.key === "Escape") { event.preventDefault(); closeMenu(); }
311
+ else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
312
+ event.preventDefault();
313
+ const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button")];
314
+ const index = items.indexOf(document.activeElement as HTMLButtonElement);
315
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
316
+ items[next]?.focus();
317
+ }
318
+ event.stopPropagation();
319
+ }}>
291
320
  <button type="button" role="menuitem" onClick={() => void act("newFolder")}>New folder…</button>
292
321
  {target.type !== "vault" ? (
293
322
  <>
@@ -74,6 +74,17 @@ export function initialTreeView(roots: readonly string[] = ["vault", "repository
74
74
  return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
75
75
  }
76
76
 
77
+ /** Make an inline file operation visible even when its destination was filtered or collapsed. */
78
+ export function revealFolder(state: TreeViewState, path: string): TreeViewState {
79
+ const expanded = new Set(state.expanded).add("vault");
80
+ let parent = "";
81
+ for (const part of path ? path.split("/") : []) {
82
+ parent = parent ? `${parent}/${part}` : part;
83
+ expanded.add(`vfolder:${parent}`);
84
+ }
85
+ return { ...state, expanded, query: "", provFilter: null };
86
+ }
87
+
77
88
  // --- reassembling the wire payload -------------------------------------------------
78
89
 
79
90
  /**
@@ -224,6 +235,14 @@ export function mutableTreeRow(id: string): { type: "note" | "folder" | "vault";
224
235
  return null;
225
236
  }
226
237
 
238
+ /** Note slugs a note or folder mutation can invalidate. */
239
+ export function affectedNoteSlugs(graph: GraphPayload | null, target: { type: "note" | "folder" | "vault"; path: string }): string[] {
240
+ if (target.type === "note") return [target.path];
241
+ if (target.type !== "folder" || graph === null) return [];
242
+ const prefix = `note:${target.path}/`;
243
+ return graph.model.nodes.filter((node) => node.kind === "note" && node.id.startsWith(prefix)).map((node) => node.id.slice(5));
244
+ }
245
+
227
246
  export function deletesSelection(selectedId: string | null, target: { type: "note" | "folder" | "vault"; path: string }): boolean {
228
247
  if (selectedId === null) return false;
229
248
  if (target.type === "note") return selectedId === `note:${target.path}`;
@@ -1,6 +1,6 @@
1
1
  /** Fetching and polling for the browser workspace. */
2
2
 
3
- import type { GraphPayload } from "../shared/wire";
3
+ import type { GraphPayload, NotePayload } from "../shared/wire";
4
4
  import type { ApiResult, FetchLike } from "./api";
5
5
  import { fetchGraph, fetchNote } from "./api";
6
6
  import type { WorkspaceState } from "./state";
@@ -115,3 +115,21 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
115
115
  },
116
116
  };
117
117
  }
118
+
119
+ /** Per-document loading: retry failures, and drop completions after a tab leaves. */
120
+ export function watchNote(fetch: FetchLike, slug: string, notify: (result: ApiResult<NotePayload>) => void,
121
+ defer: (fn: () => void, ms: number) => () => void = (fn, ms) => {
122
+ const timer = setTimeout(fn, ms); return () => clearTimeout(timer);
123
+ }): () => void {
124
+ let stopped = false;
125
+ let cancel: (() => void) | null = null;
126
+ const load = async (): Promise<void> => {
127
+ if (stopped) return;
128
+ const result = await fetchNote(fetch, slug);
129
+ if (stopped) return;
130
+ notify(result);
131
+ if (!result.ok) cancel = defer(() => void load(), POLL_MS);
132
+ };
133
+ void load();
134
+ return () => { stopped = true; cancel?.(); };
135
+ }
@@ -0,0 +1,79 @@
1
+ import { platform as osPlatform } from "node:os";
2
+ import { resolveVaultRoot, WorkspaceCache } from "../../core";
3
+ import {
4
+ startWorkspaceServer,
5
+ type StartWorkspaceServerOptions,
6
+ type WorkspaceServer,
7
+ } from "./server";
8
+
9
+ export type StartServerFn = (opts: StartWorkspaceServerOptions) => Promise<WorkspaceServer>;
10
+
11
+ export interface WorkspaceServerSession {
12
+ server: WorkspaceServer;
13
+ cache: WorkspaceCache;
14
+ cwd: string;
15
+ }
16
+
17
+ export interface WorkspaceServerControllerDeps {
18
+ startServer?: StartServerFn;
19
+ vaultRoot?: () => string;
20
+ onStateChange?: () => void;
21
+ }
22
+
23
+ /** Owns one browser-workspace server for one plugin instance. */
24
+ export class WorkspaceServerController {
25
+ private session: WorkspaceServerSession | null = null;
26
+ private pending: Promise<void> = Promise.resolve();
27
+
28
+ constructor(private readonly deps: WorkspaceServerControllerDeps = {}) {}
29
+
30
+ port(): number | null {
31
+ return this.session?.server.port ?? null;
32
+ }
33
+
34
+ run(cwd: string): Promise<{ session: WorkspaceServerSession; started: boolean }> {
35
+ return this.enqueue(async () => {
36
+ const existing = this.session;
37
+ if (existing?.cwd === cwd) return { session: existing, started: false };
38
+ if (existing) await this.stop();
39
+ const session = await this.bootOnce(cwd);
40
+ this.session = session;
41
+ this.deps.onStateChange?.();
42
+ return { session, started: true };
43
+ });
44
+ }
45
+
46
+ close(): Promise<void> {
47
+ return this.enqueue(() => this.stop());
48
+ }
49
+
50
+ private enqueue<T>(task: () => Promise<T>): Promise<T> {
51
+ const result = this.pending.then(task);
52
+ this.pending = result.then(() => undefined, () => undefined);
53
+ return result;
54
+ }
55
+
56
+ private async stop(): Promise<void> {
57
+ const session = this.session;
58
+ if (!session) return;
59
+ this.session = null;
60
+ await session.server.close();
61
+ this.deps.onStateChange?.();
62
+ }
63
+
64
+ private async bootOnce(cwd: string): Promise<WorkspaceServerSession> {
65
+ const vaultRoot = (this.deps.vaultRoot ?? resolveVaultRoot)();
66
+ const cache = new WorkspaceCache({ cwd, vaultRoot });
67
+ const server = await (this.deps.startServer ?? startWorkspaceServer)({ cwd, vaultRoot, cache });
68
+ return { server, cache, cwd };
69
+ }
70
+ }
71
+
72
+ export function browserOpenCommand(
73
+ url: string,
74
+ os: NodeJS.Platform = osPlatform(),
75
+ ): { command: string; args: string[] } {
76
+ if (os === "darwin") return { command: "open", args: [url] };
77
+ if (os === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
78
+ return { command: "xdg-open", args: [url] };
79
+ }
@@ -43,7 +43,7 @@
43
43
  * `resolveNotePath` (via `getNote`) rejects unsafe slugs.
44
44
  */
45
45
 
46
- import { createHash } from "node:crypto";
46
+ import { createHash, randomUUID } from "node:crypto";
47
47
  import { readFile } from "node:fs/promises";
48
48
  import type { IncomingMessage, ServerResponse } from "node:http";
49
49
  import type { WorkspaceSnapshot } from "../../core/cache/workspace";
@@ -65,6 +65,8 @@ import type {
65
65
  } from "../shared/wire";
66
66
  import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
67
67
  import { renderPage } from "./page";
68
+ import { isWorkspaceViewId, type WorkspaceStateStore } from "./workspace-state";
69
+ import { parseWorkspaceLayout } from "../shared/workspace";
68
70
  import type { RequestFacts, SecurityPolicy } from "./security";
69
71
  import { requestFacts } from "./security";
70
72
 
@@ -78,6 +80,7 @@ export interface RouteDeps {
78
80
  bundlePath: string;
79
81
  /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
80
82
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
83
+ workspaceState: WorkspaceStateStore;
81
84
  }
82
85
 
83
86
  const JSON_TYPE = "application/json; charset=utf-8";
@@ -160,8 +163,9 @@ export function parseTarget(url: string): Target {
160
163
  * the caller has exactly one failure branch.
161
164
  */
162
165
  export const MAX_BODY_BYTES = 64 * 1024;
166
+ const MAX_WORKSPACE_STATE_BODY_BYTES = 256 * 1024;
163
167
 
164
- export async function readJsonBody(req: IncomingMessage): Promise<unknown | null> {
168
+ export async function readJsonBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise<unknown | null> {
165
169
  const chunks: Buffer[] = [];
166
170
  let size = 0;
167
171
  // `IncomingMessage` yields Buffers unless `setEncoding` was called, and we
@@ -169,7 +173,7 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null
169
173
  // pretending to be defensive.
170
174
  for await (const chunk of req as AsyncIterable<Buffer>) {
171
175
  size += chunk.length;
172
- if (size > MAX_BODY_BYTES) return null;
176
+ if (size > maxBytes) return null;
173
177
  chunks.push(chunk);
174
178
  }
175
179
  if (chunks.length === 0) return null;
@@ -375,6 +379,10 @@ async function route(
375
379
  if (method === "GET" && path === "/") return sendShell(deps, res);
376
380
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
377
381
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
382
+ if (method === "GET" && path === "/api/workspace-state") {
383
+ return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest() }, { "cache-control": "no-store" });
384
+ }
385
+ if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
378
386
  if (path.startsWith("/api/folder/")) {
379
387
  const handled = await routeFolder(deps, method, path.slice("/api/folder/".length), req, res);
380
388
  if (handled) return;
@@ -396,6 +404,29 @@ async function route(
396
404
 
397
405
  // --- handlers ----------------------------------------------------------------
398
406
 
407
+ async function saveWorkspaceState(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
408
+ const body = await readJsonBody(req, MAX_WORKSPACE_STATE_BODY_BYTES);
409
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
410
+ sendJson(res, 400, { error: "Expected a workspace state object" });
411
+ return;
412
+ }
413
+ const record = body as Record<string, unknown>;
414
+ if (
415
+ Object.keys(record).length !== 2 || !Object.hasOwn(record, "viewId") || !Object.hasOwn(record, "layout") ||
416
+ !isWorkspaceViewId(record.viewId)
417
+ ) {
418
+ sendJson(res, 400, { error: "Expected a UUID viewId and a valid layout" });
419
+ return;
420
+ }
421
+ const layout = parseWorkspaceLayout(record.layout);
422
+ if (layout === null) {
423
+ sendJson(res, 400, { error: "Invalid workspace layout" });
424
+ return;
425
+ }
426
+ await deps.workspaceState.write(record.viewId, layout);
427
+ sendJson(res, 200, { ok: true }, { "cache-control": "no-store" });
428
+ }
429
+
399
430
  function sendShell(deps: RouteDeps, res: ServerResponse): void {
400
431
  const page = renderPage({
401
432
  bootstrap: { cwd: deps.cwd },
@@ -19,6 +19,7 @@ import { WorkspaceCache } from "../../core/cache/workspace";
19
19
  import { resolveVaultRoot } from "../../core/paths";
20
20
  import { handleRequest, type RouteDeps } from "./routes";
21
21
  import { createSecurityPolicy, type SecurityPolicy } from "./security";
22
+ import { createWorkspaceStateStore, defaultWorkspaceStateDir } from "./workspace-state";
22
23
 
23
24
  /**
24
25
  * The committed bundle, resolved relative to this module rather than to
@@ -41,6 +42,8 @@ export interface StartWorkspaceServerOptions {
41
42
  cookieName?: string | undefined;
42
43
  /** Absolute path to the client bundle. Defaults to the committed one. */
43
44
  bundlePath?: string | undefined;
45
+ /** Presentation snapshot directory. Defaults to the user's pi-weave config directory. */
46
+ presentationStateDir?: string | undefined;
44
47
  /** Test seam for `POST /api/open`. */
45
48
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
46
49
  }
@@ -61,6 +64,7 @@ export interface WorkspaceServer {
61
64
  export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): Promise<WorkspaceServer> {
62
65
  const vaultRoot = opts.vaultRoot ?? resolveVaultRoot();
63
66
  const cache = opts.cache ?? new WorkspaceCache({ cwd: opts.cwd, vaultRoot });
67
+ const workspaceState = await createWorkspaceStateStore(opts.cwd, vaultRoot, opts.presentationStateDir ?? defaultWorkspaceStateDir());
64
68
  // `deps` is assembled before `listen` because the request handler closes
65
69
  // over it, but the security policy needs the bound port — so the policy
66
70
  // slot is filled after binding and the handler reads it through the
@@ -104,6 +108,7 @@ export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): P
104
108
  security,
105
109
  bundlePath: opts.bundlePath ?? defaultBundlePath(),
106
110
  ...(opts.openNote !== undefined ? { openNote: opts.openNote } : {}),
111
+ workspaceState,
107
112
  };
108
113
 
109
114
  return {