@gmickel/gno 0.26.0 → 0.27.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.
@@ -65,13 +65,13 @@ function CommandInput({
65
65
  }: React.ComponentProps<typeof CommandPrimitive.Input>) {
66
66
  return (
67
67
  <div
68
- className="flex h-9 items-center gap-2 border-b px-3"
68
+ className="flex h-11 items-center gap-2 rounded-t-md border-border/60 border-b bg-muted/10 px-3 focus-within:bg-background/80"
69
69
  data-slot="command-input-wrapper"
70
70
  >
71
71
  <SearchIcon className="size-4 shrink-0 opacity-50" />
72
72
  <CommandPrimitive.Input
73
73
  className={cn(
74
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
74
+ "flex h-10 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none ring-0 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
75
75
  className
76
76
  )}
77
77
  data-slot="command-input"
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export interface DocumentEvent {
4
+ type: "document-changed";
5
+ uri: string;
6
+ collection: string;
7
+ relPath: string;
8
+ origin: "watcher" | "save" | "create";
9
+ changedAt: string;
10
+ }
11
+
12
+ export function useDocEvents(): DocumentEvent | null {
13
+ const [event, setEvent] = useState<DocumentEvent | null>(null);
14
+
15
+ useEffect(() => {
16
+ const source = new EventSource("/api/events");
17
+ const handleEvent = (incoming: Event) => {
18
+ const message = incoming as MessageEvent<string>;
19
+ try {
20
+ setEvent(JSON.parse(message.data) as DocumentEvent);
21
+ } catch {
22
+ // Ignore malformed event payloads.
23
+ }
24
+ };
25
+
26
+ source.addEventListener("document-changed", handleEvent);
27
+ return () => {
28
+ source.removeEventListener("document-changed", handleEvent);
29
+ source.close();
30
+ };
31
+ }, []);
32
+
33
+ return event;
34
+ }
@@ -21,7 +21,7 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
21
21
 
22
22
  interface CaptureModalContextValue {
23
23
  /** Open the capture modal */
24
- openCapture: () => void;
24
+ openCapture: (draftTitle?: string) => void;
25
25
  /** Whether the modal is open */
26
26
  isOpen: boolean;
27
27
  }
@@ -41,8 +41,12 @@ export function CaptureModalProvider({
41
41
  onSuccess,
42
42
  }: CaptureModalProviderProps) {
43
43
  const [open, setOpen] = useState(false);
44
+ const [draftTitle, setDraftTitle] = useState("");
44
45
 
45
- const openCapture = useCallback(() => setOpen(true), []);
46
+ const openCapture = useCallback((nextDraftTitle?: string) => {
47
+ setDraftTitle(nextDraftTitle ?? "");
48
+ setOpen(true);
49
+ }, []);
46
50
 
47
51
  // 'n' global shortcut (single-key, skips when in text input)
48
52
  const shortcuts = useMemo(
@@ -68,7 +72,12 @@ export function CaptureModalProvider({
68
72
  return (
69
73
  <CaptureModalContext.Provider value={value}>
70
74
  {children}
71
- <CaptureModal onOpenChange={setOpen} onSuccess={onSuccess} open={open} />
75
+ <CaptureModal
76
+ draftTitle={draftTitle}
77
+ onOpenChange={setOpen}
78
+ onSuccess={onSuccess}
79
+ open={open}
80
+ />
72
81
  </CaptureModalContext.Provider>
73
82
  );
74
83
  }
@@ -72,8 +72,8 @@ export function useKeyboardShortcuts(shortcuts: Shortcut[]): void {
72
72
 
73
73
  // Modifier handling
74
74
  if (shortcut.meta) {
75
- // Require Ctrl for meta shortcuts
76
- if (!e.ctrlKey) continue;
75
+ // Allow Ctrl or Cmd for cross-platform meta shortcuts
76
+ if (!e.ctrlKey && !e.metaKey) continue;
77
77
  } else {
78
78
  // Single-key shortcuts: don't fire when any modifier held
79
79
  // Prevents hijacking Cmd+N on macOS, Ctrl+K on Windows, etc.
@@ -0,0 +1,68 @@
1
+ export interface DocumentDeepLinkTarget {
2
+ uri: string;
3
+ view?: "rendered" | "source";
4
+ lineStart?: number;
5
+ lineEnd?: number;
6
+ }
7
+
8
+ function parsePositiveInteger(value: string | null): number | undefined {
9
+ if (!value) return;
10
+ const parsed = Number.parseInt(value, 10);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ return;
13
+ }
14
+ return parsed;
15
+ }
16
+
17
+ export function buildDocDeepLink(target: DocumentDeepLinkTarget): string {
18
+ const params = new URLSearchParams({ uri: target.uri });
19
+
20
+ if (target.view) {
21
+ params.set("view", target.view);
22
+ }
23
+ if (target.lineStart) {
24
+ params.set("lineStart", String(target.lineStart));
25
+ }
26
+ if (
27
+ target.lineStart !== undefined &&
28
+ target.lineEnd !== undefined &&
29
+ target.lineEnd >= target.lineStart
30
+ ) {
31
+ params.set("lineEnd", String(target.lineEnd));
32
+ }
33
+
34
+ return `/doc?${params.toString()}`;
35
+ }
36
+
37
+ export function buildEditDeepLink(target: DocumentDeepLinkTarget): string {
38
+ const params = new URLSearchParams({ uri: target.uri });
39
+
40
+ if (target.lineStart) {
41
+ params.set("lineStart", String(target.lineStart));
42
+ }
43
+ if (
44
+ target.lineStart !== undefined &&
45
+ target.lineEnd !== undefined &&
46
+ target.lineEnd >= target.lineStart
47
+ ) {
48
+ params.set("lineEnd", String(target.lineEnd));
49
+ }
50
+
51
+ return `/edit?${params.toString()}`;
52
+ }
53
+
54
+ export function parseDocumentDeepLink(search: string): DocumentDeepLinkTarget {
55
+ const params = new URLSearchParams(search);
56
+ const uri = params.get("uri") ?? "";
57
+ const viewParam = params.get("view");
58
+ const view = viewParam === "source" ? "source" : "rendered";
59
+ const lineStart = parsePositiveInteger(params.get("lineStart"));
60
+ const lineEnd = parsePositiveInteger(params.get("lineEnd"));
61
+
62
+ return {
63
+ uri,
64
+ view,
65
+ lineStart,
66
+ lineEnd,
67
+ };
68
+ }
@@ -0,0 +1,22 @@
1
+ import { apiFetch } from "../hooks/use-api";
2
+
3
+ interface DocAvailabilityResponse {
4
+ uri: string;
5
+ }
6
+
7
+ export async function waitForDocumentAvailability(
8
+ uri: string,
9
+ attempts = 20,
10
+ delayMs = 250
11
+ ): Promise<boolean> {
12
+ for (let i = 0; i < attempts; i += 1) {
13
+ const { data } = await apiFetch<DocAvailabilityResponse>(
14
+ `/api/doc?uri=${encodeURIComponent(uri)}`
15
+ );
16
+ if (data?.uri === uri) {
17
+ return true;
18
+ }
19
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
20
+ }
21
+ return false;
22
+ }
@@ -0,0 +1,44 @@
1
+ export interface LocalHistoryEntry {
2
+ savedAt: string;
3
+ content: string;
4
+ }
5
+
6
+ const HISTORY_PREFIX = "gno.doc-history.";
7
+ const MAX_ENTRIES = 10;
8
+
9
+ function getHistoryKey(docId: string): string {
10
+ return `${HISTORY_PREFIX}${docId}`;
11
+ }
12
+
13
+ export function loadLocalHistory(docId: string): LocalHistoryEntry[] {
14
+ try {
15
+ const raw = localStorage.getItem(getHistoryKey(docId));
16
+ if (!raw) return [];
17
+ const parsed = JSON.parse(raw) as unknown;
18
+ if (!Array.isArray(parsed)) return [];
19
+ return parsed.filter((entry): entry is LocalHistoryEntry => {
20
+ if (!entry || typeof entry !== "object") return false;
21
+ const candidate = entry as Record<string, unknown>;
22
+ return (
23
+ typeof candidate.savedAt === "string" &&
24
+ typeof candidate.content === "string"
25
+ );
26
+ });
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function appendLocalHistory(docId: string, content: string): void {
33
+ const next = [
34
+ { savedAt: new Date().toISOString(), content },
35
+ ...loadLocalHistory(docId).filter((entry) => entry.content !== content),
36
+ ].slice(0, MAX_ENTRIES);
37
+ localStorage.setItem(getHistoryKey(docId), JSON.stringify(next));
38
+ }
39
+
40
+ export function loadLatestLocalHistory(
41
+ docId: string
42
+ ): LocalHistoryEntry | undefined {
43
+ return loadLocalHistory(docId)[0];
44
+ }
@@ -0,0 +1,36 @@
1
+ export interface WikiLinkQuery {
2
+ query: string;
3
+ start: number;
4
+ end: number;
5
+ }
6
+
7
+ export function getActiveWikiLinkQuery(
8
+ content: string,
9
+ cursorPos: number
10
+ ): WikiLinkQuery | null {
11
+ if (cursorPos < 2) {
12
+ return null;
13
+ }
14
+
15
+ const prefix = content.slice(0, cursorPos);
16
+ const start = prefix.lastIndexOf("[[");
17
+ if (start === -1) {
18
+ return null;
19
+ }
20
+
21
+ const closing = prefix.indexOf("]]", start);
22
+ if (closing !== -1) {
23
+ return null;
24
+ }
25
+
26
+ const query = prefix.slice(start + 2);
27
+ if (query.includes("\n")) {
28
+ return null;
29
+ }
30
+
31
+ return {
32
+ query,
33
+ start,
34
+ end: cursorPos,
35
+ };
36
+ }
@@ -27,6 +27,7 @@ import {
27
27
  TableRow,
28
28
  } from "../components/ui/table";
29
29
  import { apiFetch } from "../hooks/use-api";
30
+ import { useDocEvents } from "../hooks/use-doc-events";
30
31
 
31
32
  interface PageProps {
32
33
  navigate: (to: string | number) => void;
@@ -77,6 +78,7 @@ export default function Browse({ navigate }: PageProps) {
77
78
  const [syncTarget, setSyncTarget] = useState<SyncTarget>(null);
78
79
  const [syncError, setSyncError] = useState<string | null>(null);
79
80
  const [refreshToken, setRefreshToken] = useState(0);
81
+ const latestDocEvent = useDocEvents();
80
82
  const limit = 25;
81
83
 
82
84
  // Parse collection from URL on mount
@@ -134,6 +136,15 @@ export default function Browse({ navigate }: PageProps) {
134
136
  setDocs([]);
135
137
  }, [availableDateFields, sortField]);
136
138
 
139
+ useEffect(() => {
140
+ if (!latestDocEvent?.changedAt) {
141
+ return;
142
+ }
143
+ setOffset(0);
144
+ setDocs([]);
145
+ setRefreshToken((current) => current + 1);
146
+ }, [latestDocEvent?.changedAt]);
147
+
137
148
  const handleCollectionChange = (value: string) => {
138
149
  const newSelected = value === "all" ? "" : value;
139
150
  setSelected(newSelected);
@@ -271,7 +271,7 @@ export default function Dashboard({ navigate }: PageProps) {
271
271
  {/* Quick Capture Card */}
272
272
  <Card
273
273
  className="group stagger-3 animate-fade-in cursor-pointer opacity-0 transition-all duration-200 hover:-translate-y-0.5 hover:border-secondary/50 hover:bg-secondary/5 hover:shadow-lg"
274
- onClick={openCapture}
274
+ onClick={() => openCapture()}
275
275
  >
276
276
  <CardHeader className="pb-2">
277
277
  <CardDescription className="flex items-center gap-2">
@@ -350,7 +350,7 @@ export default function Dashboard({ navigate }: PageProps) {
350
350
  </main>
351
351
 
352
352
  {/* Floating Action Button */}
353
- <CaptureButton onClick={openCapture} />
353
+ <CaptureButton onClick={() => openCapture()} />
354
354
  </div>
355
355
  );
356
356
  }