支持在会话消息中打开里面的链接和本地文件

This commit is contained in:
xintaofei
2026-03-10 13:40:06 +08:00
parent 5564fdd39f
commit 13667729b9
5 changed files with 392 additions and 22 deletions

View File

@@ -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<void>
}) {
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 (
<AlertDialog
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isLocalFile ? "Open local file?" : "Open external link?"}
</AlertDialogTitle>
<AlertDialogDescription>
{isLocalFile
? "You're about to open a local file in the Files panel."
: "You're about to visit an external website."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-28 overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs break-all">
{url}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={opening}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={opening} onClick={handleAction}>
{opening ? "Opening..." : isLocalFile ? "Open file" : "Open link"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
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) => (
<LinkSafetyModal {...props} onAction={handleOpenTarget} />
),
[handleOpenTarget]
)
return useMemo(
() => ({
enabled: true,
renderModal,
}),
[renderModal]
)
}

View File

@@ -28,6 +28,7 @@ import {
useState,
} from "react"
import { Streamdown } from "streamdown"
import { useStreamdownLinkSafety } from "./link-safety"
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]
@@ -327,17 +328,24 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>
const streamdownPlugins = { cjk, code, math, mermaid }
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
function MessageResponseImpl({ className, ...props }: MessageResponseProps) {
const linkSafety = useStreamdownLinkSafety()
return (
<Streamdown
className={cn(
"size-full [&>*: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
)

View File

@@ -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) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins} {...props}>
{children}
</Streamdown>
</CollapsibleContent>
)
({ className, children, ...props }: ReasoningContentProps) => {
const linkSafety = useStreamdownLinkSafety()
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown
linkSafety={linkSafety}
plugins={streamdownPlugins}
{...props}
>
{children}
</Streamdown>
</CollapsibleContent>
)
}
)
Reasoning.displayName = "Reasoning"

View File

@@ -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<string[]>([])
const editorTheme = useMonacoThemeSync()
const [editorMountVersion, setEditorMountVersion] = useState(0)
const [cursorLine, setCursorLine] = useState(1)
const [collapsedFiles, setCollapsedFiles] = useState<Record<string, boolean>>(
{}
@@ -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,

View File

@@ -76,7 +76,13 @@ interface WorkspaceContextValue {
closeOtherFileTabs: (tabId: string) => void
closeAllFileTabs: () => void
reorderFileTabs: (tabs: FileWorkspaceTab[]) => void
openFilePreview: (path: string) => Promise<void>
openFilePreview: (path: string, options?: { line?: number }) => Promise<void>
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<FileWorkspaceTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const [pendingFileReveal, setPendingFileReveal] = useState<{
requestId: number
path: string
line: number
} | null>(null)
const fileTabsRef = useRef<FileWorkspaceTab[]>([])
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,