@gmickel/gno 0.25.2 → 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/README.md +5 -3
- package/assets/skill/SKILL.md +5 -0
- package/assets/skill/cli-reference.md +8 -6
- package/package.json +1 -1
- package/src/cli/commands/get.ts +21 -0
- package/src/cli/commands/skill/install.ts +2 -2
- package/src/cli/commands/skill/paths.ts +26 -4
- package/src/cli/commands/skill/uninstall.ts +2 -2
- package/src/cli/program.ts +18 -12
- package/src/core/document-capabilities.ts +113 -0
- package/src/mcp/tools/get.ts +10 -0
- package/src/mcp/tools/index.ts +434 -110
- 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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FilePlusIcon,
|
|
3
|
+
FileTextIcon,
|
|
4
|
+
Loader2Icon,
|
|
5
|
+
SearchIcon,
|
|
6
|
+
} from "lucide-react";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
|
|
9
|
+
import { apiFetch } from "../hooks/use-api";
|
|
10
|
+
import { buildDocDeepLink } from "../lib/deep-links";
|
|
11
|
+
import {
|
|
12
|
+
CommandDialog,
|
|
13
|
+
CommandEmpty,
|
|
14
|
+
CommandGroup,
|
|
15
|
+
CommandInput,
|
|
16
|
+
CommandItem,
|
|
17
|
+
CommandList,
|
|
18
|
+
CommandSeparator,
|
|
19
|
+
CommandShortcut,
|
|
20
|
+
} from "./ui/command";
|
|
21
|
+
|
|
22
|
+
interface SearchResult {
|
|
23
|
+
docid: string;
|
|
24
|
+
uri: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
snippet: string;
|
|
27
|
+
score: number;
|
|
28
|
+
snippetRange?: {
|
|
29
|
+
startLine: number;
|
|
30
|
+
endLine: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SearchResponse {
|
|
35
|
+
results: SearchResult[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RecentDoc {
|
|
39
|
+
uri: string;
|
|
40
|
+
href: string;
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const RECENT_DOCS_STORAGE_KEY = "gno.recent-docs";
|
|
45
|
+
|
|
46
|
+
export function saveRecentDocument(doc: RecentDoc): void {
|
|
47
|
+
const current = loadRecentDocuments().filter(
|
|
48
|
+
(entry) => entry.href !== doc.href
|
|
49
|
+
);
|
|
50
|
+
const next = [doc, ...current].slice(0, 8);
|
|
51
|
+
localStorage.setItem(RECENT_DOCS_STORAGE_KEY, JSON.stringify(next));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadRecentDocuments(): RecentDoc[] {
|
|
55
|
+
try {
|
|
56
|
+
const raw = localStorage.getItem(RECENT_DOCS_STORAGE_KEY);
|
|
57
|
+
if (!raw) return [];
|
|
58
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
59
|
+
if (!Array.isArray(parsed)) return [];
|
|
60
|
+
return parsed.filter((entry): entry is RecentDoc => {
|
|
61
|
+
if (!entry || typeof entry !== "object") return false;
|
|
62
|
+
const candidate = entry as Record<string, unknown>;
|
|
63
|
+
return (
|
|
64
|
+
typeof candidate.uri === "string" &&
|
|
65
|
+
typeof candidate.href === "string" &&
|
|
66
|
+
typeof candidate.label === "string"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface QuickSwitcherProps {
|
|
75
|
+
open: boolean;
|
|
76
|
+
onOpenChange: (open: boolean) => void;
|
|
77
|
+
navigate: (to: string) => void;
|
|
78
|
+
onCreateNote: (draftTitle?: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function QuickSwitcher({
|
|
82
|
+
open,
|
|
83
|
+
onOpenChange,
|
|
84
|
+
navigate,
|
|
85
|
+
onCreateNote,
|
|
86
|
+
}: QuickSwitcherProps) {
|
|
87
|
+
const [query, setQuery] = useState("");
|
|
88
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
89
|
+
const [recentDocs, setRecentDocs] = useState<RecentDoc[]>([]);
|
|
90
|
+
const [loading, setLoading] = useState(false);
|
|
91
|
+
const requestIdRef = useRef(0);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) {
|
|
95
|
+
setQuery("");
|
|
96
|
+
setResults([]);
|
|
97
|
+
setLoading(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setRecentDocs(loadRecentDocuments());
|
|
101
|
+
}, [open]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!open) return;
|
|
105
|
+
if (!query.trim()) {
|
|
106
|
+
setResults([]);
|
|
107
|
+
setLoading(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const currentRequestId = ++requestIdRef.current;
|
|
112
|
+
setLoading(true);
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
void apiFetch<SearchResponse>("/api/search", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
query,
|
|
118
|
+
limit: 8,
|
|
119
|
+
}),
|
|
120
|
+
}).then(({ data }) => {
|
|
121
|
+
if (currentRequestId !== requestIdRef.current) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
setResults(data?.results ?? []);
|
|
125
|
+
setLoading(false);
|
|
126
|
+
});
|
|
127
|
+
}, 120);
|
|
128
|
+
|
|
129
|
+
return () => clearTimeout(timer);
|
|
130
|
+
}, [open, query]);
|
|
131
|
+
|
|
132
|
+
const recentItems = useMemo(() => recentDocs.slice(0, 6), [recentDocs]);
|
|
133
|
+
|
|
134
|
+
const openTarget = useCallback(
|
|
135
|
+
(target: { uri: string; lineStart?: number; lineEnd?: number }) => {
|
|
136
|
+
navigate(
|
|
137
|
+
buildDocDeepLink({
|
|
138
|
+
uri: target.uri,
|
|
139
|
+
view: target.lineStart ? "source" : "rendered",
|
|
140
|
+
lineStart: target.lineStart,
|
|
141
|
+
lineEnd: target.lineEnd,
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
onOpenChange(false);
|
|
145
|
+
},
|
|
146
|
+
[navigate, onOpenChange]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const showCreateAction = query.trim().length > 0;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<CommandDialog
|
|
153
|
+
description="Jump to notes, open recent documents, or create a new note."
|
|
154
|
+
onOpenChange={onOpenChange}
|
|
155
|
+
open={open}
|
|
156
|
+
title="Quick Switcher"
|
|
157
|
+
>
|
|
158
|
+
<CommandInput
|
|
159
|
+
autoFocus
|
|
160
|
+
onValueChange={setQuery}
|
|
161
|
+
placeholder="Search notes or create a new one..."
|
|
162
|
+
value={query}
|
|
163
|
+
/>
|
|
164
|
+
<CommandList>
|
|
165
|
+
<CommandEmpty>
|
|
166
|
+
{loading ? "Searching..." : "No matching documents."}
|
|
167
|
+
</CommandEmpty>
|
|
168
|
+
|
|
169
|
+
{recentItems.length > 0 && !query.trim() && (
|
|
170
|
+
<CommandGroup heading="Recent">
|
|
171
|
+
{recentItems.map((item) => (
|
|
172
|
+
<CommandItem
|
|
173
|
+
key={item.href}
|
|
174
|
+
onSelect={() => {
|
|
175
|
+
navigate(item.href);
|
|
176
|
+
onOpenChange(false);
|
|
177
|
+
}}
|
|
178
|
+
value={item.label}
|
|
179
|
+
>
|
|
180
|
+
<FileTextIcon />
|
|
181
|
+
<span>{item.label}</span>
|
|
182
|
+
<CommandShortcut>Recent</CommandShortcut>
|
|
183
|
+
</CommandItem>
|
|
184
|
+
))}
|
|
185
|
+
</CommandGroup>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{showCreateAction && (
|
|
189
|
+
<>
|
|
190
|
+
<CommandGroup heading="Actions">
|
|
191
|
+
<CommandItem
|
|
192
|
+
onSelect={() => {
|
|
193
|
+
onOpenChange(false);
|
|
194
|
+
onCreateNote(query.trim());
|
|
195
|
+
}}
|
|
196
|
+
value={`create-${query}`}
|
|
197
|
+
>
|
|
198
|
+
<FilePlusIcon />
|
|
199
|
+
<span>Create new note</span>
|
|
200
|
+
<CommandShortcut>{query.trim()}</CommandShortcut>
|
|
201
|
+
</CommandItem>
|
|
202
|
+
</CommandGroup>
|
|
203
|
+
<CommandSeparator />
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{query.trim() && (
|
|
208
|
+
<CommandGroup heading="Documents">
|
|
209
|
+
{results.map((result) => (
|
|
210
|
+
<CommandItem
|
|
211
|
+
key={result.docid}
|
|
212
|
+
onSelect={() =>
|
|
213
|
+
openTarget({
|
|
214
|
+
uri: result.uri,
|
|
215
|
+
lineStart: result.snippetRange?.startLine,
|
|
216
|
+
lineEnd: result.snippetRange?.endLine,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
value={`${result.title ?? result.uri} ${result.uri}`}
|
|
220
|
+
>
|
|
221
|
+
{loading ? (
|
|
222
|
+
<Loader2Icon className="animate-spin" />
|
|
223
|
+
) : (
|
|
224
|
+
<SearchIcon />
|
|
225
|
+
)}
|
|
226
|
+
<div className="min-w-0 flex-1">
|
|
227
|
+
<div className="truncate">{result.title || result.uri}</div>
|
|
228
|
+
<div className="truncate text-muted-foreground text-xs">
|
|
229
|
+
{result.uri}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
{result.snippetRange && (
|
|
233
|
+
<CommandShortcut>
|
|
234
|
+
L{result.snippetRange.startLine}
|
|
235
|
+
{result.snippetRange.endLine !==
|
|
236
|
+
result.snippetRange.startLine
|
|
237
|
+
? `-${result.snippetRange.endLine}`
|
|
238
|
+
: ""}
|
|
239
|
+
</CommandShortcut>
|
|
240
|
+
)}
|
|
241
|
+
</CommandItem>
|
|
242
|
+
))}
|
|
243
|
+
</CommandGroup>
|
|
244
|
+
)}
|
|
245
|
+
</CommandList>
|
|
246
|
+
</CommandDialog>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -34,6 +34,7 @@ const shortcutGroups: ShortcutGroup[] = [
|
|
|
34
34
|
title: "Global",
|
|
35
35
|
shortcuts: [
|
|
36
36
|
{ keys: "N", description: "New note" },
|
|
37
|
+
{ keys: "Cmd+K", description: "Quick switcher" },
|
|
37
38
|
{ keys: "/", description: "Focus search" },
|
|
38
39
|
{ keys: "T", description: "Cycle search depth" },
|
|
39
40
|
{ keys: "?", description: "Show shortcuts" },
|
|
@@ -17,6 +17,8 @@ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
|
|
17
17
|
code: string;
|
|
18
18
|
language: BundledLanguage;
|
|
19
19
|
showLineNumbers?: boolean;
|
|
20
|
+
highlightedLines?: number[];
|
|
21
|
+
scrollToLine?: number;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
interface CodeBlockContextType {
|
|
@@ -27,35 +29,63 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
|
|
27
29
|
code: "",
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
32
|
+
function createLineTransformer(
|
|
33
|
+
showLineNumbers: boolean,
|
|
34
|
+
highlightedLines: number[]
|
|
35
|
+
): ShikiTransformer {
|
|
36
|
+
const highlighted = new Set(highlightedLines);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: "line-metadata",
|
|
40
|
+
line(node, line) {
|
|
41
|
+
const className = Array.isArray(node.properties.className)
|
|
42
|
+
? [...node.properties.className]
|
|
43
|
+
: [];
|
|
44
|
+
className.push("gno-code-line");
|
|
45
|
+
if (highlighted.has(line)) {
|
|
46
|
+
className.push(
|
|
47
|
+
"bg-amber-500/12",
|
|
48
|
+
"ring-1",
|
|
49
|
+
"ring-inset",
|
|
50
|
+
"ring-amber-500/25"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
node.properties.className = className;
|
|
54
|
+
node.properties["data-line-number"] = String(line);
|
|
55
|
+
|
|
56
|
+
if (!showLineNumbers) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
node.children.unshift({
|
|
61
|
+
type: "element",
|
|
62
|
+
tagName: "span",
|
|
63
|
+
properties: {
|
|
64
|
+
className: [
|
|
65
|
+
"inline-block",
|
|
66
|
+
"min-w-10",
|
|
67
|
+
"mr-4",
|
|
68
|
+
"text-right",
|
|
69
|
+
"select-none",
|
|
70
|
+
"text-muted-foreground",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
children: [{ type: "text", value: String(line) }],
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
50
78
|
|
|
51
79
|
export async function highlightCode(
|
|
52
80
|
code: string,
|
|
53
81
|
language: BundledLanguage,
|
|
54
|
-
showLineNumbers = false
|
|
82
|
+
showLineNumbers = false,
|
|
83
|
+
highlightedLines: number[] = []
|
|
55
84
|
) {
|
|
56
|
-
const transformers: ShikiTransformer[] =
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const transformers: ShikiTransformer[] =
|
|
86
|
+
showLineNumbers || highlightedLines.length > 0
|
|
87
|
+
? [createLineTransformer(showLineNumbers, highlightedLines)]
|
|
88
|
+
: [];
|
|
59
89
|
|
|
60
90
|
return await Promise.all([
|
|
61
91
|
codeToHtml(code, {
|
|
@@ -75,6 +105,8 @@ export const CodeBlock = ({
|
|
|
75
105
|
code,
|
|
76
106
|
language,
|
|
77
107
|
showLineNumbers = false,
|
|
108
|
+
highlightedLines = [],
|
|
109
|
+
scrollToLine,
|
|
78
110
|
className,
|
|
79
111
|
children,
|
|
80
112
|
...props
|
|
@@ -82,6 +114,7 @@ export const CodeBlock = ({
|
|
|
82
114
|
const [html, setHtml] = useState<string>("");
|
|
83
115
|
const [darkHtml, setDarkHtml] = useState<string>("");
|
|
84
116
|
const requestIdRef = useRef(0);
|
|
117
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
85
118
|
|
|
86
119
|
useEffect(() => {
|
|
87
120
|
let cancelled = false;
|
|
@@ -91,7 +124,8 @@ export const CodeBlock = ({
|
|
|
91
124
|
const [light, dark] = await highlightCode(
|
|
92
125
|
code,
|
|
93
126
|
language,
|
|
94
|
-
showLineNumbers
|
|
127
|
+
showLineNumbers,
|
|
128
|
+
highlightedLines
|
|
95
129
|
);
|
|
96
130
|
// Only apply if this is still the latest request AND not cancelled
|
|
97
131
|
if (!cancelled && requestId === requestIdRef.current) {
|
|
@@ -104,7 +138,20 @@ export const CodeBlock = ({
|
|
|
104
138
|
return () => {
|
|
105
139
|
cancelled = true;
|
|
106
140
|
};
|
|
107
|
-
}, [code, language, showLineNumbers]);
|
|
141
|
+
}, [code, highlightedLines, language, showLineNumbers]);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!scrollToLine) return;
|
|
145
|
+
|
|
146
|
+
const frame = requestAnimationFrame(() => {
|
|
147
|
+
const target = containerRef.current?.querySelector<HTMLElement>(
|
|
148
|
+
`[data-line-number="${scrollToLine}"]`
|
|
149
|
+
);
|
|
150
|
+
target?.scrollIntoView({ block: "center" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return () => cancelAnimationFrame(frame);
|
|
154
|
+
}, [darkHtml, html, scrollToLine]);
|
|
108
155
|
|
|
109
156
|
return (
|
|
110
157
|
<CodeBlockContext.Provider value={{ code }}>
|
|
@@ -113,6 +160,7 @@ export const CodeBlock = ({
|
|
|
113
160
|
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
|
114
161
|
className
|
|
115
162
|
)}
|
|
163
|
+
ref={containerRef}
|
|
116
164
|
{...props}
|
|
117
165
|
>
|
|
118
166
|
<div className="relative">
|
|
@@ -36,6 +36,16 @@ export interface CodeMirrorEditorRef {
|
|
|
36
36
|
insertAtCursor: (text: string) => void;
|
|
37
37
|
/** Scroll to percentage position (0-1). Returns true if scroll actually changed. */
|
|
38
38
|
scrollToPercent: (percent: number) => boolean;
|
|
39
|
+
/** Reveal a 1-based line number and select it. */
|
|
40
|
+
revealLine: (lineNumber: number) => boolean;
|
|
41
|
+
/** Current cursor position and screen coordinates. */
|
|
42
|
+
getCursorInfo: () => {
|
|
43
|
+
pos: number;
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
} | null;
|
|
47
|
+
/** Replace a text range and place the cursor at the end. */
|
|
48
|
+
replaceRange: (from: number, to: number, text: string) => boolean;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export function CodeMirrorEditor({
|
|
@@ -179,6 +189,47 @@ export function CodeMirrorEditor({
|
|
|
179
189
|
}
|
|
180
190
|
return false;
|
|
181
191
|
},
|
|
192
|
+
revealLine: (lineNumber: number): boolean => {
|
|
193
|
+
const view = viewRef.current;
|
|
194
|
+
if (!view || !Number.isFinite(lineNumber)) return false;
|
|
195
|
+
|
|
196
|
+
const clampedLine = Math.max(
|
|
197
|
+
1,
|
|
198
|
+
Math.min(Math.trunc(lineNumber), view.state.doc.lines)
|
|
199
|
+
);
|
|
200
|
+
const line = view.state.doc.line(clampedLine);
|
|
201
|
+
view.dispatch({
|
|
202
|
+
selection: { anchor: line.from, head: line.to },
|
|
203
|
+
scrollIntoView: true,
|
|
204
|
+
});
|
|
205
|
+
view.focus();
|
|
206
|
+
return true;
|
|
207
|
+
},
|
|
208
|
+
getCursorInfo: () => {
|
|
209
|
+
const view = viewRef.current;
|
|
210
|
+
if (!view) return null;
|
|
211
|
+
|
|
212
|
+
const pos = view.state.selection.main.head;
|
|
213
|
+
const coords = view.coordsAtPos(pos);
|
|
214
|
+
if (!coords) return null;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
pos,
|
|
218
|
+
x: coords.left,
|
|
219
|
+
y: coords.bottom,
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
replaceRange: (from: number, to: number, text: string): boolean => {
|
|
223
|
+
const view = viewRef.current;
|
|
224
|
+
if (!view) return false;
|
|
225
|
+
|
|
226
|
+
view.dispatch({
|
|
227
|
+
changes: { from, to, insert: text },
|
|
228
|
+
selection: { anchor: from + text.length },
|
|
229
|
+
});
|
|
230
|
+
view.focus();
|
|
231
|
+
return true;
|
|
232
|
+
},
|
|
182
233
|
}));
|
|
183
234
|
|
|
184
235
|
return <div ref={containerRef} className={className} />;
|
|
@@ -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
|
+
}
|