@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.
- package/assets/skill/SKILL.md +5 -0
- package/package.json +1 -1
- package/src/cli/commands/get.ts +21 -0
- package/src/core/document-capabilities.ts +113 -0
- package/src/mcp/tools/get.ts +10 -0
- package/src/sdk/documents.ts +12 -0
- package/src/serve/doc-events.ts +69 -0
- package/src/serve/public/app.tsx +81 -24
- package/src/serve/public/components/CaptureModal.tsx +138 -3
- package/src/serve/public/components/QuickSwitcher.tsx +248 -0
- package/src/serve/public/components/ShortcutHelpModal.tsx +1 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +74 -26
- package/src/serve/public/components/editor/CodeMirrorEditor.tsx +51 -0
- package/src/serve/public/components/ui/command.tsx +2 -2
- package/src/serve/public/hooks/use-doc-events.ts +34 -0
- package/src/serve/public/hooks/useCaptureModal.tsx +12 -3
- package/src/serve/public/hooks/useKeyboardShortcuts.ts +2 -2
- package/src/serve/public/lib/deep-links.ts +68 -0
- package/src/serve/public/lib/document-availability.ts +22 -0
- package/src/serve/public/lib/local-history.ts +44 -0
- package/src/serve/public/lib/wiki-link.ts +36 -0
- package/src/serve/public/pages/Browse.tsx +11 -0
- package/src/serve/public/pages/Dashboard.tsx +2 -2
- package/src/serve/public/pages/DocView.tsx +241 -18
- package/src/serve/public/pages/DocumentEditor.tsx +399 -9
- package/src/serve/public/pages/Search.tsx +20 -1
- package/src/serve/routes/api.ts +359 -28
- package/src/serve/server.ts +48 -1
- package/src/serve/watch-service.ts +149 -0
|
@@ -65,13 +65,13 @@ function CommandInput({
|
|
|
65
65
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
66
66
|
return (
|
|
67
67
|
<div
|
|
68
|
-
className="flex h-
|
|
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-
|
|
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(() =>
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|