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,9 +1,102 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { CornerDownLeft, Clock, GitPullRequest, Check, X, Minus } from "lucide-react";
|
|
3
|
+
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
interface ToolStatus {
|
|
6
|
+
name: string;
|
|
7
|
+
installed: boolean;
|
|
8
|
+
version?: string;
|
|
9
|
+
detail?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PreflightData {
|
|
13
|
+
github: ToolStatus & { authenticated: boolean; user?: string };
|
|
14
|
+
agents: ToolStatus[];
|
|
15
|
+
openrouterKey: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RISK_DOT: Record<string, string> = {
|
|
19
|
+
low: "bg-green-500",
|
|
20
|
+
medium: "bg-yellow-500",
|
|
21
|
+
high: "bg-red-500",
|
|
22
|
+
critical: "bg-red-600",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function timeAgo(date: string): string {
|
|
26
|
+
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
|
27
|
+
if (s < 60) return "just now";
|
|
28
|
+
const m = Math.floor(s / 60);
|
|
29
|
+
if (m < 60) return `${m}m ago`;
|
|
30
|
+
const h = Math.floor(m / 60);
|
|
31
|
+
if (h < 24) return `${h}h ago`;
|
|
32
|
+
const d = Math.floor(h / 24);
|
|
33
|
+
if (d < 30) return `${d}d ago`;
|
|
34
|
+
return `${Math.floor(d / 30)}mo ago`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function StatusIcon({ ok, optional }: { ok: boolean; optional?: boolean }) {
|
|
38
|
+
if (ok) return <Check className="h-3 w-3 text-green-500" />;
|
|
39
|
+
if (optional) return <Minus className="h-3 w-3 text-muted-foreground/30" />;
|
|
40
|
+
return <X className="h-3 w-3 text-red-500" />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function PreflightStatus({ data }: { data: PreflightData }) {
|
|
44
|
+
const gh = data.github;
|
|
45
|
+
return (
|
|
46
|
+
<div className="space-y-1.5">
|
|
47
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">Status</div>
|
|
48
|
+
<div className="flex items-center gap-2">
|
|
49
|
+
<StatusIcon ok={gh.installed && gh.authenticated} />
|
|
50
|
+
<span className="text-[11px] font-mono">gh</span>
|
|
51
|
+
{gh.installed && gh.authenticated && gh.user && (
|
|
52
|
+
<span className="text-[10px] text-muted-foreground/40">{gh.user}</span>
|
|
53
|
+
)}
|
|
54
|
+
{gh.installed && !gh.authenticated && (
|
|
55
|
+
<span className="text-[10px] text-red-500/70">not authenticated</span>
|
|
56
|
+
)}
|
|
57
|
+
{!gh.installed && (
|
|
58
|
+
<span className="text-[10px] text-red-500/70">not installed</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
{data.agents.map((agent) => (
|
|
62
|
+
<div key={agent.name} className="flex items-center gap-2">
|
|
63
|
+
<StatusIcon ok={agent.installed} optional />
|
|
64
|
+
<span className={`text-[11px] font-mono ${agent.installed ? "" : "text-muted-foreground/30"}`}>{agent.name}</span>
|
|
65
|
+
{agent.installed && agent.version && (
|
|
66
|
+
<span className="text-[10px] text-muted-foreground/30">{agent.version}</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
<div className="flex items-center gap-2">
|
|
71
|
+
<StatusIcon ok={data.openrouterKey} />
|
|
72
|
+
<span className="text-[11px]">OpenRouter</span>
|
|
73
|
+
{!data.openrouterKey && (
|
|
74
|
+
<span className="text-[10px] text-red-500/70">run newpr auth</span>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function InputScreen({
|
|
82
|
+
onSubmit,
|
|
83
|
+
sessions,
|
|
84
|
+
onSessionSelect,
|
|
85
|
+
}: {
|
|
86
|
+
onSubmit: (pr: string) => void;
|
|
87
|
+
sessions?: SessionRecord[];
|
|
88
|
+
onSessionSelect?: (id: string) => void;
|
|
89
|
+
}) {
|
|
6
90
|
const [value, setValue] = useState("");
|
|
91
|
+
const [focused, setFocused] = useState(false);
|
|
92
|
+
const [preflight, setPreflight] = useState<PreflightData | null>(null);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
fetch("/api/preflight")
|
|
96
|
+
.then((r) => r.json())
|
|
97
|
+
.then((data) => { if (data) setPreflight(data as PreflightData); })
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
}, []);
|
|
7
100
|
|
|
8
101
|
function handleSubmit(e: React.FormEvent) {
|
|
9
102
|
e.preventDefault();
|
|
@@ -11,31 +104,89 @@ export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
|
|
|
11
104
|
if (trimmed) onSubmit(trimmed);
|
|
12
105
|
}
|
|
13
106
|
|
|
14
|
-
|
|
15
|
-
<div className="flex flex-col items-center gap-12 py-24">
|
|
16
|
-
<div className="flex flex-col items-center gap-3">
|
|
17
|
-
<h1 className="text-4xl font-bold tracking-tight">newpr</h1>
|
|
18
|
-
<p className="text-muted-foreground text-center max-w-md">
|
|
19
|
-
AI-powered PR review tool. Paste a PR URL to get a comprehensive analysis.
|
|
20
|
-
</p>
|
|
21
|
-
</div>
|
|
107
|
+
const recents = sessions?.slice(0, 5) ?? [];
|
|
22
108
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Analyze
|
|
35
|
-
<ArrowRight className="ml-2 h-4 w-4" />
|
|
36
|
-
</Button>
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
|
111
|
+
<div className="w-full max-w-lg space-y-8">
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
<div className="flex items-baseline gap-2">
|
|
114
|
+
<h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
|
|
115
|
+
<span className="text-[10px] text-muted-foreground/40">AI code review</span>
|
|
116
|
+
</div>
|
|
117
|
+
<p className="text-xs text-muted-foreground">
|
|
118
|
+
Paste a GitHub PR URL to start analysis
|
|
119
|
+
</p>
|
|
37
120
|
</div>
|
|
38
|
-
|
|
121
|
+
|
|
122
|
+
<form onSubmit={handleSubmit}>
|
|
123
|
+
<div className={`flex items-center rounded-xl border bg-background transition-all ${
|
|
124
|
+
focused ? "ring-1 ring-ring border-foreground/15 shadow-sm" : "border-border"
|
|
125
|
+
}`}>
|
|
126
|
+
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground/40 ml-4 shrink-0" />
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
value={value}
|
|
130
|
+
onChange={(e) => setValue(e.target.value)}
|
|
131
|
+
onFocus={() => setFocused(true)}
|
|
132
|
+
onBlur={() => setFocused(false)}
|
|
133
|
+
placeholder="https://github.com/owner/repo/pull/123"
|
|
134
|
+
className="flex-1 h-11 bg-transparent px-3 text-xs font-mono placeholder:text-muted-foreground/40 focus:outline-none"
|
|
135
|
+
autoFocus
|
|
136
|
+
/>
|
|
137
|
+
<button
|
|
138
|
+
type="submit"
|
|
139
|
+
disabled={!value.trim()}
|
|
140
|
+
className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background mr-2 transition-opacity disabled:opacity-20 hover:opacity-80"
|
|
141
|
+
>
|
|
142
|
+
<CornerDownLeft className="h-3.5 w-3.5" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex justify-end mt-2 pr-1">
|
|
146
|
+
<span className="text-[10px] text-muted-foreground/30">
|
|
147
|
+
Enter to analyze
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
</form>
|
|
151
|
+
|
|
152
|
+
{recents.length > 0 && (
|
|
153
|
+
<div className="space-y-2 pt-2">
|
|
154
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider px-0.5">
|
|
155
|
+
Recent
|
|
156
|
+
</div>
|
|
157
|
+
<div className="space-y-px">
|
|
158
|
+
{recents.map((s) => (
|
|
159
|
+
<button
|
|
160
|
+
key={s.id}
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => onSessionSelect?.(s.id)}
|
|
163
|
+
className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent/50 transition-colors group"
|
|
164
|
+
>
|
|
165
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
166
|
+
<div className="flex-1 min-w-0">
|
|
167
|
+
<div className="text-xs truncate group-hover:text-foreground transition-colors">
|
|
168
|
+
{s.pr_title}
|
|
169
|
+
</div>
|
|
170
|
+
<div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground/50">
|
|
171
|
+
<span className="font-mono truncate">{s.repo.split("/").pop()}</span>
|
|
172
|
+
<span className="font-mono">#{s.pr_number}</span>
|
|
173
|
+
<span className="text-muted-foreground/20">·</span>
|
|
174
|
+
<span className="text-green-600 dark:text-green-400">+{s.total_additions}</span>
|
|
175
|
+
<span className="text-red-600 dark:text-red-400">-{s.total_deletions}</span>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/30 shrink-0">
|
|
179
|
+
<Clock className="h-2.5 w-2.5" />
|
|
180
|
+
<span>{timeAgo(s.analyzed_at)}</span>
|
|
181
|
+
</div>
|
|
182
|
+
</button>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{preflight && <PreflightStatus data={preflight} />}
|
|
189
|
+
</div>
|
|
39
190
|
</div>
|
|
40
191
|
);
|
|
41
192
|
}
|
|
@@ -110,15 +110,28 @@ export function LoadingTimeline({
|
|
|
110
110
|
const steps = buildSteps(events);
|
|
111
111
|
const seconds = Math.floor(elapsed / 1000);
|
|
112
112
|
|
|
113
|
+
const prInfo = events.find((e) => e.pr_title);
|
|
114
|
+
const title = prInfo?.pr_title;
|
|
115
|
+
const prNum = prInfo?.pr_number;
|
|
116
|
+
|
|
113
117
|
return (
|
|
114
118
|
<div className="flex flex-col items-center py-16">
|
|
115
119
|
<div className="w-full max-w-lg">
|
|
116
|
-
<div className="
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
<div className="mb-8">
|
|
121
|
+
<div className="flex items-center gap-2">
|
|
122
|
+
{title ? (
|
|
123
|
+
<span className="text-sm font-semibold truncate">{title}</span>
|
|
124
|
+
) : (
|
|
125
|
+
<span className="text-sm font-semibold font-mono">newpr</span>
|
|
126
|
+
)}
|
|
127
|
+
<span className="text-muted-foreground/30">·</span>
|
|
128
|
+
<span className="text-xs text-muted-foreground/50 tabular-nums shrink-0">
|
|
129
|
+
{seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
{prNum && (
|
|
133
|
+
<span className="text-[11px] text-muted-foreground/40 font-mono">#{prNum}</span>
|
|
134
|
+
)}
|
|
122
135
|
</div>
|
|
123
136
|
|
|
124
137
|
<div className="space-y-3">
|
|
@@ -1,24 +1,150 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
1
2
|
import ReactMarkdown from "react-markdown";
|
|
2
3
|
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkMath from "remark-math";
|
|
5
|
+
import rehypeRaw from "rehype-raw";
|
|
6
|
+
import rehypeKatex from "rehype-katex";
|
|
7
|
+
import "katex/dist/katex.min.css";
|
|
3
8
|
import type { Components } from "react-markdown";
|
|
9
|
+
import type { Highlighter } from "shiki";
|
|
10
|
+
import { ensureHighlighter, getHighlighterSync, langFromClassName } from "../lib/shiki.ts";
|
|
4
11
|
|
|
5
12
|
interface MarkdownProps {
|
|
6
13
|
children: string;
|
|
7
|
-
onAnchorClick?: (kind: "group" | "file", id: string) => void;
|
|
14
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
8
15
|
activeId?: string | null;
|
|
9
16
|
}
|
|
10
17
|
|
|
18
|
+
function useHighlighter(): Highlighter | null {
|
|
19
|
+
const [hl, setHl] = useState<Highlighter | null>(getHighlighterSync());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!hl) ensureHighlighter().then(setHl).catch(() => {});
|
|
22
|
+
}, [hl]);
|
|
23
|
+
return hl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function useDarkMode(): boolean {
|
|
27
|
+
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"));
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const observer = new MutationObserver(() => {
|
|
30
|
+
setDark(document.documentElement.classList.contains("dark"));
|
|
31
|
+
});
|
|
32
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
33
|
+
return () => observer.disconnect();
|
|
34
|
+
}, []);
|
|
35
|
+
return dark;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GITHUB_ATTACHMENT_RE = /^https:\/\/github\.com\/user-attachments\/assets\//;
|
|
39
|
+
const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?|$)/i;
|
|
40
|
+
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?|$)/i;
|
|
41
|
+
|
|
42
|
+
function isMediaUrl(href: string): boolean {
|
|
43
|
+
return GITHUB_ATTACHMENT_RE.test(href) || VIDEO_EXT_RE.test(href) || IMAGE_EXT_RE.test(href);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLikelyVideo(href: string): boolean {
|
|
47
|
+
return VIDEO_EXT_RE.test(href);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function proxied(url: string): string {
|
|
51
|
+
if (GITHUB_ATTACHMENT_RE.test(url) || url.startsWith("https://user-images.githubusercontent.com/")) {
|
|
52
|
+
return `/api/proxy?url=${encodeURIComponent(url)}`;
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function MediaEmbed({ src }: { src: string }) {
|
|
58
|
+
const url = proxied(src);
|
|
59
|
+
const [mode, setMode] = useState<"img" | "video" | "link">(isLikelyVideo(src) ? "video" : "img");
|
|
60
|
+
const triedRef = useState(() => new Set<string>())[0];
|
|
61
|
+
|
|
62
|
+
function fallback(from: "img" | "video") {
|
|
63
|
+
triedRef.add(from);
|
|
64
|
+
const next = from === "img" ? "video" : "img";
|
|
65
|
+
if (triedRef.has(next)) {
|
|
66
|
+
setMode("link");
|
|
67
|
+
} else {
|
|
68
|
+
setMode(next);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mode === "link") {
|
|
73
|
+
return <a href={src} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline break-all text-sm">{src}</a>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mode === "video") {
|
|
77
|
+
return (
|
|
78
|
+
<video
|
|
79
|
+
src={url}
|
|
80
|
+
controls
|
|
81
|
+
playsInline
|
|
82
|
+
className="max-w-full rounded-lg my-2"
|
|
83
|
+
onError={() => fallback("video")}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<img
|
|
90
|
+
src={url}
|
|
91
|
+
alt=""
|
|
92
|
+
className="max-w-full rounded-lg my-2"
|
|
93
|
+
onError={() => fallback("img")}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
11
98
|
const ANCHOR_RE = /\[\[(group|file):([^\]]+)\]\]/g;
|
|
99
|
+
const LINE_ANCHOR_WITH_TEXT_RE = /\[\[line:([^\]]+)\]\]\(([^)]+)\)/g;
|
|
100
|
+
const LINE_ANCHOR_BARE_RE = /\[\[line:([^\]]+)\]\]/g;
|
|
101
|
+
const BOLD_CJK_RE = /\*\*(.+?)\*\*/g;
|
|
102
|
+
|
|
103
|
+
function hasCJK(text: string): boolean {
|
|
104
|
+
return /[가-힣ぁ-ヿ一-鿿]/.test(text);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatLineLabel(id: string): string {
|
|
108
|
+
const hashIdx = id.indexOf("#");
|
|
109
|
+
if (hashIdx < 0) return id;
|
|
110
|
+
const file = id.slice(0, hashIdx);
|
|
111
|
+
const lineRef = id.slice(hashIdx + 1);
|
|
112
|
+
const fileName = file.split("/").pop() ?? file;
|
|
113
|
+
return `${fileName}:${lineRef}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function inlineMarkdownToHtml(text: string): string {
|
|
117
|
+
return text
|
|
118
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
119
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
120
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
121
|
+
}
|
|
12
122
|
|
|
13
123
|
function preprocess(text: string): string {
|
|
14
|
-
return text
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
124
|
+
return text
|
|
125
|
+
.replace(LINE_ANCHOR_WITH_TEXT_RE, (_, id, label) => {
|
|
126
|
+
const encoded = encodeURIComponent(id);
|
|
127
|
+
return `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
|
|
128
|
+
})
|
|
129
|
+
.replace(LINE_ANCHOR_BARE_RE, (_, id) => {
|
|
130
|
+
const encoded = encodeURIComponent(id);
|
|
131
|
+
const label = formatLineLabel(id);
|
|
132
|
+
return `<span data-line-ref="${encoded}">${label}</span>`;
|
|
133
|
+
})
|
|
134
|
+
.replace(ANCHOR_RE, (_, kind, id) => {
|
|
135
|
+
const encoded = encodeURIComponent(id);
|
|
136
|
+
return ``;
|
|
137
|
+
})
|
|
138
|
+
.replace(BOLD_CJK_RE, (match, inner) => {
|
|
139
|
+
if (hasCJK(inner)) return `<strong>${inner}</strong>`;
|
|
140
|
+
return match;
|
|
141
|
+
});
|
|
18
142
|
}
|
|
19
143
|
|
|
20
144
|
export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
21
145
|
const processed = preprocess(children);
|
|
146
|
+
const hl = useHighlighter();
|
|
147
|
+
const dark = useDarkMode();
|
|
22
148
|
|
|
23
149
|
const components: Components = {
|
|
24
150
|
h1: ({ children }) => <h1 className="text-xl font-bold mt-6 mb-3 break-words">{children}</h1>,
|
|
@@ -32,69 +158,122 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
32
158
|
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
|
33
159
|
em: ({ children }) => <em className="italic">{children}</em>,
|
|
34
160
|
code: ({ children, className }) => {
|
|
161
|
+
const lang = langFromClassName(className);
|
|
162
|
+
if (lang && hl) {
|
|
163
|
+
const code = String(children).replace(/\n$/, "");
|
|
164
|
+
const theme = dark ? "github-dark" : "github-light";
|
|
165
|
+
try {
|
|
166
|
+
const html = hl.codeToHtml(code, { lang, theme });
|
|
167
|
+
return <span dangerouslySetInnerHTML={{ __html: html }} />;
|
|
168
|
+
} catch {
|
|
169
|
+
return <code className="text-xs font-mono">{children}</code>;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
35
172
|
if (className?.includes("language-")) {
|
|
36
|
-
return
|
|
37
|
-
<pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3">
|
|
38
|
-
<code className="text-xs font-mono">{children}</code>
|
|
39
|
-
</pre>
|
|
40
|
-
);
|
|
173
|
+
return <code className="text-xs font-mono">{children}</code>;
|
|
41
174
|
}
|
|
42
175
|
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
|
|
43
176
|
},
|
|
44
|
-
pre: ({ children }) =>
|
|
45
|
-
|
|
46
|
-
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{children}</a>
|
|
177
|
+
pre: ({ children }) => (
|
|
178
|
+
<pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3 whitespace-pre text-xs font-mono [&>span>pre]:!bg-transparent [&>span>pre]:!p-0 [&>span>pre]:!m-0">{children}</pre>
|
|
47
179
|
),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!onAnchorClick) {
|
|
55
|
-
if (kind === "group") {
|
|
56
|
-
return <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium">{id}</span>;
|
|
57
|
-
}
|
|
58
|
-
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono text-blue-600 dark:text-blue-400">{id.split("/").pop()}</code>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const isActive = activeId === `${kind}:${id}`;
|
|
62
|
-
if (kind === "group") {
|
|
180
|
+
span: ({ children, ...props }) => {
|
|
181
|
+
const lineRef = (props as Record<string, unknown>)["data-line-ref"] as string | undefined;
|
|
182
|
+
if (lineRef && onAnchorClick) {
|
|
183
|
+
const id = decodeURIComponent(lineRef);
|
|
184
|
+
const isActive = activeId === `line:${id}`;
|
|
63
185
|
return (
|
|
64
186
|
<span
|
|
65
187
|
role="button"
|
|
66
188
|
tabIndex={0}
|
|
67
|
-
onClick={(e) => { e.stopPropagation(); onAnchorClick("
|
|
68
|
-
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("
|
|
69
|
-
className={`
|
|
189
|
+
onClick={(e) => { e.stopPropagation(); onAnchorClick("line", id); }}
|
|
190
|
+
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("line", id); }}
|
|
191
|
+
className={`underline decoration-1 underline-offset-[3px] cursor-pointer transition-colors ${
|
|
70
192
|
isActive
|
|
71
|
-
? "
|
|
72
|
-
: "
|
|
193
|
+
? "decoration-blue-500 dark:decoration-blue-400 bg-blue-500/5 rounded-sm"
|
|
194
|
+
: "decoration-foreground/15 hover:decoration-foreground/40"
|
|
73
195
|
}`}
|
|
74
196
|
>
|
|
75
|
-
{
|
|
197
|
+
{children}
|
|
76
198
|
</span>
|
|
77
199
|
);
|
|
78
200
|
}
|
|
201
|
+
if (lineRef) {
|
|
202
|
+
return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
|
|
203
|
+
}
|
|
204
|
+
return <span>{children}</span>;
|
|
205
|
+
},
|
|
206
|
+
a: ({ href, children }) => {
|
|
207
|
+
if (href && isMediaUrl(href)) {
|
|
208
|
+
const textContent = String(children ?? "");
|
|
209
|
+
if (textContent === href || !textContent.trim()) {
|
|
210
|
+
return <MediaEmbed src={href} />;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{children}</a>;
|
|
214
|
+
},
|
|
215
|
+
video: ({ src, ...rest }: React.ComponentProps<"video">) => (
|
|
216
|
+
src ? <video src={src} controls playsInline className="max-w-full rounded-lg my-2" {...rest} /> : null
|
|
217
|
+
),
|
|
218
|
+
img: ({ alt, src, ...rest }) => {
|
|
219
|
+
if (src !== "newpr") {
|
|
220
|
+
return <img alt={alt} src={src ? proxied(src) : src} {...rest} className="max-w-full rounded-lg my-2" />;
|
|
221
|
+
}
|
|
222
|
+
if (!alt?.includes(":")) return null;
|
|
223
|
+
const colonIdx = alt.indexOf(":");
|
|
224
|
+
const kind = alt.slice(0, colonIdx) as "group" | "file";
|
|
225
|
+
const id = decodeURIComponent(alt.slice(colonIdx + 1));
|
|
226
|
+
|
|
227
|
+
if (!onAnchorClick) {
|
|
228
|
+
if (kind === "group") {
|
|
229
|
+
return <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-accent text-[11px] font-medium text-foreground/80">{id}</span>;
|
|
230
|
+
}
|
|
231
|
+
return <code className="px-1.5 py-0.5 rounded-md bg-accent text-[11px] font-mono text-foreground/70">{id.split("/").pop()}</code>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const isActive = activeId === `${kind}:${id}`;
|
|
235
|
+
if (kind === "group") {
|
|
79
236
|
return (
|
|
80
237
|
<span
|
|
81
238
|
role="button"
|
|
82
239
|
tabIndex={0}
|
|
83
|
-
onClick={(e) => { e.stopPropagation(); onAnchorClick("
|
|
84
|
-
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("
|
|
85
|
-
className={`inline px-1.5 py-0.5 rounded text-
|
|
240
|
+
onClick={(e) => { e.stopPropagation(); onAnchorClick("group", id); }}
|
|
241
|
+
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("group", id); }}
|
|
242
|
+
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[11px] font-medium transition-colors cursor-pointer border ${
|
|
86
243
|
isActive
|
|
87
|
-
? "bg-
|
|
88
|
-
: "bg-
|
|
244
|
+
? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
|
|
245
|
+
: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 hover:border-blue-300 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/30 dark:hover:bg-blue-500/20 dark:hover:border-blue-500/40"
|
|
89
246
|
}`}
|
|
90
247
|
>
|
|
91
|
-
{id
|
|
248
|
+
{id}
|
|
92
249
|
</span>
|
|
93
250
|
);
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<span
|
|
254
|
+
role="button"
|
|
255
|
+
tabIndex={0}
|
|
256
|
+
onClick={(e) => { e.stopPropagation(); onAnchorClick("file", id); }}
|
|
257
|
+
onKeyDown={(e) => { if (e.key === "Enter") onAnchorClick("file", id); }}
|
|
258
|
+
className={`inline px-1.5 py-0.5 rounded-md text-[11px] font-mono transition-colors cursor-pointer border ${
|
|
259
|
+
isActive
|
|
260
|
+
? "bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500"
|
|
261
|
+
: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 hover:border-blue-300 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/30 dark:hover:bg-blue-500/20 dark:hover:border-blue-500/40"
|
|
262
|
+
}`}
|
|
263
|
+
>
|
|
264
|
+
{id.split("/").pop()}
|
|
265
|
+
</span>
|
|
266
|
+
);
|
|
94
267
|
},
|
|
95
268
|
blockquote: ({ children }) => (
|
|
96
269
|
<blockquote className="border-l-2 border-muted-foreground/30 pl-4 text-sm text-muted-foreground italic mb-3">{children}</blockquote>
|
|
97
270
|
),
|
|
271
|
+
details: ({ children, ...rest }) => (
|
|
272
|
+
<details className="rounded-lg border border-border mb-3 open:pb-2" {...rest}>{children}</details>
|
|
273
|
+
),
|
|
274
|
+
summary: ({ children }) => (
|
|
275
|
+
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none hover:bg-muted/50 rounded-lg">{children}</summary>
|
|
276
|
+
),
|
|
98
277
|
hr: () => <hr className="border-border my-4" />,
|
|
99
278
|
table: ({ children }) => (
|
|
100
279
|
<div className="overflow-x-auto mb-3">
|
|
@@ -105,5 +284,5 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
105
284
|
td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
|
|
106
285
|
};
|
|
107
286
|
|
|
108
|
-
return <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>{processed}</ReactMarkdown>;
|
|
287
|
+
return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }], rehypeRaw]} components={components}>{processed}</ReactMarkdown>;
|
|
109
288
|
}
|