From 13667729b9dc4b8a5cdbc117e74436ea161c6fa4 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Mar 2026 13:40:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=AD=E6=89=93=E5=BC=80=E9=87=8C=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E9=93=BE=E6=8E=A5=E5=92=8C=E6=9C=AC=E5=9C=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ai-elements/link-safety.tsx | 274 ++++++++++++++++++ src/components/ai-elements/message.tsx | 14 +- src/components/ai-elements/reasoning.tsx | 37 ++- src/components/files/file-workspace-panel.tsx | 44 ++- src/contexts/workspace-context.tsx | 45 ++- 5 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 src/components/ai-elements/link-safety.tsx diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx new file mode 100644 index 0000000..1c1f7ea --- /dev/null +++ b/src/components/ai-elements/link-safety.tsx @@ -0,0 +1,274 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { openUrl } from "@tauri-apps/plugin-opener" +import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" +import { toast } from "sonner" +import { useFolderContext } from "@/contexts/folder-context" +import { useWorkspaceContext } from "@/contexts/workspace-context" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface LocalFileTarget { + path: string + line: number | null +} + +const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ +const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+\-.]*:/ + +function normalizeSlashPath(path: string): string { + return path.replace(/\\/g, "/") +} + +function decodeUriSafely(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function parseLineValue(raw: string | undefined): number | null { + if (!raw) return null + const line = Number.parseInt(raw, 10) + if (!Number.isFinite(line) || line <= 0) return null + return line +} + +function parseHashLine(hash: string): number | null { + const normalized = hash.startsWith("#") ? hash.slice(1) : hash + if (!normalized) return null + return ( + parseLineValue(normalized.match(/^L(\d+)$/i)?.[1]) ?? + parseLineValue(normalized.match(/^line=(\d+)$/i)?.[1]) ?? + parseLineValue(normalized.match(/^(\d+)$/)?.[1]) + ) +} + +function splitPathAndLine(rawPath: string): LocalFileTarget { + const trimmed = rawPath.trim() + const match = trimmed.match(/^(.*):(\d+)(?::\d+)?$/) + if (!match) { + return { path: trimmed, line: null } + } + + const maybePath = match[1] + if (!maybePath || maybePath.endsWith("://")) { + return { path: trimmed, line: null } + } + + const line = parseLineValue(match[2]) + if (!line) { + return { path: trimmed, line: null } + } + + return { path: maybePath, line } +} + +function isLocalPathLike(path: string): boolean { + return ( + path.startsWith("/") || + path.startsWith("./") || + path.startsWith("../") || + path.startsWith("~/") || + WINDOWS_ABSOLUTE_PATH.test(path) + ) +} + +function parseLocalFileTarget(rawUrl: string): LocalFileTarget | null { + const raw = decodeUriSafely(rawUrl.trim()) + if (!raw) return null + + if (raw.toLowerCase().startsWith("file://")) { + try { + const parsed = new URL(raw) + const rawPathname = decodeUriSafely(parsed.pathname) + const normalizedPathname = + rawPathname.startsWith("/") && WINDOWS_ABSOLUTE_PATH.test(rawPathname) + ? rawPathname.slice(1) + : rawPathname + const pathAndLine = splitPathAndLine(normalizedPathname) + if (!pathAndLine.path) return null + return { + path: normalizeSlashPath(pathAndLine.path), + line: parseHashLine(parsed.hash) ?? pathAndLine.line, + } + } catch { + return null + } + } + + if (URL_SCHEME.test(raw) && !WINDOWS_ABSOLUTE_PATH.test(raw)) { + return null + } + + const hashIndex = raw.indexOf("#") + const hash = hashIndex >= 0 ? raw.slice(hashIndex) : "" + const withoutHash = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw + const queryIndex = withoutHash.indexOf("?") + const withoutQuery = + queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash + const pathAndLine = splitPathAndLine(withoutQuery) + if (!isLocalPathLike(pathAndLine.path)) return null + + return { + path: normalizeSlashPath(pathAndLine.path), + line: parseHashLine(hash) ?? pathAndLine.line, + } +} + +function toWorkspaceRelativePath( + path: string, + workspacePath: string +): string | null { + const normalizedPath = normalizeSlashPath(path) + const normalizedWorkspace = normalizeSlashPath(workspacePath).replace( + /\/+$/, + "" + ) + if (!normalizedPath || !normalizedWorkspace) return null + + if (!normalizedPath.startsWith("/") && !WINDOWS_ABSOLUTE_PATH.test(path)) { + return normalizedPath.replace(/^\.\/+/, "") + } + + const isWindows = WINDOWS_ABSOLUTE_PATH.test(normalizedWorkspace) + const pathForCompare = isWindows + ? normalizedPath.toLowerCase() + : normalizedPath + const workspaceForCompare = isWindows + ? normalizedWorkspace.toLowerCase() + : normalizedWorkspace + + if (pathForCompare === workspaceForCompare) return null + if (!pathForCompare.startsWith(`${workspaceForCompare}/`)) return null + + return normalizedPath.slice(normalizedWorkspace.length + 1) +} + +function LinkSafetyModal({ + url, + isOpen, + onClose, + onAction, +}: LinkSafetyModalProps & { + onAction: (url: string) => Promise +}) { + const [opening, setOpening] = useState(false) + const localTarget = useMemo(() => parseLocalFileTarget(url), [url]) + const isLocalFile = Boolean(localTarget) + + const handleAction = useCallback(() => { + if (opening) return + setOpening(true) + void onAction(url).finally(() => { + setOpening(false) + }) + }, [onAction, opening, url]) + + return ( + { + if (!nextOpen) onClose() + }} + > + + + + {isLocalFile ? "Open local file?" : "Open external link?"} + + + {isLocalFile + ? "You're about to open a local file in the Files panel." + : "You're about to visit an external website."} + + +
+ {url} +
+ + Cancel + + {opening ? "Opening..." : isLocalFile ? "Open file" : "Open link"} + + +
+
+ ) +} + +export function useStreamdownLinkSafety(): LinkSafetyConfig { + const { folder } = useFolderContext() + const folderPath = folder?.path + const { openFilePreview } = useWorkspaceContext() + + const handleOpenTarget = useCallback( + async (url: string) => { + const localTarget = parseLocalFileTarget(url) + if (localTarget) { + if (!folderPath) { + toast.error("Cannot open local file", { + description: "No workspace folder is currently active.", + }) + return + } + + const relativePath = toWorkspaceRelativePath( + localTarget.path, + folderPath + ) + if (!relativePath) { + toast.error("Cannot open local file", { + description: "The file is outside the current workspace folder.", + }) + return + } + + try { + await openFilePreview(relativePath, { + line: localTarget.line ?? undefined, + }) + } catch (error) { + toast.error("Failed to open local file", { + description: error instanceof Error ? error.message : String(error), + }) + } + return + } + + try { + await openUrl(url) + } catch (error) { + toast.error("Failed to open link", { + description: error instanceof Error ? error.message : String(error), + }) + } + }, + [folderPath, openFilePreview] + ) + + const renderModal = useCallback( + (props: LinkSafetyModalProps) => ( + + ), + [handleOpenTarget] + ) + + return useMemo( + () => ({ + enabled: true, + renderModal, + }), + [renderModal] + ) +} diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 1794ae9..a2f15b6 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -28,6 +28,7 @@ import { useState, } from "react" import { Streamdown } from "streamdown" +import { useStreamdownLinkSafety } from "./link-safety" export type MessageProps = HTMLAttributes & { from: UIMessage["role"] @@ -327,17 +328,24 @@ export type MessageResponseProps = ComponentProps const streamdownPlugins = { cjk, code, math, mermaid } -export const MessageResponse = memo( - ({ className, ...props }: MessageResponseProps) => ( +function MessageResponseImpl({ className, ...props }: MessageResponseProps) { + const linkSafety = useStreamdownLinkSafety() + + return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", className )} + linkSafety={linkSafety} plugins={streamdownPlugins} {...props} /> - ), + ) +} + +export const MessageResponse = memo( + MessageResponseImpl, (prevProps, nextProps) => prevProps.children === nextProps.children ) diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index e34db87..ebaf0bb 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -28,6 +28,7 @@ import { import { Streamdown } from "streamdown" import { Shimmer } from "./shimmer" +import { useStreamdownLinkSafety } from "./link-safety" interface ReasoningContextValue { isStreaming: boolean @@ -214,20 +215,28 @@ export type ReasoningContentProps = ComponentProps< const streamdownPlugins = { cjk, code, math, mermaid } export const ReasoningContent = memo( - ({ className, children, ...props }: ReasoningContentProps) => ( - - - {children} - - - ) + ({ className, children, ...props }: ReasoningContentProps) => { + const linkSafety = useStreamdownLinkSafety() + + return ( + + + {children} + + + ) + } ) Reasoning.displayName = "Reasoning" diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 567ba49..5e40d8a 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -75,6 +75,10 @@ function normalizeDiffPath(rawPath: string): string | null { return trimmed.replace(/\\/g, "/") } +function normalizeWorkspacePath(path: string): string { + return path.replace(/\\/g, "/") +} + function parsePathFromDiffGitLine(line: string): string | null { if (!line.startsWith("diff --git ")) return null const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/) @@ -574,6 +578,8 @@ export function FileWorkspacePanel() { const t = useTranslations("Folder.fileWorkspacePanel") const { activeFileTab, + consumePendingFileReveal, + pendingFileReveal, openBranchDiff, openCommitDiff, openFilePreview, @@ -586,6 +592,7 @@ export function FileWorkspacePanel() { const cursorListenerRef = useRef<{ dispose: () => void } | null>(null) const gitChangeDecorationsRef = useRef([]) const editorTheme = useMonacoThemeSync() + const [editorMountVersion, setEditorMountVersion] = useState(0) const [cursorLine, setCursorLine] = useState(1) const [collapsedFiles, setCollapsedFiles] = useState>( {} @@ -826,6 +833,7 @@ export function FileWorkspacePanel() { setCursorLine(event.position.lineNumber) } ) + setEditorMountVersion((prev) => prev + 1) setCursorLine(editorInstance.getPosition()?.lineNumber ?? 1) applyHiddenAreas() applyGitChangeDecorations() @@ -835,11 +843,17 @@ export function FileWorkspacePanel() { const jumpToLine = useCallback((lineNumber: number) => { const editorInstance = editorRef.current - if (!editorInstance) return + if (!editorInstance) return false - editorInstance.revealLineInCenter(lineNumber) - editorInstance.setPosition({ lineNumber, column: 1 }) + const model = editorInstance.getModel() + if (!model) return false + const maxLine = model.getLineCount() + const targetLine = Math.min(Math.max(1, lineNumber), maxLine) + + editorInstance.revealLineInCenter(targetLine) + editorInstance.setPosition({ lineNumber: targetLine, column: 1 }) editorInstance.focus() + return true }, []) const jumpToHunk = useCallback( @@ -897,6 +911,30 @@ export function FileWorkspacePanel() { applyGitChangeDecorations() }, [activeFileTab?.id, applyGitChangeDecorations]) + useEffect(() => { + if (!pendingFileReveal) return + if (!isFileTab || !activeFileTab || activeFileTab.loading) return + if (!activeFileTab.path) return + if ( + normalizeWorkspacePath(activeFileTab.path) !== + normalizeWorkspacePath(pendingFileReveal.path) + ) { + return + } + + const jumped = jumpToLine(pendingFileReveal.line) + if (!jumped) return + + consumePendingFileReveal(pendingFileReveal.requestId) + }, [ + activeFileTab, + consumePendingFileReveal, + editorMountVersion, + isFileTab, + jumpToLine, + pendingFileReveal, + ]) + useEffect(() => { autoSaveGuardRef.current = { canEdit, diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index 617b4ed..13d2daf 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -76,7 +76,13 @@ interface WorkspaceContextValue { closeOtherFileTabs: (tabId: string) => void closeAllFileTabs: () => void reorderFileTabs: (tabs: FileWorkspaceTab[]) => void - openFilePreview: (path: string) => Promise + openFilePreview: (path: string, options?: { line?: number }) => Promise + pendingFileReveal: { + requestId: number + path: string + line: number + } | null + consumePendingFileReveal: (requestId: number) => void openWorkingTreeDiff: ( path?: string, options?: { mode?: "auto" | "unified" | "overview" } @@ -186,7 +192,13 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const [restored, setRestored] = useState(false) const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const [pendingFileReveal, setPendingFileReveal] = useState<{ + requestId: number + path: string + line: number + } | null>(null) const fileTabsRef = useRef([]) + const fileRevealRequestIdRef = useRef(0) useEffect(() => { fileTabsRef.current = fileTabs @@ -310,10 +322,30 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { [] ) + const consumePendingFileReveal = useCallback((requestId: number) => { + setPendingFileReveal((prev) => + prev && prev.requestId === requestId ? null : prev + ) + }, []) + const openFilePreview = useCallback( - async (rawPath: string) => { + async (rawPath: string, options?: { line?: number }) => { if (!folderPath) return const path = normalizePath(rawPath) + const requestedLine = + typeof options?.line === "number" && Number.isFinite(options.line) + ? Math.max(1, Math.floor(options.line)) + : null + if (requestedLine) { + fileRevealRequestIdRef.current += 1 + setPendingFileReveal({ + requestId: fileRevealRequestIdRef.current, + path, + line: requestedLine, + }) + } else { + setPendingFileReveal(null) + } const tabId = `file:${path}` upsertLoadingTab( loadingTab( @@ -363,6 +395,11 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { ) ) } catch (error) { + if (requestedLine) { + setPendingFileReveal((prev) => + prev && prev.path === path ? null : prev + ) + } rejectTab(tabId, error instanceof Error ? error.message : String(error)) } }, @@ -960,6 +997,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { closeAllFileTabs, reorderFileTabs, openFilePreview, + pendingFileReveal, + consumePendingFileReveal, openWorkingTreeDiff, openBranchDiff, openCommitDiff, @@ -986,6 +1025,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { closeAllFileTabs, reorderFileTabs, openFilePreview, + pendingFileReveal, + consumePendingFileReveal, openWorkingTreeDiff, openBranchDiff, openCommitDiff,