Skip to content

Commit 573dea7

Browse files
committed
feat(chat): markdown 链接接入智能路径解析(PathLink + 存在性检查)
- pathDetection: 新增 extractMarkdownLinkHrefs,从 [text](href) 提取本地路径 - usePathHits 增加 includeMarkdownHrefs 开关,markdown 渲染时合并 bare/link 候选 - CollapsibleContent <a> renderer:本地路径命中 PathHit 时渲染 PathLink(带右键菜单),未命中或外链走原系统打开 fallback - 去除代码块外层 border 与 prose-pre 默认背景,避免双重边框
1 parent a845311 commit 573dea7

3 files changed

Lines changed: 82 additions & 44 deletions

File tree

src/views/Chat/CollapsibleContent.tsx

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function CollapsibleContent({ content, markdown, defaultCollapsed = false
134134
const [isOverflow, setIsOverflow] = useState(false);
135135
const contentRef = useRef<HTMLDivElement>(null);
136136

137-
const pathHits = usePathHits(content, cwd);
137+
const pathHits = usePathHits(content, cwd, markdown);
138138

139139
const rehypePlugins = useMemo(() => {
140140
const plugins: unknown[] = [];
@@ -161,7 +161,7 @@ export function CollapsibleContent({ content, markdown, defaultCollapsed = false
161161
className={`text-ink text-sm leading-relaxed ${collapsed ? "overflow-hidden max-h-10" : ""}`}
162162
>
163163
{markdown ? (
164-
<div className="prose prose-sm max-w-none prose-p:my-1 prose-headings:my-2 prose-pre:my-0 prose-ul:my-1 prose-ol:my-1 prose-table:my-0 prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:border prose-td:border prose-th:border-border prose-td:border-border prose-table:border-collapse prose-code:before:hidden prose-code:after:hidden">
164+
<div className="prose prose-sm max-w-none prose-p:my-1 prose-headings:my-2 prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0 prose-pre:text-ink prose-ul:my-1 prose-ol:my-1 prose-table:my-0 prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:border prose-td:border prose-th:border-border prose-td:border-border prose-table:border-collapse prose-code:before:hidden prose-code:after:hidden">
165165
<Markdown
166166
remarkPlugins={[remarkGfm]}
167167
rehypePlugins={rehypePlugins as never}
@@ -175,43 +175,53 @@ export function CollapsibleContent({ content, markdown, defaultCollapsed = false
175175
}
176176
return <span {...props}>{children}</span>;
177177
},
178-
a: ({ node: _node, href, children, ...props }) => (
179-
<a
180-
{...props}
181-
href={href}
182-
onClick={(e) => {
183-
e.preventDefault();
184-
if (!href) return;
185-
// Treat anything that isn't a real URL (http/https/mailto/...), a fragment,
186-
// or a query as a local filesystem path. Bare relative hrefs like
187-
// "output/foo.md" come from markdown links and resolve against the session cwd.
188-
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href);
189-
const isFragment = href.startsWith("#") || href.startsWith("?");
190-
const isFileUrl = href.startsWith("file://");
191-
const isLocalPath = isFileUrl || (!hasScheme && !isFragment);
192-
193-
if (isLocalPath) {
194-
let path = isFileUrl ? href.slice(7) : href;
195-
try { path = decodeURIComponent(path); } catch { /* keep raw on bad encoding */ }
196-
console.log("[link click]", { href, path, cwd });
197-
invoke("open_path", { path, cwd }).catch((err) => {
198-
console.error("[open_path failed]", err);
199-
const msg = typeof err === "string" ? err : err instanceof Error ? err.message : JSON.stringify(err);
200-
toast.error(`打开失败: ${msg}`);
201-
});
202-
} else {
203-
openUrl(href).catch((err) => {
204-
console.error("[openUrl failed]", err);
205-
const msg = typeof err === "string" ? err : err instanceof Error ? err.message : JSON.stringify(err);
206-
toast.error(`打开链接失败: ${msg}`);
207-
});
178+
a: ({ node: _node, href, children, ...props }) => {
179+
// Local path link → route through smart PathLink (existence-checked, context menu).
180+
if (href) {
181+
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href);
182+
const isFragment = href.startsWith("#") || href.startsWith("?");
183+
const isFileUrl = href.startsWith("file://");
184+
if (isFileUrl || (!hasScheme && !isFragment)) {
185+
let key = isFileUrl ? href.slice(7) : href;
186+
try { key = decodeURIComponent(key); } catch { /* keep raw */ }
187+
const hit = pathHits.get(key);
188+
if (hit) {
189+
return <PathLink text={typeof children === "string" ? children : (Array.isArray(children) ? children.join("") : key)} hit={hit} />;
208190
}
209-
}}
210-
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer"
211-
>
212-
{children}
213-
</a>
214-
),
191+
}
192+
}
193+
// Fallback: external URL (or unresolved local path) → open via system.
194+
return (
195+
<a
196+
{...props}
197+
href={href}
198+
onClick={(e) => {
199+
e.preventDefault();
200+
if (!href) return;
201+
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href);
202+
const isFragment = href.startsWith("#") || href.startsWith("?");
203+
const isFileUrl = href.startsWith("file://");
204+
const isLocalPath = isFileUrl || (!hasScheme && !isFragment);
205+
if (isLocalPath) {
206+
let path = isFileUrl ? href.slice(7) : href;
207+
try { path = decodeURIComponent(path); } catch { /* keep raw */ }
208+
invoke("open_path", { path, cwd }).catch((err) => {
209+
const msg = typeof err === "string" ? err : err instanceof Error ? err.message : JSON.stringify(err);
210+
toast.error(`打开失败: ${msg}`);
211+
});
212+
} else {
213+
openUrl(href).catch((err) => {
214+
const msg = typeof err === "string" ? err : err instanceof Error ? err.message : JSON.stringify(err);
215+
toast.error(`打开链接失败: ${msg}`);
216+
});
217+
}
218+
}}
219+
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer"
220+
>
221+
{children}
222+
</a>
223+
);
224+
},
215225
table: ({ node: _node, ...props }) => (
216226
<div className="my-2 overflow-x-auto">
217227
<table {...props} />
@@ -221,12 +231,12 @@ export function CollapsibleContent({ content, markdown, defaultCollapsed = false
221231
const match = /language-(\w+)/.exec(className || "");
222232
if (!inline && match) {
223233
return (
224-
<div className="my-2 rounded-md overflow-hidden border border-border">
234+
<div className="my-2 rounded-md overflow-hidden">
225235
<SyntaxHighlighter
226236
style={warmAcademicTheme}
227237
language={match[1]}
228238
PreTag="div"
229-
customStyle={{ margin: 0, borderRadius: 0, padding: "0.75rem 1rem", background: "#F0EEE6" }}
239+
customStyle={{ margin: 0, borderRadius: "0.375rem", padding: "0.75rem 1rem", background: "#F0EEE6" }}
230240
>
231241
{String(children).replace(/\n$/, "")}
232242
</SyntaxHighlighter>

src/views/Chat/pathDetection.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ export function extractPathCandidates(text: string): string[] {
3737
return Array.from(out);
3838
}
3939

40+
// Extract hrefs from markdown link syntax `[text](href)` or `[text](<href>)` that look like local
41+
// filesystem paths. Used so smart-path resolution covers explicit markdown links — not just bare
42+
// path strings in prose. Skips URL schemes (http://, mailto:, etc.) and fragments/queries.
43+
const MD_LINK_RE = /\[(?:[^\]\\]|\\.)*\]\(\s*<?([^\s<>)]+)>?\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*\)/g;
44+
45+
export function extractMarkdownLinkHrefs(text: string): string[] {
46+
const out = new Set<string>();
47+
let m: RegExpExecArray | null;
48+
MD_LINK_RE.lastIndex = 0;
49+
while ((m = MD_LINK_RE.exec(text)) !== null) {
50+
let href = m[1];
51+
if (!href) continue;
52+
if (href.startsWith("#") || href.startsWith("?")) continue;
53+
if (href.startsWith("file://")) {
54+
href = href.slice(7);
55+
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) {
56+
continue;
57+
}
58+
try { href = decodeURIComponent(href); } catch { /* keep raw on bad encoding */ }
59+
if (href.length >= 1) out.add(href);
60+
}
61+
return Array.from(out);
62+
}
63+
4064
const cache = new Map<string, PathHit | null>();
4165

4266
function cacheKey(raw: string, cwd: string | undefined): string {

src/views/Chat/usePathHits.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { checkPaths, extractPathCandidates, type PathHit } from "./pathDetection";
2+
import { checkPaths, extractMarkdownLinkHrefs, extractPathCandidates, type PathHit } from "./pathDetection";
33

44
function sameMap(a: Map<string, PathHit>, b: Map<string, PathHit>): boolean {
55
if (a.size !== b.size) return false;
@@ -10,11 +10,15 @@ function sameMap(a: Map<string, PathHit>, b: Map<string, PathHit>): boolean {
1010
return true;
1111
}
1212

13-
export function usePathHits(text: string, cwd?: string): Map<string, PathHit> {
13+
export function usePathHits(text: string, cwd?: string, includeMarkdownHrefs = false): Map<string, PathHit> {
1414
const [hits, setHits] = useState<Map<string, PathHit>>(new Map());
1515

1616
useEffect(() => {
17-
const candidates = extractPathCandidates(text);
17+
const seen = new Set<string>(extractPathCandidates(text));
18+
if (includeMarkdownHrefs) {
19+
for (const h of extractMarkdownLinkHrefs(text)) seen.add(h);
20+
}
21+
const candidates = Array.from(seen);
1822
if (candidates.length === 0) {
1923
setHits((prev) => (prev.size === 0 ? prev : new Map()));
2024
return;
@@ -27,7 +31,7 @@ export function usePathHits(text: string, cwd?: string): Map<string, PathHit> {
2731
return () => {
2832
cancelled = true;
2933
};
30-
}, [text, cwd]);
34+
}, [text, cwd, includeMarkdownHrefs]);
3135

3236
return hits;
3337
}

0 commit comments

Comments
 (0)