@gmickel/gno 0.34.1 → 0.35.0
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 +37 -26
- package/assets/screenshots/webui-collections.jpg +0 -0
- package/assets/screenshots/webui-graph.jpg +0 -0
- package/package.json +1 -1
- package/src/core/file-ops.ts +12 -1
- package/src/core/file-refactors.ts +180 -0
- package/src/core/note-creation.ts +137 -0
- package/src/core/note-presets.ts +183 -0
- package/src/core/sections.ts +62 -0
- package/src/core/validation.ts +4 -3
- package/src/mcp/tools/capture.ts +71 -12
- package/src/mcp/tools/index.ts +82 -1
- package/src/mcp/tools/workspace-write.ts +321 -0
- package/src/sdk/client.ts +341 -0
- package/src/sdk/types.ts +67 -0
- package/src/serve/CLAUDE.md +3 -1
- package/src/serve/browse-tree.ts +60 -12
- package/src/serve/public/app.tsx +1 -0
- package/src/serve/public/components/CaptureModal.tsx +135 -13
- package/src/serve/public/components/QuickSwitcher.tsx +228 -4
- package/src/serve/public/components/ShortcutHelpModal.tsx +54 -1
- package/src/serve/public/components/editor/MarkdownPreview.tsx +58 -26
- package/src/serve/public/hooks/useCaptureModal.tsx +31 -5
- package/src/serve/public/lib/workspace-actions.ts +226 -0
- package/src/serve/public/lib/workspace-events.ts +39 -0
- package/src/serve/public/pages/Browse.tsx +154 -3
- package/src/serve/public/pages/DocView.tsx +439 -0
- package/src/serve/public/pages/DocumentEditor.tsx +52 -0
- package/src/serve/routes/api.ts +712 -13
- package/src/serve/server.ts +74 -0
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
parseTargetParts,
|
|
19
19
|
stripWikiMdExt,
|
|
20
20
|
} from "../../../../core/links";
|
|
21
|
+
import { slugifySectionTitle } from "../../../../core/sections";
|
|
21
22
|
import {
|
|
22
23
|
extractMarkdownCodeLanguage,
|
|
23
24
|
resolveCodeLanguage,
|
|
@@ -126,9 +127,34 @@ const Link: FC<ComponentProps<"a"> & { node?: unknown }> = ({
|
|
|
126
127
|
);
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
function flattenNodeText(node: ReactNode): string {
|
|
131
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
132
|
+
return String(node);
|
|
133
|
+
}
|
|
134
|
+
if (!node || typeof node === "boolean") {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(node)) {
|
|
138
|
+
return node.map((child) => flattenNodeText(child)).join("");
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
typeof node === "object" &&
|
|
142
|
+
"props" in node &&
|
|
143
|
+
node.props &&
|
|
144
|
+
typeof node.props === "object" &&
|
|
145
|
+
"children" in node.props
|
|
146
|
+
) {
|
|
147
|
+
return flattenNodeText(node.props.children as ReactNode);
|
|
148
|
+
}
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
|
|
129
152
|
// Heading styles with proper hierarchy
|
|
130
153
|
const createHeading =
|
|
131
|
-
(
|
|
154
|
+
(
|
|
155
|
+
level: 1 | 2 | 3 | 4 | 5 | 6,
|
|
156
|
+
anchorCounts: Map<string, number>
|
|
157
|
+
): FC<{ children?: ReactNode }> =>
|
|
132
158
|
({ children }) => {
|
|
133
159
|
const Tag = `h${level}` as const;
|
|
134
160
|
const sizes = {
|
|
@@ -139,8 +165,15 @@ const createHeading =
|
|
|
139
165
|
5: "text-base mt-3 mb-1 font-semibold",
|
|
140
166
|
6: "text-sm mt-3 mb-1 font-semibold text-muted-foreground",
|
|
141
167
|
};
|
|
168
|
+
const baseAnchor = slugifySectionTitle(flattenNodeText(children));
|
|
169
|
+
const seen = (anchorCounts.get(baseAnchor) ?? 0) + 1;
|
|
170
|
+
anchorCounts.set(baseAnchor, seen);
|
|
171
|
+
const anchor = seen === 1 ? baseAnchor : `${baseAnchor}-${seen}`;
|
|
142
172
|
return (
|
|
143
|
-
<Tag
|
|
173
|
+
<Tag
|
|
174
|
+
className={cn("scroll-mt-24 font-serif tracking-tight", sizes[level])}
|
|
175
|
+
id={anchor}
|
|
176
|
+
>
|
|
144
177
|
{children}
|
|
145
178
|
</Tag>
|
|
146
179
|
);
|
|
@@ -343,30 +376,6 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
|
|
|
343
376
|
/>
|
|
344
377
|
);
|
|
345
378
|
|
|
346
|
-
// Component mapping for react-markdown
|
|
347
|
-
const components = {
|
|
348
|
-
h1: createHeading(1),
|
|
349
|
-
h2: createHeading(2),
|
|
350
|
-
h3: createHeading(3),
|
|
351
|
-
h4: createHeading(4),
|
|
352
|
-
h5: createHeading(5),
|
|
353
|
-
h6: createHeading(6),
|
|
354
|
-
p: Paragraph,
|
|
355
|
-
a: Link,
|
|
356
|
-
code: InlineCode,
|
|
357
|
-
pre: Pre,
|
|
358
|
-
blockquote: Blockquote,
|
|
359
|
-
ul: UnorderedList,
|
|
360
|
-
ol: OrderedList,
|
|
361
|
-
table: Table,
|
|
362
|
-
thead: TableHead,
|
|
363
|
-
tr: TableRow,
|
|
364
|
-
td: TableCell,
|
|
365
|
-
th: TableHeaderCell,
|
|
366
|
-
hr: Hr,
|
|
367
|
-
img: Image,
|
|
368
|
-
};
|
|
369
|
-
|
|
370
379
|
/**
|
|
371
380
|
* Renders markdown content with syntax highlighting and proper styling.
|
|
372
381
|
* Sanitizes HTML to prevent XSS attacks.
|
|
@@ -386,6 +395,29 @@ export const MarkdownPreview = memo(
|
|
|
386
395
|
collection,
|
|
387
396
|
wikiLinks
|
|
388
397
|
);
|
|
398
|
+
const anchorCounts = new Map<string, number>();
|
|
399
|
+
const components = {
|
|
400
|
+
h1: createHeading(1, anchorCounts),
|
|
401
|
+
h2: createHeading(2, anchorCounts),
|
|
402
|
+
h3: createHeading(3, anchorCounts),
|
|
403
|
+
h4: createHeading(4, anchorCounts),
|
|
404
|
+
h5: createHeading(5, anchorCounts),
|
|
405
|
+
h6: createHeading(6, anchorCounts),
|
|
406
|
+
p: Paragraph,
|
|
407
|
+
a: Link,
|
|
408
|
+
code: InlineCode,
|
|
409
|
+
pre: Pre,
|
|
410
|
+
blockquote: Blockquote,
|
|
411
|
+
ul: UnorderedList,
|
|
412
|
+
ol: OrderedList,
|
|
413
|
+
table: Table,
|
|
414
|
+
thead: TableHead,
|
|
415
|
+
tr: TableRow,
|
|
416
|
+
td: TableCell,
|
|
417
|
+
th: TableHeaderCell,
|
|
418
|
+
hr: Hr,
|
|
419
|
+
img: Image,
|
|
420
|
+
};
|
|
389
421
|
|
|
390
422
|
return (
|
|
391
423
|
<div
|
|
@@ -19,9 +19,16 @@ import {
|
|
|
19
19
|
import { CaptureModal } from "../components/CaptureModal";
|
|
20
20
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
|
21
21
|
|
|
22
|
+
export interface CaptureModalOpenOptions {
|
|
23
|
+
draftTitle?: string;
|
|
24
|
+
defaultCollection?: string;
|
|
25
|
+
defaultFolderPath?: string;
|
|
26
|
+
presetId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
interface CaptureModalContextValue {
|
|
23
30
|
/** Open the capture modal */
|
|
24
|
-
openCapture: (
|
|
31
|
+
openCapture: (options?: string | CaptureModalOpenOptions) => void;
|
|
25
32
|
/** Whether the modal is open */
|
|
26
33
|
isOpen: boolean;
|
|
27
34
|
}
|
|
@@ -42,11 +49,27 @@ export function CaptureModalProvider({
|
|
|
42
49
|
}: CaptureModalProviderProps) {
|
|
43
50
|
const [open, setOpen] = useState(false);
|
|
44
51
|
const [draftTitle, setDraftTitle] = useState("");
|
|
52
|
+
const [defaultCollection, setDefaultCollection] = useState("");
|
|
53
|
+
const [defaultFolderPath, setDefaultFolderPath] = useState("");
|
|
54
|
+
const [presetId, setPresetId] = useState("");
|
|
45
55
|
|
|
46
|
-
const openCapture = useCallback(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
const openCapture = useCallback(
|
|
57
|
+
(options?: string | CaptureModalOpenOptions) => {
|
|
58
|
+
if (typeof options === "string") {
|
|
59
|
+
setDraftTitle(options);
|
|
60
|
+
setDefaultCollection("");
|
|
61
|
+
setDefaultFolderPath("");
|
|
62
|
+
setPresetId("");
|
|
63
|
+
} else {
|
|
64
|
+
setDraftTitle(options?.draftTitle ?? "");
|
|
65
|
+
setDefaultCollection(options?.defaultCollection ?? "");
|
|
66
|
+
setDefaultFolderPath(options?.defaultFolderPath ?? "");
|
|
67
|
+
setPresetId(options?.presetId ?? "");
|
|
68
|
+
}
|
|
69
|
+
setOpen(true);
|
|
70
|
+
},
|
|
71
|
+
[]
|
|
72
|
+
);
|
|
50
73
|
|
|
51
74
|
// 'n' global shortcut (single-key, skips when in text input)
|
|
52
75
|
const shortcuts = useMemo(
|
|
@@ -73,10 +96,13 @@ export function CaptureModalProvider({
|
|
|
73
96
|
<CaptureModalContext.Provider value={value}>
|
|
74
97
|
{children}
|
|
75
98
|
<CaptureModal
|
|
99
|
+
defaultCollection={defaultCollection}
|
|
100
|
+
defaultFolderPath={defaultFolderPath}
|
|
76
101
|
draftTitle={draftTitle}
|
|
77
102
|
onOpenChange={setOpen}
|
|
78
103
|
onSuccess={onSuccess}
|
|
79
104
|
open={open}
|
|
105
|
+
presetId={presetId}
|
|
80
106
|
/>
|
|
81
107
|
</CaptureModalContext.Provider>
|
|
82
108
|
);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { parseBrowseLocation } from "./browse";
|
|
2
|
+
import { emitWorkspaceActionRequest } from "./workspace-events";
|
|
3
|
+
|
|
4
|
+
export type WorkspaceActionId =
|
|
5
|
+
| "new-note"
|
|
6
|
+
| "new-note-in-context"
|
|
7
|
+
| "create-folder-here"
|
|
8
|
+
| "rename-current-note"
|
|
9
|
+
| "move-current-note"
|
|
10
|
+
| "duplicate-current-note"
|
|
11
|
+
| "go-home"
|
|
12
|
+
| "go-search"
|
|
13
|
+
| "go-browse"
|
|
14
|
+
| "go-ask"
|
|
15
|
+
| "go-graph"
|
|
16
|
+
| "go-collections"
|
|
17
|
+
| "go-connectors";
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceAction {
|
|
20
|
+
id: WorkspaceActionId;
|
|
21
|
+
group: "Create" | "Go To";
|
|
22
|
+
label: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
keywords: string[];
|
|
25
|
+
available: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceActionContext {
|
|
29
|
+
location: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkspaceActionHandlers {
|
|
33
|
+
navigate: (to: string) => void;
|
|
34
|
+
openCapture: (options?: {
|
|
35
|
+
draftTitle?: string;
|
|
36
|
+
defaultCollection?: string;
|
|
37
|
+
defaultFolderPath?: string;
|
|
38
|
+
presetId?: string;
|
|
39
|
+
}) => void;
|
|
40
|
+
closePalette: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getWorkspaceActions(
|
|
44
|
+
context: WorkspaceActionContext
|
|
45
|
+
): WorkspaceAction[] {
|
|
46
|
+
const selection = parseBrowseLocation(
|
|
47
|
+
context.location.includes("?")
|
|
48
|
+
? `?${context.location.split("?")[1] ?? ""}`
|
|
49
|
+
: ""
|
|
50
|
+
);
|
|
51
|
+
const hasBrowseContext = Boolean(selection.collection);
|
|
52
|
+
const isDocView = context.location.startsWith("/doc?");
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
id: "new-note",
|
|
57
|
+
group: "Create",
|
|
58
|
+
label: "New note",
|
|
59
|
+
description: "Open note capture",
|
|
60
|
+
keywords: ["new", "note", "capture", "create"],
|
|
61
|
+
available: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "new-note-in-context",
|
|
65
|
+
group: "Create",
|
|
66
|
+
label: "New note in current location",
|
|
67
|
+
description: hasBrowseContext
|
|
68
|
+
? `Create in ${selection.collection}${
|
|
69
|
+
selection.path ? ` / ${selection.path}` : ""
|
|
70
|
+
}`
|
|
71
|
+
: "Requires a selected collection in Browse",
|
|
72
|
+
keywords: ["new", "note", "folder", "browse", "collection", "create"],
|
|
73
|
+
available: hasBrowseContext,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "create-folder-here",
|
|
77
|
+
group: "Create",
|
|
78
|
+
label: "Create folder here",
|
|
79
|
+
description: hasBrowseContext
|
|
80
|
+
? "Create a folder in the current Browse location"
|
|
81
|
+
: "Requires a selected collection in Browse",
|
|
82
|
+
keywords: ["folder", "browse", "create", "directory"],
|
|
83
|
+
available: hasBrowseContext,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "rename-current-note",
|
|
87
|
+
group: "Create",
|
|
88
|
+
label: "Rename current note",
|
|
89
|
+
description: "Open rename dialog for the active note",
|
|
90
|
+
keywords: ["rename", "current", "note", "document"],
|
|
91
|
+
available: isDocView,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "move-current-note",
|
|
95
|
+
group: "Create",
|
|
96
|
+
label: "Move current note",
|
|
97
|
+
description: "Open move dialog for the active note",
|
|
98
|
+
keywords: ["move", "current", "note", "document"],
|
|
99
|
+
available: isDocView,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "duplicate-current-note",
|
|
103
|
+
group: "Create",
|
|
104
|
+
label: "Duplicate current note",
|
|
105
|
+
description: "Open duplicate dialog for the active note",
|
|
106
|
+
keywords: ["duplicate", "copy", "current", "note", "document"],
|
|
107
|
+
available: isDocView,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "go-home",
|
|
111
|
+
group: "Go To",
|
|
112
|
+
label: "Home",
|
|
113
|
+
keywords: ["home", "dashboard"],
|
|
114
|
+
available: true,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "go-search",
|
|
118
|
+
group: "Go To",
|
|
119
|
+
label: "Search",
|
|
120
|
+
keywords: ["search", "find", "query"],
|
|
121
|
+
available: true,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "go-browse",
|
|
125
|
+
group: "Go To",
|
|
126
|
+
label: "Browse",
|
|
127
|
+
keywords: ["browse", "tree", "folders"],
|
|
128
|
+
available: true,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "go-ask",
|
|
132
|
+
group: "Go To",
|
|
133
|
+
label: "Ask",
|
|
134
|
+
keywords: ["ask", "answer", "rag"],
|
|
135
|
+
available: true,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "go-graph",
|
|
139
|
+
group: "Go To",
|
|
140
|
+
label: "Graph",
|
|
141
|
+
keywords: ["graph", "links", "relationships"],
|
|
142
|
+
available: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "go-collections",
|
|
146
|
+
group: "Go To",
|
|
147
|
+
label: "Collections",
|
|
148
|
+
keywords: ["collections", "sources", "folders"],
|
|
149
|
+
available: true,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "go-connectors",
|
|
153
|
+
group: "Go To",
|
|
154
|
+
label: "Connectors",
|
|
155
|
+
keywords: ["connectors", "mcp", "skills", "agents"],
|
|
156
|
+
available: true,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function runWorkspaceAction(
|
|
162
|
+
action: WorkspaceAction,
|
|
163
|
+
context: WorkspaceActionContext,
|
|
164
|
+
handlers: WorkspaceActionHandlers,
|
|
165
|
+
query?: string
|
|
166
|
+
): void {
|
|
167
|
+
const selection = parseBrowseLocation(
|
|
168
|
+
context.location.includes("?")
|
|
169
|
+
? `?${context.location.split("?")[1] ?? ""}`
|
|
170
|
+
: ""
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
switch (action.id) {
|
|
174
|
+
case "new-note":
|
|
175
|
+
handlers.openCapture({ draftTitle: query?.trim() || undefined });
|
|
176
|
+
handlers.closePalette();
|
|
177
|
+
return;
|
|
178
|
+
case "new-note-in-context":
|
|
179
|
+
handlers.openCapture({
|
|
180
|
+
draftTitle: query?.trim() || undefined,
|
|
181
|
+
defaultCollection: selection.collection || undefined,
|
|
182
|
+
defaultFolderPath: selection.path || undefined,
|
|
183
|
+
});
|
|
184
|
+
handlers.closePalette();
|
|
185
|
+
return;
|
|
186
|
+
case "create-folder-here":
|
|
187
|
+
emitWorkspaceActionRequest("create-folder-here");
|
|
188
|
+
handlers.closePalette();
|
|
189
|
+
return;
|
|
190
|
+
case "rename-current-note":
|
|
191
|
+
emitWorkspaceActionRequest("rename-current-note");
|
|
192
|
+
handlers.closePalette();
|
|
193
|
+
return;
|
|
194
|
+
case "move-current-note":
|
|
195
|
+
emitWorkspaceActionRequest("move-current-note");
|
|
196
|
+
handlers.closePalette();
|
|
197
|
+
return;
|
|
198
|
+
case "duplicate-current-note":
|
|
199
|
+
emitWorkspaceActionRequest("duplicate-current-note");
|
|
200
|
+
handlers.closePalette();
|
|
201
|
+
return;
|
|
202
|
+
case "go-home":
|
|
203
|
+
handlers.navigate("/");
|
|
204
|
+
break;
|
|
205
|
+
case "go-search":
|
|
206
|
+
handlers.navigate("/search");
|
|
207
|
+
break;
|
|
208
|
+
case "go-browse":
|
|
209
|
+
handlers.navigate("/browse");
|
|
210
|
+
break;
|
|
211
|
+
case "go-ask":
|
|
212
|
+
handlers.navigate("/ask");
|
|
213
|
+
break;
|
|
214
|
+
case "go-graph":
|
|
215
|
+
handlers.navigate("/graph");
|
|
216
|
+
break;
|
|
217
|
+
case "go-collections":
|
|
218
|
+
handlers.navigate("/collections");
|
|
219
|
+
break;
|
|
220
|
+
case "go-connectors":
|
|
221
|
+
handlers.navigate("/connectors");
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
handlers.closePalette();
|
|
226
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type WorkspaceActionRequestType =
|
|
2
|
+
| "create-folder-here"
|
|
3
|
+
| "rename-current-note"
|
|
4
|
+
| "move-current-note"
|
|
5
|
+
| "duplicate-current-note";
|
|
6
|
+
|
|
7
|
+
const WORKSPACE_ACTION_EVENT = "gno:workspace-action";
|
|
8
|
+
|
|
9
|
+
export function emitWorkspaceActionRequest(
|
|
10
|
+
type: WorkspaceActionRequestType
|
|
11
|
+
): void {
|
|
12
|
+
window.dispatchEvent(
|
|
13
|
+
new CustomEvent(WORKSPACE_ACTION_EVENT, {
|
|
14
|
+
detail: { type },
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function subscribeWorkspaceActionRequest(
|
|
20
|
+
type: WorkspaceActionRequestType,
|
|
21
|
+
handler: () => void
|
|
22
|
+
): () => void {
|
|
23
|
+
const listener = (event: Event) => {
|
|
24
|
+
if (!(event instanceof CustomEvent)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
typeof event.detail === "object" &&
|
|
29
|
+
event.detail &&
|
|
30
|
+
"type" in event.detail &&
|
|
31
|
+
event.detail.type === type
|
|
32
|
+
) {
|
|
33
|
+
handler();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
window.addEventListener(WORKSPACE_ACTION_EVENT, listener);
|
|
38
|
+
return () => window.removeEventListener(WORKSPACE_ACTION_EVENT, listener);
|
|
39
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowLeft,
|
|
3
|
+
FilePlus2,
|
|
3
4
|
FolderOpen,
|
|
4
5
|
HomeIcon,
|
|
6
|
+
FolderPlus,
|
|
5
7
|
RefreshCw,
|
|
6
8
|
StarIcon,
|
|
7
9
|
} from "lucide-react";
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
DialogHeader,
|
|
27
29
|
DialogTitle,
|
|
28
30
|
} from "../components/ui/dialog";
|
|
31
|
+
import { Input } from "../components/ui/input";
|
|
29
32
|
import {
|
|
30
33
|
Select,
|
|
31
34
|
SelectContent,
|
|
@@ -35,6 +38,7 @@ import {
|
|
|
35
38
|
} from "../components/ui/select";
|
|
36
39
|
import { apiFetch } from "../hooks/use-api";
|
|
37
40
|
import { useDocEvents } from "../hooks/use-doc-events";
|
|
41
|
+
import { useCaptureModal } from "../hooks/useCaptureModal";
|
|
38
42
|
import { useWorkspace } from "../hooks/useWorkspace";
|
|
39
43
|
import {
|
|
40
44
|
buildBrowseCrumbs,
|
|
@@ -51,6 +55,7 @@ import {
|
|
|
51
55
|
toggleFavoriteCollection,
|
|
52
56
|
toggleFavoriteDocument,
|
|
53
57
|
} from "../lib/navigation-state";
|
|
58
|
+
import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
|
|
54
59
|
|
|
55
60
|
interface PageProps {
|
|
56
61
|
navigate: (to: string | number) => void;
|
|
@@ -61,10 +66,18 @@ interface SyncResponse {
|
|
|
61
66
|
jobId: string;
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
interface CreateFolderResponse {
|
|
70
|
+
success: boolean;
|
|
71
|
+
collection: string;
|
|
72
|
+
folderPath: string;
|
|
73
|
+
path: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
type SyncTarget = { kind: "all" } | { kind: "collection"; name: string } | null;
|
|
65
77
|
|
|
66
78
|
export default function Browse({ navigate, location }: PageProps) {
|
|
67
79
|
const { activeTab, updateActiveTabBrowseState } = useWorkspace();
|
|
80
|
+
const { openCapture } = useCaptureModal();
|
|
68
81
|
const latestDocEvent = useDocEvents();
|
|
69
82
|
const resolvedLocation =
|
|
70
83
|
location ?? `${window.location.pathname}${window.location.search}`;
|
|
@@ -93,6 +106,12 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
93
106
|
const [favoriteDocHrefs, setFavoriteDocHrefs] = useState<string[]>([]);
|
|
94
107
|
const [favoriteCollections, setFavoriteCollections] = useState<string[]>([]);
|
|
95
108
|
const [mobileTreeOpen, setMobileTreeOpen] = useState(false);
|
|
109
|
+
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
|
110
|
+
const [createFolderName, setCreateFolderName] = useState("");
|
|
111
|
+
const [createFolderError, setCreateFolderError] = useState<string | null>(
|
|
112
|
+
null
|
|
113
|
+
);
|
|
114
|
+
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
96
115
|
const limit = 25;
|
|
97
116
|
|
|
98
117
|
const selectedCollection = selection.collection;
|
|
@@ -100,9 +119,28 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
100
119
|
const selectedNodeId = selectedCollection
|
|
101
120
|
? createBrowseNodeId(selectedCollection, selectedPath)
|
|
102
121
|
: null;
|
|
103
|
-
const
|
|
122
|
+
const resolvedSelectedNode = tree
|
|
104
123
|
? findBrowseNode(tree.collections, selectedCollection, selectedPath)
|
|
105
124
|
: null;
|
|
125
|
+
const selectedNode =
|
|
126
|
+
resolvedSelectedNode ??
|
|
127
|
+
(selectedCollection
|
|
128
|
+
? {
|
|
129
|
+
id:
|
|
130
|
+
selectedNodeId ??
|
|
131
|
+
createBrowseNodeId(selectedCollection, selectedPath),
|
|
132
|
+
kind: selectedPath ? "folder" : "collection",
|
|
133
|
+
collection: selectedCollection,
|
|
134
|
+
path: selectedPath,
|
|
135
|
+
name: selectedPath.split("/").at(-1) ?? selectedCollection,
|
|
136
|
+
depth: selectedPath
|
|
137
|
+
? selectedPath.split("/").filter(Boolean).length
|
|
138
|
+
: 0,
|
|
139
|
+
documentCount: docs.length,
|
|
140
|
+
directDocumentCount: docs.length,
|
|
141
|
+
children: [],
|
|
142
|
+
}
|
|
143
|
+
: null);
|
|
106
144
|
|
|
107
145
|
const expandedNodeIds = useMemo(() => {
|
|
108
146
|
const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
|
|
@@ -176,13 +214,14 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
176
214
|
if (node) {
|
|
177
215
|
return;
|
|
178
216
|
}
|
|
179
|
-
|
|
217
|
+
if (selectedPath) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
180
220
|
const collectionRoot = findBrowseNode(tree.collections, selectedCollection);
|
|
181
221
|
if (collectionRoot) {
|
|
182
222
|
navigate(buildBrowseLocation(selectedCollection));
|
|
183
223
|
return;
|
|
184
224
|
}
|
|
185
|
-
|
|
186
225
|
navigate("/browse");
|
|
187
226
|
}, [navigate, selectedCollection, selectedPath, tree]);
|
|
188
227
|
|
|
@@ -241,6 +280,16 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
241
280
|
setRefreshToken((current) => current + 1);
|
|
242
281
|
}, [latestDocEvent?.changedAt]);
|
|
243
282
|
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
return subscribeWorkspaceActionRequest("create-folder-here", () => {
|
|
285
|
+
if (!selectedCollection) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
setCreateFolderError(null);
|
|
289
|
+
setCreateFolderOpen(true);
|
|
290
|
+
});
|
|
291
|
+
}, [selectedCollection]);
|
|
292
|
+
|
|
244
293
|
useEffect(() => {
|
|
245
294
|
const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
|
|
246
295
|
const ancestors = selectedPath
|
|
@@ -328,6 +377,38 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
328
377
|
}
|
|
329
378
|
};
|
|
330
379
|
|
|
380
|
+
const handleCreateFolder = async () => {
|
|
381
|
+
if (!selectedCollection || !createFolderName.trim()) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
setCreatingFolder(true);
|
|
386
|
+
setCreateFolderError(null);
|
|
387
|
+
const { data, error } = await apiFetch<CreateFolderResponse>(
|
|
388
|
+
"/api/folders",
|
|
389
|
+
{
|
|
390
|
+
method: "POST",
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
collection: selectedCollection,
|
|
393
|
+
parentPath: selectedPath || undefined,
|
|
394
|
+
name: createFolderName.trim(),
|
|
395
|
+
}),
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
setCreatingFolder(false);
|
|
399
|
+
|
|
400
|
+
if (error) {
|
|
401
|
+
setCreateFolderError(error);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (data?.folderPath) {
|
|
405
|
+
setCreateFolderOpen(false);
|
|
406
|
+
setCreateFolderName("");
|
|
407
|
+
navigate(buildBrowseLocation(selectedCollection, data.folderPath));
|
|
408
|
+
setRefreshToken((current) => current + 1);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
331
412
|
const renderSidebar = () => (
|
|
332
413
|
<BrowseTreeSidebar
|
|
333
414
|
collections={tree?.collections ?? []}
|
|
@@ -412,6 +493,37 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
412
493
|
<FolderOpen className="size-4" />
|
|
413
494
|
Collections
|
|
414
495
|
</Button>
|
|
496
|
+
{selectedCollection && (
|
|
497
|
+
<Button
|
|
498
|
+
className="gap-2"
|
|
499
|
+
onClick={() =>
|
|
500
|
+
openCapture({
|
|
501
|
+
defaultCollection: selectedCollection,
|
|
502
|
+
defaultFolderPath: selectedPath || undefined,
|
|
503
|
+
draftTitle: "",
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
size="sm"
|
|
507
|
+
variant="outline"
|
|
508
|
+
>
|
|
509
|
+
<FilePlus2 className="size-4" />
|
|
510
|
+
New Note
|
|
511
|
+
</Button>
|
|
512
|
+
)}
|
|
513
|
+
{selectedCollection && (
|
|
514
|
+
<Button
|
|
515
|
+
className="gap-2"
|
|
516
|
+
onClick={() => {
|
|
517
|
+
setCreateFolderOpen(true);
|
|
518
|
+
setCreateFolderError(null);
|
|
519
|
+
}}
|
|
520
|
+
size="sm"
|
|
521
|
+
variant="outline"
|
|
522
|
+
>
|
|
523
|
+
<FolderPlus className="size-4" />
|
|
524
|
+
New Folder
|
|
525
|
+
</Button>
|
|
526
|
+
)}
|
|
415
527
|
{selectedCollection && (
|
|
416
528
|
<Button
|
|
417
529
|
className="gap-2"
|
|
@@ -542,6 +654,45 @@ export default function Browse({ navigate, location }: PageProps) {
|
|
|
542
654
|
</div>
|
|
543
655
|
</DialogContent>
|
|
544
656
|
</Dialog>
|
|
657
|
+
|
|
658
|
+
<Dialog onOpenChange={setCreateFolderOpen} open={createFolderOpen}>
|
|
659
|
+
<DialogContent className="max-w-md">
|
|
660
|
+
<DialogHeader>
|
|
661
|
+
<DialogTitle>Create folder</DialogTitle>
|
|
662
|
+
</DialogHeader>
|
|
663
|
+
<div className="space-y-3">
|
|
664
|
+
<Input
|
|
665
|
+
autoFocus
|
|
666
|
+
onChange={(event) => setCreateFolderName(event.target.value)}
|
|
667
|
+
placeholder="research"
|
|
668
|
+
value={createFolderName}
|
|
669
|
+
/>
|
|
670
|
+
{selectedCollection && (
|
|
671
|
+
<p className="font-mono text-xs text-muted-foreground">
|
|
672
|
+
{selectedCollection}
|
|
673
|
+
{selectedPath ? ` / ${selectedPath}` : ""}
|
|
674
|
+
</p>
|
|
675
|
+
)}
|
|
676
|
+
{createFolderError && (
|
|
677
|
+
<p className="text-destructive text-sm">{createFolderError}</p>
|
|
678
|
+
)}
|
|
679
|
+
<div className="flex justify-end gap-2">
|
|
680
|
+
<Button
|
|
681
|
+
onClick={() => setCreateFolderOpen(false)}
|
|
682
|
+
variant="outline"
|
|
683
|
+
>
|
|
684
|
+
Cancel
|
|
685
|
+
</Button>
|
|
686
|
+
<Button
|
|
687
|
+
disabled={!createFolderName.trim() || creatingFolder}
|
|
688
|
+
onClick={() => void handleCreateFolder()}
|
|
689
|
+
>
|
|
690
|
+
{creatingFolder ? "Creating..." : "Create Folder"}
|
|
691
|
+
</Button>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
</DialogContent>
|
|
695
|
+
</Dialog>
|
|
545
696
|
</div>
|
|
546
697
|
);
|
|
547
698
|
}
|