@gmickel/gno 0.34.1 → 0.36.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.
Files changed (37) hide show
  1. package/README.md +37 -26
  2. package/assets/screenshots/webui-collections.jpg +0 -0
  3. package/assets/screenshots/webui-graph.jpg +0 -0
  4. package/package.json +1 -1
  5. package/src/core/file-ops.ts +12 -1
  6. package/src/core/file-refactors.ts +180 -0
  7. package/src/core/note-creation.ts +137 -0
  8. package/src/core/note-presets.ts +183 -0
  9. package/src/core/sections.ts +62 -0
  10. package/src/core/validation.ts +4 -3
  11. package/src/mcp/tools/capture.ts +71 -12
  12. package/src/mcp/tools/index.ts +82 -1
  13. package/src/mcp/tools/workspace-write.ts +321 -0
  14. package/src/sdk/client.ts +341 -0
  15. package/src/sdk/types.ts +67 -0
  16. package/src/serve/CLAUDE.md +3 -1
  17. package/src/serve/browse-tree.ts +60 -12
  18. package/src/serve/public/app.tsx +8 -3
  19. package/src/serve/public/components/BrowseDetailPane.tsx +18 -1
  20. package/src/serve/public/components/CaptureModal.tsx +135 -13
  21. package/src/serve/public/components/QuickSwitcher.tsx +408 -46
  22. package/src/serve/public/components/ShortcutHelpModal.tsx +54 -1
  23. package/src/serve/public/components/editor/MarkdownPreview.tsx +58 -26
  24. package/src/serve/public/components/ui/command.tsx +19 -9
  25. package/src/serve/public/components/ui/dialog.tsx +1 -1
  26. package/src/serve/public/globals.built.css +2 -2
  27. package/src/serve/public/globals.css +47 -0
  28. package/src/serve/public/hooks/useCaptureModal.tsx +31 -5
  29. package/src/serve/public/lib/browse.ts +1 -0
  30. package/src/serve/public/lib/workspace-actions.ts +226 -0
  31. package/src/serve/public/lib/workspace-events.ts +39 -0
  32. package/src/serve/public/pages/Browse.tsx +154 -3
  33. package/src/serve/public/pages/DocView.tsx +472 -10
  34. package/src/serve/public/pages/DocumentEditor.tsx +52 -0
  35. package/src/serve/routes/api.ts +712 -13
  36. package/src/serve/server.ts +74 -0
  37. package/src/store/sqlite/adapter.ts +19 -19
@@ -19,6 +19,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
19
19
 
20
20
  import type { WikiLinkDoc } from "./WikiLinkAutocomplete";
21
21
 
22
+ import {
23
+ getNotePreset,
24
+ NOTE_PRESETS,
25
+ resolveNotePreset,
26
+ } from "../../../core/note-presets";
22
27
  import { apiFetch } from "../hooks/use-api";
23
28
  import { getActiveWikiLinkQuery } from "../lib/wiki-link";
24
29
  import { IndexingProgress } from "./IndexingProgress";
@@ -48,6 +53,12 @@ export interface CaptureModalProps {
48
53
  open: boolean;
49
54
  /** Prefill title when opening from another surface */
50
55
  draftTitle?: string;
56
+ /** Default collection from current workspace context */
57
+ defaultCollection?: string;
58
+ /** Default folder path from current workspace context */
59
+ defaultFolderPath?: string;
60
+ /** Optional preset id */
61
+ presetId?: string;
51
62
  /** Callback when open state changes */
52
63
  onOpenChange: (open: boolean) => void;
53
64
  /** Callback when document created successfully */
@@ -62,8 +73,11 @@ interface Collection {
62
73
  interface CreateDocResponse {
63
74
  uri: string;
64
75
  path: string;
65
- jobId: string;
76
+ jobId: string | null;
66
77
  note: string;
78
+ openedExisting?: boolean;
79
+ created?: boolean;
80
+ relPath?: string;
67
81
  }
68
82
 
69
83
  interface CollectionsResponse {
@@ -91,8 +105,11 @@ type ModalState = "form" | "submitting" | "success" | "error";
91
105
  export function CaptureModal({
92
106
  open,
93
107
  draftTitle = "",
108
+ defaultCollection = "",
109
+ defaultFolderPath = "",
94
110
  onOpenChange,
95
111
  onSuccess,
112
+ presetId = "",
96
113
  }: CaptureModalProps) {
97
114
  // Form state
98
115
  const [title, setTitle] = useState("");
@@ -100,6 +117,9 @@ export function CaptureModal({
100
117
  const [collection, setCollection] = useState("");
101
118
  const [collections, setCollections] = useState<Collection[]>([]);
102
119
  const [tags, setTags] = useState<string[]>([]);
120
+ const [selectedPresetId, setSelectedPresetId] = useState<string>(presetId);
121
+ const [contentTouched, setContentTouched] = useState(false);
122
+ const [lastGeneratedContent, setLastGeneratedContent] = useState("");
103
123
  const [wikiLinkDocs, setWikiLinkDocs] = useState<WikiLinkDoc[]>([]);
104
124
  const [wikiLinkOpen, setWikiLinkOpen] = useState(false);
105
125
  const [wikiLinkQuery, setWikiLinkQuery] = useState("");
@@ -123,6 +143,7 @@ export function CaptureModal({
123
143
  if (draftTitle.trim()) {
124
144
  setTitle(draftTitle);
125
145
  }
146
+ setSelectedPresetId(presetId || "blank");
126
147
 
127
148
  void apiFetch<CollectionsResponse>("/api/status").then(({ data }) => {
128
149
  if (data?.collections) {
@@ -130,9 +151,17 @@ export function CaptureModal({
130
151
  data.collections.map((c) => ({ name: c.name, path: c.path }))
131
152
  );
132
153
 
133
- // Restore last used collection or default to first
154
+ const requestedCollection = defaultCollection.trim();
134
155
  const lastUsed = localStorage.getItem(STORAGE_KEY);
135
- if (lastUsed && data.collections.some((c) => c.name === lastUsed)) {
156
+ if (
157
+ requestedCollection &&
158
+ data.collections.some((c) => c.name === requestedCollection)
159
+ ) {
160
+ setCollection(requestedCollection);
161
+ } else if (
162
+ lastUsed &&
163
+ data.collections.some((c) => c.name === lastUsed)
164
+ ) {
136
165
  setCollection(lastUsed);
137
166
  } else {
138
167
  const firstCollection = data.collections.at(0);
@@ -142,7 +171,7 @@ export function CaptureModal({
142
171
  }
143
172
  }
144
173
  });
145
- }, [draftTitle, open]);
174
+ }, [defaultCollection, draftTitle, open, presetId]);
146
175
 
147
176
  // Reset form when modal closes
148
177
  useEffect(() => {
@@ -152,6 +181,9 @@ export function CaptureModal({
152
181
  setTitle("");
153
182
  setContent("");
154
183
  setTags([]);
184
+ setContentTouched(false);
185
+ setLastGeneratedContent("");
186
+ setSelectedPresetId("blank");
155
187
  setState("form");
156
188
  setError(null);
157
189
  setJobId(null);
@@ -168,6 +200,44 @@ export function CaptureModal({
168
200
  // Validate form
169
201
  const isValid = title.trim() && content.trim() && collection;
170
202
 
203
+ useEffect(() => {
204
+ if (!open) {
205
+ return;
206
+ }
207
+
208
+ const resolved = resolveNotePreset({
209
+ presetId: selectedPresetId || "blank",
210
+ title: title.trim() || draftTitle.trim() || "Untitled",
211
+ tags,
212
+ });
213
+ const generatedContent = resolved?.content ?? "";
214
+ const shouldApply =
215
+ !contentTouched || !content.trim() || content === lastGeneratedContent;
216
+
217
+ setLastGeneratedContent(generatedContent);
218
+ if (shouldApply) {
219
+ setContent(generatedContent);
220
+ if (resolved?.tags) {
221
+ const nextTags = resolved.tags;
222
+ const sameTags =
223
+ nextTags.length === tags.length &&
224
+ nextTags.every((tag, index) => tags[index] === tag);
225
+ if (!sameTags) {
226
+ setTags(nextTags);
227
+ }
228
+ }
229
+ }
230
+ }, [
231
+ content,
232
+ contentTouched,
233
+ draftTitle,
234
+ lastGeneratedContent,
235
+ open,
236
+ selectedPresetId,
237
+ tags,
238
+ title,
239
+ ]);
240
+
171
241
  // Submit form
172
242
  const handleSubmit = useCallback(async () => {
173
243
  if (!isValid) return;
@@ -175,9 +245,6 @@ export function CaptureModal({
175
245
  setState("submitting");
176
246
  setError(null);
177
247
 
178
- const filename = sanitizeFilename(title) || "untitled";
179
- const relPath = `${filename}.md`;
180
-
181
248
  // Include tags in the POST request (server writes to frontmatter)
182
249
  const { data, error: err } = await apiFetch<CreateDocResponse>(
183
250
  "/api/docs",
@@ -185,8 +252,11 @@ export function CaptureModal({
185
252
  method: "POST",
186
253
  body: JSON.stringify({
187
254
  collection,
188
- relPath,
255
+ title,
256
+ folderPath: defaultFolderPath || undefined,
189
257
  content,
258
+ presetId: selectedPresetId || undefined,
259
+ collisionPolicy: "create_with_suffix",
190
260
  ...(tags.length > 0 && { tags }),
191
261
  }),
192
262
  }
@@ -207,7 +277,16 @@ export function CaptureModal({
207
277
  setCreatedUri(data.uri);
208
278
  onSuccess?.(data.uri);
209
279
  }
210
- }, [isValid, title, collection, content, tags, onSuccess]);
280
+ }, [
281
+ collection,
282
+ content,
283
+ defaultFolderPath,
284
+ isValid,
285
+ onSuccess,
286
+ selectedPresetId,
287
+ tags,
288
+ title,
289
+ ]);
211
290
 
212
291
  // Handle keyboard submit
213
292
  const handleKeyDown = useCallback(
@@ -249,16 +328,16 @@ export function CaptureModal({
249
328
 
250
329
  const handleCreateLinkedNote = useCallback(
251
330
  async (linkedTitle: string) => {
252
- const filename = sanitizeFilename(linkedTitle) || "untitled";
253
- const relPath = `${filename}.md`;
254
331
  const { data, error: err } = await apiFetch<CreateDocResponse>(
255
332
  "/api/docs",
256
333
  {
257
334
  method: "POST",
258
335
  body: JSON.stringify({
259
336
  collection,
260
- relPath,
337
+ title: linkedTitle,
338
+ folderPath: defaultFolderPath || undefined,
261
339
  content: `# ${linkedTitle}\n`,
340
+ collisionPolicy: "open_existing",
262
341
  }),
263
342
  }
264
343
  );
@@ -279,10 +358,11 @@ export function CaptureModal({
279
358
  ]);
280
359
  }
281
360
  },
282
- [collection, insertWikiLink]
361
+ [collection, defaultFolderPath, insertWikiLink]
283
362
  );
284
363
 
285
364
  const handleContentInput = useCallback((nextContent: string) => {
365
+ setContentTouched(true);
286
366
  setContent(nextContent);
287
367
  const cursorPos = contentRef.current?.selectionStart ?? nextContent.length;
288
368
  const activeQuery = getActiveWikiLinkQuery(nextContent, cursorPos);
@@ -414,6 +494,48 @@ export function CaptureModal({
414
494
  )}
415
495
  </div>
416
496
 
497
+ {/* Preset */}
498
+ <div>
499
+ <label
500
+ className="mb-1.5 block font-medium text-sm"
501
+ htmlFor="capture-preset"
502
+ >
503
+ Preset
504
+ </label>
505
+ <Select
506
+ disabled={state === "submitting"}
507
+ onValueChange={setSelectedPresetId}
508
+ value={selectedPresetId}
509
+ >
510
+ <SelectTrigger className="w-full" id="capture-preset">
511
+ <SelectValue placeholder="Select a preset" />
512
+ </SelectTrigger>
513
+ <SelectContent>
514
+ {NOTE_PRESETS.map((preset) => (
515
+ <SelectItem key={preset.id} value={preset.id}>
516
+ {preset.label}
517
+ </SelectItem>
518
+ ))}
519
+ </SelectContent>
520
+ </Select>
521
+ {getNotePreset(selectedPresetId)?.description && (
522
+ <p className="mt-1 text-muted-foreground text-xs">
523
+ {getNotePreset(selectedPresetId)?.description}
524
+ </p>
525
+ )}
526
+ </div>
527
+
528
+ {defaultFolderPath && (
529
+ <div className="rounded-lg border border-border/50 bg-muted/20 p-3">
530
+ <div className="mb-1 font-mono text-[10px] text-muted-foreground uppercase tracking-[0.14em]">
531
+ Target location
532
+ </div>
533
+ <div className="font-mono text-xs text-muted-foreground">
534
+ {collection || defaultCollection} / {defaultFolderPath}
535
+ </div>
536
+ </div>
537
+ )}
538
+
417
539
  {/* Tags */}
418
540
  <div>
419
541
  <span className="mb-1.5 block font-medium text-sm">