@gmickel/gno 0.34.0 → 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.
@@ -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">
@@ -8,8 +8,12 @@ import {
8
8
  } from "lucide-react";
9
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
10
 
11
+ import type { DocumentSection } from "../../../core/sections";
12
+ import type { CaptureModalOpenOptions } from "../hooks/useCaptureModal";
13
+
14
+ import { NOTE_PRESETS } from "../../../core/note-presets";
11
15
  import { apiFetch } from "../hooks/use-api";
12
- import { buildDocDeepLink } from "../lib/deep-links";
16
+ import { buildDocDeepLink, parseDocumentDeepLink } from "../lib/deep-links";
13
17
  import {
14
18
  loadFavoriteCollections,
15
19
  loadFavoriteDocuments,
@@ -18,6 +22,10 @@ import {
18
22
  type FavoriteDoc,
19
23
  type RecentDoc,
20
24
  } from "../lib/navigation-state";
25
+ import {
26
+ getWorkspaceActions,
27
+ runWorkspaceAction,
28
+ } from "../lib/workspace-actions";
21
29
  import {
22
30
  CommandDialog,
23
31
  CommandEmpty,
@@ -45,14 +53,25 @@ interface SearchResponse {
45
53
  results: SearchResult[];
46
54
  }
47
55
 
56
+ interface DocLookupResponse {
57
+ docid: string;
58
+ uri: string;
59
+ }
60
+
61
+ interface SectionsResponse {
62
+ sections: DocumentSection[];
63
+ }
64
+
48
65
  export interface QuickSwitcherProps {
66
+ location: string;
49
67
  open: boolean;
50
68
  onOpenChange: (open: boolean) => void;
51
69
  navigate: (to: string) => void;
52
- onCreateNote: (draftTitle?: string) => void;
70
+ onCreateNote: (options?: string | CaptureModalOpenOptions) => void;
53
71
  }
54
72
 
55
73
  export function QuickSwitcher({
74
+ location,
56
75
  open,
57
76
  onOpenChange,
58
77
  navigate,
@@ -65,6 +84,7 @@ export function QuickSwitcher({
65
84
  const [favoriteCollections, setFavoriteCollections] = useState<
66
85
  FavoriteCollection[]
67
86
  >([]);
87
+ const [sections, setSections] = useState<DocumentSection[]>([]);
68
88
  const [loading, setLoading] = useState(false);
69
89
  const requestIdRef = useRef(0);
70
90
 
@@ -80,6 +100,35 @@ export function QuickSwitcher({
80
100
  setFavoriteCollections(loadFavoriteCollections());
81
101
  }, [open]);
82
102
 
103
+ useEffect(() => {
104
+ if (!open || !location.startsWith("/doc?")) {
105
+ setSections([]);
106
+ return;
107
+ }
108
+
109
+ const target = parseDocumentDeepLink(
110
+ location.includes("?") ? `?${location.split("?")[1] ?? ""}` : ""
111
+ );
112
+ if (!target.uri) {
113
+ setSections([]);
114
+ return;
115
+ }
116
+
117
+ void apiFetch<DocLookupResponse>(
118
+ `/api/doc?uri=${encodeURIComponent(target.uri)}`
119
+ ).then(({ data }) => {
120
+ if (!data?.docid) {
121
+ setSections([]);
122
+ return;
123
+ }
124
+ void apiFetch<SectionsResponse>(
125
+ `/api/doc/${encodeURIComponent(data.docid)}/sections`
126
+ ).then(({ data: sectionData }) => {
127
+ setSections(sectionData?.sections ?? []);
128
+ });
129
+ });
130
+ }, [location, open]);
131
+
83
132
  useEffect(() => {
84
133
  if (!open) return;
85
134
  if (!query.trim()) {
@@ -118,6 +167,31 @@ export function QuickSwitcher({
118
167
  () => favoriteCollections.slice(0, 6),
119
168
  [favoriteCollections]
120
169
  );
170
+ const workspaceActions = useMemo(
171
+ () => getWorkspaceActions({ location }),
172
+ [location]
173
+ );
174
+ const exactResult = useMemo(() => {
175
+ const normalizedQuery = query.trim().toLowerCase();
176
+ if (!normalizedQuery) {
177
+ return null;
178
+ }
179
+
180
+ return (
181
+ results.find((result) => {
182
+ const normalizedTitle = (result.title ?? "").trim().toLowerCase();
183
+ const normalizedLeaf = decodeURIComponent(
184
+ result.uri.split("/").pop() ?? ""
185
+ )
186
+ .replace(/\.[^.]+$/u, "")
187
+ .toLowerCase();
188
+ return (
189
+ normalizedTitle === normalizedQuery ||
190
+ normalizedLeaf === normalizedQuery
191
+ );
192
+ }) ?? null
193
+ );
194
+ }, [query, results]);
121
195
 
122
196
  const openTarget = useCallback(
123
197
  (target: { uri: string; lineStart?: number; lineEnd?: number }) => {
@@ -134,7 +208,32 @@ export function QuickSwitcher({
134
208
  [navigate, onOpenChange]
135
209
  );
136
210
 
137
- const showCreateAction = query.trim().length > 0;
211
+ const showCreateAction = true;
212
+ const actionHandlers = useMemo(
213
+ () => ({
214
+ navigate,
215
+ openCapture: onCreateNote,
216
+ closePalette: () => onOpenChange(false),
217
+ }),
218
+ [navigate, onCreateNote, onOpenChange]
219
+ );
220
+ const filteredPresetActions = useMemo(() => {
221
+ const normalizedQuery = query.trim().toLowerCase();
222
+ return NOTE_PRESETS.filter((preset) => preset.id !== "blank").filter(
223
+ (preset) =>
224
+ !normalizedQuery ||
225
+ preset.label.toLowerCase().includes(normalizedQuery) ||
226
+ preset.description.toLowerCase().includes(normalizedQuery)
227
+ );
228
+ }, [query]);
229
+ const filteredSections = useMemo(() => {
230
+ const normalizedQuery = query.trim().toLowerCase();
231
+ return sections.filter(
232
+ (section) =>
233
+ !normalizedQuery ||
234
+ section.title.toLowerCase().includes(normalizedQuery)
235
+ );
236
+ }, [query, sections]);
138
237
 
139
238
  return (
140
239
  <CommandDialog
@@ -214,10 +313,24 @@ export function QuickSwitcher({
214
313
  {showCreateAction && (
215
314
  <>
216
315
  <CommandGroup heading="Actions">
316
+ {exactResult && (
317
+ <CommandItem
318
+ onSelect={() =>
319
+ openTarget({
320
+ uri: exactResult.uri,
321
+ })
322
+ }
323
+ value={`open-exact-${query}`}
324
+ >
325
+ <FileTextIcon />
326
+ <span>Open exact match</span>
327
+ <CommandShortcut>Exact</CommandShortcut>
328
+ </CommandItem>
329
+ )}
217
330
  <CommandItem
218
331
  onSelect={() => {
219
332
  onOpenChange(false);
220
- onCreateNote(query.trim());
333
+ onCreateNote({ draftTitle: query.trim() });
221
334
  }}
222
335
  value={`create-${query}`}
223
336
  >
@@ -225,11 +338,122 @@ export function QuickSwitcher({
225
338
  <span>Create new note</span>
226
339
  <CommandShortcut>{query.trim()}</CommandShortcut>
227
340
  </CommandItem>
341
+ {workspaceActions
342
+ .filter((action) =>
343
+ [
344
+ "new-note-in-context",
345
+ "create-folder-here",
346
+ "rename-current-note",
347
+ "move-current-note",
348
+ "duplicate-current-note",
349
+ ].includes(action.id)
350
+ )
351
+ .map((action) => (
352
+ <CommandItem
353
+ disabled={!action.available}
354
+ key={action.id}
355
+ onSelect={() =>
356
+ runWorkspaceAction(
357
+ action,
358
+ { location },
359
+ actionHandlers,
360
+ query.trim()
361
+ )
362
+ }
363
+ value={`${action.label} ${action.keywords.join(" ")} ${query}`}
364
+ >
365
+ <FolderIcon />
366
+ <span>{action.label}</span>
367
+ <CommandShortcut>
368
+ {action.id === "new-note-in-context"
369
+ ? "Context"
370
+ : "Action"}
371
+ </CommandShortcut>
372
+ </CommandItem>
373
+ ))}
228
374
  </CommandGroup>
229
375
  <CommandSeparator />
230
376
  </>
231
377
  )}
232
378
 
379
+ <CommandGroup heading="Go To">
380
+ {workspaceActions
381
+ .filter((action) => action.group === "Go To" && action.available)
382
+ .map((action) => (
383
+ <CommandItem
384
+ key={action.id}
385
+ onSelect={() =>
386
+ runWorkspaceAction(
387
+ action,
388
+ { location },
389
+ actionHandlers,
390
+ query.trim()
391
+ )
392
+ }
393
+ value={`${action.label} ${action.keywords.join(" ")}`}
394
+ >
395
+ <FolderIcon />
396
+ <span>{action.label}</span>
397
+ </CommandItem>
398
+ ))}
399
+ </CommandGroup>
400
+
401
+ {filteredPresetActions.length > 0 && (
402
+ <CommandGroup heading="Presets">
403
+ {filteredPresetActions.map((preset) => (
404
+ <CommandItem
405
+ key={preset.id}
406
+ onSelect={() => {
407
+ onCreateNote({
408
+ draftTitle: query.trim() || undefined,
409
+ presetId: preset.id,
410
+ });
411
+ onOpenChange(false);
412
+ }}
413
+ value={`${preset.label} ${preset.description} preset`}
414
+ >
415
+ <FilePlusIcon />
416
+ <span>{preset.label}</span>
417
+ <CommandShortcut>Preset</CommandShortcut>
418
+ </CommandItem>
419
+ ))}
420
+ </CommandGroup>
421
+ )}
422
+
423
+ {filteredSections.length > 0 && (
424
+ <CommandGroup heading="Sections">
425
+ {filteredSections.map((section) => {
426
+ const target = parseDocumentDeepLink(
427
+ location.includes("?") ? `?${location.split("?")[1] ?? ""}` : ""
428
+ );
429
+ if (!target.uri) {
430
+ return null;
431
+ }
432
+ return (
433
+ <CommandItem
434
+ key={section.anchor}
435
+ onSelect={() => {
436
+ navigate(
437
+ `${buildDocDeepLink({
438
+ uri: target.uri,
439
+ view: "rendered",
440
+ })}#${section.anchor}`
441
+ );
442
+ onOpenChange(false);
443
+ }}
444
+ value={`${section.title} section heading outline`}
445
+ >
446
+ <SearchIcon />
447
+ <span>{section.title}</span>
448
+ <CommandShortcut>{`H${section.level}`}</CommandShortcut>
449
+ </CommandItem>
450
+ );
451
+ })}
452
+ </CommandGroup>
453
+ )}
454
+
455
+ {!query.trim() && <CommandSeparator />}
456
+
233
457
  {query.trim() && (
234
458
  <CommandGroup heading="Documents">
235
459
  {results.map((result) => (
@@ -29,12 +29,17 @@ interface ShortcutGroup {
29
29
  shortcuts: ShortcutItem[];
30
30
  }
31
31
 
32
+ interface CommandExampleGroup {
33
+ title: string;
34
+ commands: string[];
35
+ }
36
+
32
37
  const shortcutGroups: ShortcutGroup[] = [
33
38
  {
34
39
  title: "Global",
35
40
  shortcuts: [
36
41
  { keys: "N", description: "New note" },
37
- { keys: "Cmd+K", description: "Quick switcher" },
42
+ { keys: "Cmd+K", description: "Command palette" },
38
43
  { keys: "/", description: "Focus search" },
39
44
  { keys: "T", description: "Cycle search depth" },
40
45
  { keys: "?", description: "Show shortcuts" },
@@ -59,6 +64,29 @@ const footerShortcut: ShortcutItem = {
59
64
  description: "Submit form",
60
65
  };
61
66
 
67
+ const commandExamples: CommandExampleGroup[] = [
68
+ {
69
+ title: "Create",
70
+ commands: [
71
+ "new note",
72
+ "new note in current location",
73
+ "create folder here",
74
+ "Project Note",
75
+ "Research Note",
76
+ ],
77
+ },
78
+ {
79
+ title: "Current Note",
80
+ commands: [
81
+ "rename current note",
82
+ "move current note",
83
+ "duplicate current note",
84
+ "Intro",
85
+ "Details",
86
+ ],
87
+ },
88
+ ];
89
+
62
90
  function KeyCombo({ keys }: { keys: string }) {
63
91
  const parts = keys.split("+");
64
92
 
@@ -202,6 +230,31 @@ export function ShortcutHelpModal({
202
230
  </div>
203
231
  <KeyCombo keys={footerShortcut.keys} />
204
232
  </div>
233
+
234
+ <div className="mt-4 space-y-3 rounded-md border border-[hsl(var(--secondary)/0.12)] bg-[hsl(var(--secondary)/0.03)] p-3">
235
+ <div className="font-mono text-[10px] uppercase tracking-[0.15em] text-[hsl(var(--secondary)/0.7)]">
236
+ Command Palette Examples
237
+ </div>
238
+ <div className="grid grid-cols-2 gap-4">
239
+ {commandExamples.map((group) => (
240
+ <div className="space-y-1" key={group.title}>
241
+ <div className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground/60">
242
+ {group.title}
243
+ </div>
244
+ <div className="space-y-1">
245
+ {group.commands.map((command) => (
246
+ <div
247
+ className="rounded px-2 py-1 font-mono text-[11px] text-muted-foreground/80"
248
+ key={command}
249
+ >
250
+ {command}
251
+ </div>
252
+ ))}
253
+ </div>
254
+ </div>
255
+ ))}
256
+ </div>
257
+ </div>
205
258
  </DialogContent>
206
259
  </Dialog>
207
260
  );