支持在会话消息中打开里面的链接和本地文件
This commit is contained in:
274
src/components/ai-elements/link-safety.tsx
Normal file
274
src/components/ai-elements/link-safety.tsx
Normal 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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user