newpr 0.1.3 → 0.3.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/package.json +11 -1
- package/src/analyzer/pipeline.ts +37 -15
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +53 -1
- package/src/history/store.ts +107 -1
- package/src/history/types.ts +1 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +80 -19
- package/src/llm/response-parser.ts +13 -1
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +14 -0
- package/src/types/output.ts +50 -0
- package/src/web/client/App.tsx +33 -5
- package/src/web/client/components/AppShell.tsx +107 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +217 -77
- package/src/web/client/components/DiffViewer.tsx +713 -0
- package/src/web/client/components/InputScreen.tsx +178 -27
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +220 -41
- package/src/web/client/components/ResultsScreen.tsx +109 -73
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +62 -40
- package/src/web/client/panels/StoryPanel.tsx +43 -23
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +856 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +66 -4
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
|
-
import { X, Check, Loader2
|
|
3
|
-
import { Button } from "../../components/ui/button.tsx";
|
|
2
|
+
import { X, Check, Loader2 } from "lucide-react";
|
|
4
3
|
|
|
5
4
|
interface ConfigData {
|
|
6
5
|
model: string;
|
|
@@ -30,7 +29,7 @@ const MODELS = [
|
|
|
30
29
|
|
|
31
30
|
const AGENTS = [
|
|
32
31
|
{ value: "", label: "Auto" },
|
|
33
|
-
{ value: "claude", label: "Claude
|
|
32
|
+
{ value: "claude", label: "Claude" },
|
|
34
33
|
{ value: "opencode", label: "OpenCode" },
|
|
35
34
|
{ value: "codex", label: "Codex" },
|
|
36
35
|
];
|
|
@@ -76,109 +75,114 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
76
75
|
if (!config) {
|
|
77
76
|
return (
|
|
78
77
|
<div className="flex items-center justify-center py-20">
|
|
79
|
-
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
78
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
|
|
80
79
|
</div>
|
|
81
80
|
);
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
return (
|
|
85
|
-
<div
|
|
86
|
-
<div className="flex items-center justify-between
|
|
87
|
-
<h2 className="text-
|
|
84
|
+
<div>
|
|
85
|
+
<div className="flex items-center justify-between mb-6">
|
|
86
|
+
<h2 className="text-xs font-semibold">Settings</h2>
|
|
88
87
|
<div className="flex items-center gap-2">
|
|
89
|
-
{saving && <Loader2 className="h-3
|
|
90
|
-
{saved && <Check className="h-3
|
|
88
|
+
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />}
|
|
89
|
+
{saved && <Check className="h-3 w-3 text-green-500" />}
|
|
91
90
|
<button
|
|
92
91
|
type="button"
|
|
93
92
|
onClick={onClose}
|
|
94
|
-
className="rounded-md
|
|
93
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/40 transition-colors"
|
|
95
94
|
>
|
|
96
|
-
<X className="h-
|
|
95
|
+
<X className="h-3.5 w-3.5" />
|
|
97
96
|
</button>
|
|
98
97
|
</div>
|
|
99
98
|
</div>
|
|
100
99
|
|
|
101
|
-
<div className="space-y-
|
|
102
|
-
<Section
|
|
100
|
+
<div className="space-y-6">
|
|
101
|
+
<Section title="Authentication">
|
|
103
102
|
<Row label="OpenRouter API Key">
|
|
104
103
|
{showApiKeyField ? (
|
|
105
|
-
<div className="flex gap-
|
|
104
|
+
<div className="flex gap-1.5">
|
|
106
105
|
<input
|
|
107
106
|
type="password"
|
|
108
107
|
value={apiKeyInput}
|
|
109
108
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
|
110
109
|
placeholder="sk-or-..."
|
|
111
|
-
className="flex-1 h-
|
|
110
|
+
className="flex-1 h-7 rounded-md border bg-background px-2.5 text-[11px] font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:border-foreground/20"
|
|
112
111
|
autoFocus
|
|
112
|
+
onKeyDown={(e) => {
|
|
113
|
+
if (e.key === "Enter" && apiKeyInput.trim()) {
|
|
114
|
+
save({ openrouter_api_key: apiKeyInput.trim() });
|
|
115
|
+
setApiKeyInput("");
|
|
116
|
+
setShowApiKeyField(false);
|
|
117
|
+
}
|
|
118
|
+
if (e.key === "Escape") {
|
|
119
|
+
setShowApiKeyField(false);
|
|
120
|
+
setApiKeyInput("");
|
|
121
|
+
}
|
|
122
|
+
}}
|
|
113
123
|
/>
|
|
114
|
-
<
|
|
115
|
-
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
116
126
|
disabled={!apiKeyInput.trim()}
|
|
117
127
|
onClick={() => {
|
|
118
128
|
save({ openrouter_api_key: apiKeyInput.trim() });
|
|
119
129
|
setApiKeyInput("");
|
|
120
130
|
setShowApiKeyField(false);
|
|
121
131
|
}}
|
|
132
|
+
className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-20 hover:opacity-80 transition-opacity"
|
|
122
133
|
>
|
|
123
134
|
Save
|
|
124
|
-
</
|
|
125
|
-
<Button
|
|
126
|
-
variant="ghost"
|
|
127
|
-
size="sm"
|
|
128
|
-
onClick={() => { setShowApiKeyField(false); setApiKeyInput(""); }}
|
|
129
|
-
>
|
|
130
|
-
Cancel
|
|
131
|
-
</Button>
|
|
135
|
+
</button>
|
|
132
136
|
</div>
|
|
133
137
|
) : (
|
|
134
|
-
<div className="flex items-center gap-
|
|
135
|
-
<
|
|
136
|
-
<span className="text-
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<span className={`h-1.5 w-1.5 rounded-full ${config.has_api_key ? "bg-green-500" : "bg-red-500"}`} />
|
|
140
|
+
<span className="text-[11px] text-muted-foreground/50">
|
|
137
141
|
{config.has_api_key ? "Configured" : "Not set"}
|
|
138
142
|
</span>
|
|
139
143
|
<button
|
|
140
144
|
type="button"
|
|
141
145
|
onClick={() => setShowApiKeyField(true)}
|
|
142
|
-
className="text-
|
|
146
|
+
className="text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
|
|
143
147
|
>
|
|
144
|
-
{config.has_api_key ? "Change" : "Set
|
|
148
|
+
{config.has_api_key ? "Change" : "Set"}
|
|
145
149
|
</button>
|
|
146
150
|
</div>
|
|
147
151
|
)}
|
|
148
152
|
</Row>
|
|
149
153
|
<Row label="GitHub Token">
|
|
150
|
-
<div className="flex items-center gap-
|
|
151
|
-
<
|
|
152
|
-
<span className="text-
|
|
153
|
-
{config.has_github_token ? "
|
|
154
|
+
<div className="flex items-center gap-2">
|
|
155
|
+
<span className={`h-1.5 w-1.5 rounded-full ${config.has_github_token ? "bg-green-500" : "bg-red-500"}`} />
|
|
156
|
+
<span className="text-[11px] text-muted-foreground/50">
|
|
157
|
+
{config.has_github_token ? "gh CLI" : "Not detected"}
|
|
154
158
|
</span>
|
|
155
159
|
</div>
|
|
156
160
|
</Row>
|
|
157
161
|
</Section>
|
|
158
162
|
|
|
159
|
-
<Section
|
|
160
|
-
<Row label="
|
|
163
|
+
<Section title="Model">
|
|
164
|
+
<Row label="LLM">
|
|
161
165
|
<select
|
|
162
166
|
value={config.model}
|
|
163
167
|
onChange={(e) => save({ model: e.target.value })}
|
|
164
|
-
className="h-
|
|
168
|
+
className="h-7 rounded-md border bg-background px-2 text-[11px] font-mono focus:outline-none focus:border-foreground/20 cursor-pointer"
|
|
165
169
|
>
|
|
166
170
|
{MODELS.map((m) => (
|
|
167
171
|
<option key={m} value={m}>{m.split("/").pop()}</option>
|
|
168
172
|
))}
|
|
169
173
|
</select>
|
|
170
174
|
</Row>
|
|
171
|
-
<Row label="
|
|
172
|
-
<div className="flex gap-
|
|
175
|
+
<Row label="Agent">
|
|
176
|
+
<div className="flex gap-px rounded-md border p-0.5">
|
|
173
177
|
{AGENTS.map((a) => (
|
|
174
178
|
<button
|
|
175
179
|
key={a.value}
|
|
176
180
|
type="button"
|
|
177
181
|
onClick={() => save({ agent: a.value })}
|
|
178
|
-
className={`px-
|
|
182
|
+
className={`px-2.5 py-1 rounded text-[11px] transition-colors ${
|
|
179
183
|
(config.agent ?? "") === a.value
|
|
180
|
-
? "bg-
|
|
181
|
-
: "
|
|
184
|
+
? "bg-accent text-foreground font-medium"
|
|
185
|
+
: "text-muted-foreground/50 hover:text-foreground"
|
|
182
186
|
}`}
|
|
183
187
|
>
|
|
184
188
|
{a.label}
|
|
@@ -186,14 +190,11 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
186
190
|
))}
|
|
187
191
|
</div>
|
|
188
192
|
</Row>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<Section icon={Globe} title="Language">
|
|
192
|
-
<Row label="Output Language">
|
|
193
|
+
<Row label="Language">
|
|
193
194
|
<select
|
|
194
195
|
value={config.language}
|
|
195
196
|
onChange={(e) => save({ language: e.target.value })}
|
|
196
|
-
className="h-
|
|
197
|
+
className="h-7 rounded-md border bg-background px-2 text-[11px] focus:outline-none focus:border-foreground/20 cursor-pointer"
|
|
197
198
|
>
|
|
198
199
|
{LANGUAGES.map((l) => (
|
|
199
200
|
<option key={l} value={l}>{l === "auto" ? "Auto-detect" : l}</option>
|
|
@@ -202,27 +203,18 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
202
203
|
</Row>
|
|
203
204
|
</Section>
|
|
204
205
|
|
|
205
|
-
<Section
|
|
206
|
+
<Section title="Limits">
|
|
206
207
|
<Row label="Max files">
|
|
207
|
-
<NumberInput
|
|
208
|
-
value={config.max_files}
|
|
209
|
-
defaultValue={config.defaults.max_files}
|
|
210
|
-
onChange={(v) => save({ max_files: v })}
|
|
211
|
-
/>
|
|
208
|
+
<NumberInput value={config.max_files} onChange={(v) => save({ max_files: v })} />
|
|
212
209
|
</Row>
|
|
213
|
-
<Row label="Timeout
|
|
214
|
-
<
|
|
215
|
-
value={config.timeout}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
/>
|
|
210
|
+
<Row label="Timeout">
|
|
211
|
+
<div className="flex items-center gap-1.5">
|
|
212
|
+
<NumberInput value={config.timeout} onChange={(v) => save({ timeout: v })} />
|
|
213
|
+
<span className="text-[10px] text-muted-foreground/30">sec</span>
|
|
214
|
+
</div>
|
|
219
215
|
</Row>
|
|
220
216
|
<Row label="Concurrency">
|
|
221
|
-
<NumberInput
|
|
222
|
-
value={config.concurrency}
|
|
223
|
-
defaultValue={config.defaults.concurrency}
|
|
224
|
-
onChange={(v) => save({ concurrency: v })}
|
|
225
|
-
/>
|
|
217
|
+
<NumberInput value={config.concurrency} onChange={(v) => save({ concurrency: v })} />
|
|
226
218
|
</Row>
|
|
227
219
|
</Section>
|
|
228
220
|
</div>
|
|
@@ -230,22 +222,13 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
|
230
222
|
);
|
|
231
223
|
}
|
|
232
224
|
|
|
233
|
-
function Section({
|
|
234
|
-
icon: Icon,
|
|
235
|
-
title,
|
|
236
|
-
children,
|
|
237
|
-
}: {
|
|
238
|
-
icon: typeof Key;
|
|
239
|
-
title: string;
|
|
240
|
-
children: React.ReactNode;
|
|
241
|
-
}) {
|
|
225
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
242
226
|
return (
|
|
243
227
|
<div>
|
|
244
|
-
<div className="
|
|
245
|
-
|
|
246
|
-
<h3 className="text-sm font-medium">{title}</h3>
|
|
228
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
|
|
229
|
+
{title}
|
|
247
230
|
</div>
|
|
248
|
-
<div className="space-y-
|
|
231
|
+
<div className="space-y-3">{children}</div>
|
|
249
232
|
</div>
|
|
250
233
|
);
|
|
251
234
|
}
|
|
@@ -253,24 +236,17 @@ function Section({
|
|
|
253
236
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
254
237
|
return (
|
|
255
238
|
<div className="flex items-center justify-between gap-4">
|
|
256
|
-
<label className="text-
|
|
239
|
+
<label className="text-[11px] text-muted-foreground/60 shrink-0">{label}</label>
|
|
257
240
|
<div className="flex-1 flex justify-end">{children}</div>
|
|
258
241
|
</div>
|
|
259
242
|
);
|
|
260
243
|
}
|
|
261
244
|
|
|
262
|
-
function StatusDot({ ok }: { ok: boolean }) {
|
|
263
|
-
return (
|
|
264
|
-
<span className={`h-2 w-2 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`} />
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
245
|
function NumberInput({
|
|
269
246
|
value,
|
|
270
247
|
onChange,
|
|
271
248
|
}: {
|
|
272
249
|
value: number;
|
|
273
|
-
defaultValue?: number;
|
|
274
250
|
onChange: (v: number) => void;
|
|
275
251
|
}) {
|
|
276
252
|
const [local, setLocal] = useState(String(value));
|
|
@@ -293,7 +269,7 @@ function NumberInput({
|
|
|
293
269
|
onChange={(e) => setLocal(e.target.value)}
|
|
294
270
|
onBlur={handleBlur}
|
|
295
271
|
onKeyDown={(e) => { if (e.key === "Enter") handleBlur(); }}
|
|
296
|
-
className="w-
|
|
272
|
+
className="w-16 h-7 rounded-md border bg-background px-2 text-[11px] text-right font-mono tabular-nums focus:outline-none focus:border-foreground/20"
|
|
297
273
|
/>
|
|
298
274
|
);
|
|
299
275
|
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
|
2
|
+
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
5
|
+
import Mention from "@tiptap/extension-mention";
|
|
6
|
+
import { PluginKey } from "@tiptap/pm/state";
|
|
7
|
+
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
|
8
|
+
|
|
9
|
+
export interface AnchorItem {
|
|
10
|
+
kind: "group" | "file";
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
description: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SuggestionEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
badge?: string;
|
|
25
|
+
badgeClass?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
mono?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TipTapEditorProps {
|
|
31
|
+
content?: string;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
autoFocus?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
onSubmit?: () => void;
|
|
37
|
+
onChange?: (text: string) => void;
|
|
38
|
+
submitOnEnter?: boolean;
|
|
39
|
+
submitOnModEnter?: boolean;
|
|
40
|
+
onEscape?: () => void;
|
|
41
|
+
editorRef?: React.MutableRefObject<ReturnType<typeof useEditor> | null>;
|
|
42
|
+
anchorItems?: AnchorItem[];
|
|
43
|
+
commands?: CommandItem[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function SuggestionList({
|
|
47
|
+
items,
|
|
48
|
+
command,
|
|
49
|
+
selectedIndex,
|
|
50
|
+
setSelectedIndex,
|
|
51
|
+
}: {
|
|
52
|
+
items: SuggestionEntry[];
|
|
53
|
+
command: (item: SuggestionEntry) => void;
|
|
54
|
+
selectedIndex: number;
|
|
55
|
+
setSelectedIndex: (i: number) => void;
|
|
56
|
+
}) {
|
|
57
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
|
|
61
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
62
|
+
}, [selectedIndex]);
|
|
63
|
+
|
|
64
|
+
if (items.length === 0) return null;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
ref={listRef}
|
|
69
|
+
className="z-50 min-w-[200px] max-w-[340px] max-h-[200px] overflow-y-auto rounded-lg border bg-background shadow-lg py-1"
|
|
70
|
+
>
|
|
71
|
+
{items.map((item, i) => (
|
|
72
|
+
<button
|
|
73
|
+
key={item.id}
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => command(item)}
|
|
76
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
77
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
|
78
|
+
i === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
{item.badge && (
|
|
82
|
+
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded ${item.badgeClass ?? "bg-muted text-muted-foreground"}`}>
|
|
83
|
+
{item.badge}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
<span className={`truncate text-xs ${item.mono ? "font-mono" : ""}`}>
|
|
87
|
+
{item.label}
|
|
88
|
+
</span>
|
|
89
|
+
{item.description && (
|
|
90
|
+
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/50">
|
|
91
|
+
{item.description}
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface SuggestionListRef {
|
|
101
|
+
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SuggestionListWrapper = forwardRef<SuggestionListRef, SuggestionProps<SuggestionEntry>>(
|
|
105
|
+
(props, ref) => {
|
|
106
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setSelectedIndex(0);
|
|
110
|
+
}, [props.items]);
|
|
111
|
+
|
|
112
|
+
useImperativeHandle(ref, () => ({
|
|
113
|
+
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
|
114
|
+
if (event.key === "ArrowUp") {
|
|
115
|
+
setSelectedIndex((i) => (i + props.items.length - 1) % props.items.length);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (event.key === "ArrowDown") {
|
|
119
|
+
setSelectedIndex((i) => (i + 1) % props.items.length);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (event.key === "Enter") {
|
|
123
|
+
const item = props.items[selectedIndex];
|
|
124
|
+
if (item) props.command(item);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (event.key === "Escape") {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<SuggestionList
|
|
136
|
+
items={props.items}
|
|
137
|
+
command={props.command}
|
|
138
|
+
selectedIndex={selectedIndex}
|
|
139
|
+
setSelectedIndex={setSelectedIndex}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
function createSuggestionRender(suggestionOpenRef: React.MutableRefObject<boolean>) {
|
|
146
|
+
return () => {
|
|
147
|
+
let renderer: ReactRenderer<SuggestionListRef> | null = null;
|
|
148
|
+
let popup: HTMLDivElement | null = null;
|
|
149
|
+
|
|
150
|
+
const positionPopup = (rect: DOMRect | null) => {
|
|
151
|
+
if (!rect || !popup) return;
|
|
152
|
+
const menuHeight = popup.offsetHeight || 200;
|
|
153
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
154
|
+
const fitsBelow = spaceBelow > menuHeight + 8;
|
|
155
|
+
popup.style.left = `${rect.left}px`;
|
|
156
|
+
if (fitsBelow) {
|
|
157
|
+
popup.style.top = `${rect.bottom + 4}px`;
|
|
158
|
+
popup.style.bottom = "";
|
|
159
|
+
} else {
|
|
160
|
+
popup.style.top = "";
|
|
161
|
+
popup.style.bottom = `${window.innerHeight - rect.top + 4}px`;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
onStart: (props: SuggestionProps<SuggestionEntry>) => {
|
|
167
|
+
suggestionOpenRef.current = true;
|
|
168
|
+
popup = document.createElement("div");
|
|
169
|
+
popup.style.position = "fixed";
|
|
170
|
+
popup.style.zIndex = "50";
|
|
171
|
+
document.body.appendChild(popup);
|
|
172
|
+
|
|
173
|
+
renderer = new ReactRenderer(SuggestionListWrapper, {
|
|
174
|
+
props,
|
|
175
|
+
editor: props.editor,
|
|
176
|
+
});
|
|
177
|
+
popup.appendChild(renderer.element);
|
|
178
|
+
positionPopup(props.clientRect?.() ?? null);
|
|
179
|
+
},
|
|
180
|
+
onUpdate: (props: SuggestionProps<SuggestionEntry>) => {
|
|
181
|
+
renderer?.updateProps(props);
|
|
182
|
+
positionPopup(props.clientRect?.() ?? null);
|
|
183
|
+
},
|
|
184
|
+
onKeyDown: (props: SuggestionKeyDownProps) => {
|
|
185
|
+
if (props.event.key === "Escape") {
|
|
186
|
+
popup?.remove();
|
|
187
|
+
renderer?.destroy();
|
|
188
|
+
popup = null;
|
|
189
|
+
renderer = null;
|
|
190
|
+
suggestionOpenRef.current = false;
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return renderer?.ref?.onKeyDown(props) ?? false;
|
|
194
|
+
},
|
|
195
|
+
onExit: () => {
|
|
196
|
+
popup?.remove();
|
|
197
|
+
renderer?.destroy();
|
|
198
|
+
popup = null;
|
|
199
|
+
renderer = null;
|
|
200
|
+
suggestionOpenRef.current = false;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getTextWithAnchors(editor: ReturnType<typeof useEditor>): string {
|
|
207
|
+
if (!editor) return "";
|
|
208
|
+
const doc = editor.state.doc;
|
|
209
|
+
const parts: string[] = [];
|
|
210
|
+
|
|
211
|
+
doc.descendants((node) => {
|
|
212
|
+
if (node.type.name === "anchorMention") {
|
|
213
|
+
const kind = node.attrs.kind ?? "file";
|
|
214
|
+
const id = node.attrs.id ?? node.attrs.label ?? "";
|
|
215
|
+
parts.push(`[[${kind}:${id}]]`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (node.type.name === "slashCommand") {
|
|
219
|
+
parts.push(`/${node.attrs.id ?? node.attrs.label ?? ""}`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (node.isText) {
|
|
223
|
+
parts.push(node.text ?? "");
|
|
224
|
+
}
|
|
225
|
+
if (node.type.name === "paragraph" && parts.length > 0) {
|
|
226
|
+
const last = parts[parts.length - 1];
|
|
227
|
+
if (last !== "\n") parts.push("\n");
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return parts.join("").trim();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const AnchorMention = Mention.extend({ name: "anchorMention" });
|
|
236
|
+
const SlashCommand = Mention.extend({ name: "slashCommand" });
|
|
237
|
+
|
|
238
|
+
export function TipTapEditor({
|
|
239
|
+
content,
|
|
240
|
+
placeholder: placeholderText = "",
|
|
241
|
+
disabled = false,
|
|
242
|
+
autoFocus = false,
|
|
243
|
+
className = "",
|
|
244
|
+
onSubmit,
|
|
245
|
+
onChange,
|
|
246
|
+
submitOnEnter = false,
|
|
247
|
+
submitOnModEnter = false,
|
|
248
|
+
onEscape,
|
|
249
|
+
editorRef,
|
|
250
|
+
anchorItems,
|
|
251
|
+
commands,
|
|
252
|
+
}: TipTapEditorProps) {
|
|
253
|
+
const callbacksRef = useRef({ onSubmit, onChange, onEscape });
|
|
254
|
+
callbacksRef.current = { onSubmit, onChange, onEscape };
|
|
255
|
+
const anchorItemsRef = useRef(anchorItems);
|
|
256
|
+
anchorItemsRef.current = anchorItems;
|
|
257
|
+
const commandsRef = useRef(commands);
|
|
258
|
+
commandsRef.current = commands;
|
|
259
|
+
const suggestionOpenRef = useRef(false);
|
|
260
|
+
|
|
261
|
+
const editor = useEditor({
|
|
262
|
+
extensions: [
|
|
263
|
+
StarterKit.configure({
|
|
264
|
+
heading: false,
|
|
265
|
+
horizontalRule: false,
|
|
266
|
+
blockquote: false,
|
|
267
|
+
}),
|
|
268
|
+
Placeholder.configure({ placeholder: placeholderText }),
|
|
269
|
+
...(anchorItems
|
|
270
|
+
? [
|
|
271
|
+
AnchorMention.configure({
|
|
272
|
+
HTMLAttributes: { class: "mention-anchor" },
|
|
273
|
+
suggestion: {
|
|
274
|
+
char: "@",
|
|
275
|
+
pluginKey: new PluginKey("anchorMention"),
|
|
276
|
+
allowSpaces: true,
|
|
277
|
+
items: ({ query }: { query: string }) => {
|
|
278
|
+
const all = (anchorItemsRef.current ?? []).map((a) => ({
|
|
279
|
+
id: a.id,
|
|
280
|
+
label: a.label,
|
|
281
|
+
badge: a.kind === "group" ? "group" : "file",
|
|
282
|
+
badgeClass: a.kind === "group"
|
|
283
|
+
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
|
284
|
+
: "bg-muted text-muted-foreground",
|
|
285
|
+
mono: a.kind === "file",
|
|
286
|
+
kind: a.kind,
|
|
287
|
+
}));
|
|
288
|
+
if (!query) return all.slice(0, 12);
|
|
289
|
+
const q = query.toLowerCase();
|
|
290
|
+
return all.filter((item) => item.label.toLowerCase().includes(q)).slice(0, 12);
|
|
291
|
+
},
|
|
292
|
+
render: createSuggestionRender(suggestionOpenRef),
|
|
293
|
+
command: ({ editor: ed, range, props: attrs }) => {
|
|
294
|
+
const item = attrs as unknown as AnchorItem & SuggestionEntry;
|
|
295
|
+
ed.chain()
|
|
296
|
+
.focus()
|
|
297
|
+
.insertContentAt(range, [
|
|
298
|
+
{
|
|
299
|
+
type: "anchorMention",
|
|
300
|
+
attrs: { id: item.id, label: item.label, kind: item.kind ?? "file" },
|
|
301
|
+
},
|
|
302
|
+
{ type: "text", text: " " },
|
|
303
|
+
])
|
|
304
|
+
.run();
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
}).extend({
|
|
308
|
+
addAttributes() {
|
|
309
|
+
return {
|
|
310
|
+
...this.parent?.(),
|
|
311
|
+
kind: { default: "file" },
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
]
|
|
316
|
+
: []),
|
|
317
|
+
...(commands && commands.length > 0
|
|
318
|
+
? [
|
|
319
|
+
SlashCommand.configure({
|
|
320
|
+
HTMLAttributes: { class: "mention-command" },
|
|
321
|
+
suggestion: {
|
|
322
|
+
char: "/",
|
|
323
|
+
pluginKey: new PluginKey("slashCommand"),
|
|
324
|
+
startOfLine: true,
|
|
325
|
+
items: ({ query }: { query: string }) => {
|
|
326
|
+
const all = (commandsRef.current ?? []).map((c) => ({
|
|
327
|
+
id: c.id,
|
|
328
|
+
label: `/${c.id}`,
|
|
329
|
+
description: c.description,
|
|
330
|
+
}));
|
|
331
|
+
if (!query) return all;
|
|
332
|
+
const q = query.toLowerCase();
|
|
333
|
+
return all.filter((item) => item.id.toLowerCase().includes(q));
|
|
334
|
+
},
|
|
335
|
+
render: createSuggestionRender(suggestionOpenRef),
|
|
336
|
+
command: ({ editor: ed, range, props: attrs }) => {
|
|
337
|
+
ed.chain()
|
|
338
|
+
.focus()
|
|
339
|
+
.deleteRange(range)
|
|
340
|
+
.insertContent(`/${attrs.id} `)
|
|
341
|
+
.run();
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
]
|
|
346
|
+
: []),
|
|
347
|
+
],
|
|
348
|
+
editorProps: {
|
|
349
|
+
attributes: {
|
|
350
|
+
class: `outline-none min-h-[20px] ${className}`,
|
|
351
|
+
},
|
|
352
|
+
handleKeyDown: (_view, event) => {
|
|
353
|
+
if (suggestionOpenRef.current) return false;
|
|
354
|
+
if (event.key === "Escape" && callbacksRef.current.onEscape) {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
callbacksRef.current.onEscape();
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (submitOnEnter && event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
callbacksRef.current.onSubmit?.();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (submitOnModEnter && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
callbacksRef.current.onSubmit?.();
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
content: content ?? "",
|
|
373
|
+
editable: !disabled,
|
|
374
|
+
onUpdate: ({ editor: ed }) => {
|
|
375
|
+
callbacksRef.current.onChange?.(ed.getText());
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (editorRef) editorRef.current = editor;
|
|
381
|
+
}, [editor, editorRef]);
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!editor) return;
|
|
385
|
+
editor.setEditable(!disabled);
|
|
386
|
+
}, [editor, disabled]);
|
|
387
|
+
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!editor || content === undefined) return;
|
|
390
|
+
const current = editor.getText();
|
|
391
|
+
if (content !== current) {
|
|
392
|
+
editor.commands.setContent(content ? `<p>${content.replace(/\n/g, "<br>")}</p>` : "");
|
|
393
|
+
}
|
|
394
|
+
}, [editor, content]);
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (autoFocus && editor) {
|
|
398
|
+
setTimeout(() => editor.commands.focus("end"), 0);
|
|
399
|
+
}
|
|
400
|
+
}, [autoFocus, editor]);
|
|
401
|
+
|
|
402
|
+
return <EditorContent editor={editor} />;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export { getTextWithAnchors };
|