支持在会话消息中打开里面的链接和本地文件
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,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { Streamdown } from "streamdown"
|
import { Streamdown } from "streamdown"
|
||||||
|
import { useStreamdownLinkSafety } from "./link-safety"
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"]
|
from: UIMessage["role"]
|
||||||
@@ -327,17 +328,24 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>
|
|||||||
|
|
||||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
const streamdownPlugins = { cjk, code, math, mermaid }
|
||||||
|
|
||||||
export const MessageResponse = memo(
|
function MessageResponseImpl({ className, ...props }: MessageResponseProps) {
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
const linkSafety = useStreamdownLinkSafety()
|
||||||
|
|
||||||
|
return (
|
||||||
<Streamdown
|
<Streamdown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
linkSafety={linkSafety}
|
||||||
plugins={streamdownPlugins}
|
plugins={streamdownPlugins}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageResponse = memo(
|
||||||
|
MessageResponseImpl,
|
||||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { Streamdown } from "streamdown"
|
import { Streamdown } from "streamdown"
|
||||||
|
|
||||||
import { Shimmer } from "./shimmer"
|
import { Shimmer } from "./shimmer"
|
||||||
|
import { useStreamdownLinkSafety } from "./link-safety"
|
||||||
|
|
||||||
interface ReasoningContextValue {
|
interface ReasoningContextValue {
|
||||||
isStreaming: boolean
|
isStreaming: boolean
|
||||||
@@ -214,7 +215,10 @@ export type ReasoningContentProps = ComponentProps<
|
|||||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
const streamdownPlugins = { cjk, code, math, mermaid }
|
||||||
|
|
||||||
export const ReasoningContent = memo(
|
export const ReasoningContent = memo(
|
||||||
({ className, children, ...props }: ReasoningContentProps) => (
|
({ className, children, ...props }: ReasoningContentProps) => {
|
||||||
|
const linkSafety = useStreamdownLinkSafety()
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-4 text-sm",
|
"mt-4 text-sm",
|
||||||
@@ -223,11 +227,16 @@ export const ReasoningContent = memo(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Streamdown plugins={streamdownPlugins} {...props}>
|
<Streamdown
|
||||||
|
linkSafety={linkSafety}
|
||||||
|
plugins={streamdownPlugins}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Reasoning.displayName = "Reasoning"
|
Reasoning.displayName = "Reasoning"
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ function normalizeDiffPath(rawPath: string): string | null {
|
|||||||
return trimmed.replace(/\\/g, "/")
|
return trimmed.replace(/\\/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspacePath(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
function parsePathFromDiffGitLine(line: string): string | null {
|
function parsePathFromDiffGitLine(line: string): string | null {
|
||||||
if (!line.startsWith("diff --git ")) return null
|
if (!line.startsWith("diff --git ")) return null
|
||||||
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
|
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
|
||||||
@@ -574,6 +578,8 @@ export function FileWorkspacePanel() {
|
|||||||
const t = useTranslations("Folder.fileWorkspacePanel")
|
const t = useTranslations("Folder.fileWorkspacePanel")
|
||||||
const {
|
const {
|
||||||
activeFileTab,
|
activeFileTab,
|
||||||
|
consumePendingFileReveal,
|
||||||
|
pendingFileReveal,
|
||||||
openBranchDiff,
|
openBranchDiff,
|
||||||
openCommitDiff,
|
openCommitDiff,
|
||||||
openFilePreview,
|
openFilePreview,
|
||||||
@@ -586,6 +592,7 @@ export function FileWorkspacePanel() {
|
|||||||
const cursorListenerRef = useRef<{ dispose: () => void } | null>(null)
|
const cursorListenerRef = useRef<{ dispose: () => void } | null>(null)
|
||||||
const gitChangeDecorationsRef = useRef<string[]>([])
|
const gitChangeDecorationsRef = useRef<string[]>([])
|
||||||
const editorTheme = useMonacoThemeSync()
|
const editorTheme = useMonacoThemeSync()
|
||||||
|
const [editorMountVersion, setEditorMountVersion] = useState(0)
|
||||||
const [cursorLine, setCursorLine] = useState(1)
|
const [cursorLine, setCursorLine] = useState(1)
|
||||||
const [collapsedFiles, setCollapsedFiles] = useState<Record<string, boolean>>(
|
const [collapsedFiles, setCollapsedFiles] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
@@ -826,6 +833,7 @@ export function FileWorkspacePanel() {
|
|||||||
setCursorLine(event.position.lineNumber)
|
setCursorLine(event.position.lineNumber)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
setEditorMountVersion((prev) => prev + 1)
|
||||||
setCursorLine(editorInstance.getPosition()?.lineNumber ?? 1)
|
setCursorLine(editorInstance.getPosition()?.lineNumber ?? 1)
|
||||||
applyHiddenAreas()
|
applyHiddenAreas()
|
||||||
applyGitChangeDecorations()
|
applyGitChangeDecorations()
|
||||||
@@ -835,11 +843,17 @@ export function FileWorkspacePanel() {
|
|||||||
|
|
||||||
const jumpToLine = useCallback((lineNumber: number) => {
|
const jumpToLine = useCallback((lineNumber: number) => {
|
||||||
const editorInstance = editorRef.current
|
const editorInstance = editorRef.current
|
||||||
if (!editorInstance) return
|
if (!editorInstance) return false
|
||||||
|
|
||||||
editorInstance.revealLineInCenter(lineNumber)
|
const model = editorInstance.getModel()
|
||||||
editorInstance.setPosition({ lineNumber, column: 1 })
|
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()
|
editorInstance.focus()
|
||||||
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const jumpToHunk = useCallback(
|
const jumpToHunk = useCallback(
|
||||||
@@ -897,6 +911,30 @@ export function FileWorkspacePanel() {
|
|||||||
applyGitChangeDecorations()
|
applyGitChangeDecorations()
|
||||||
}, [activeFileTab?.id, 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(() => {
|
useEffect(() => {
|
||||||
autoSaveGuardRef.current = {
|
autoSaveGuardRef.current = {
|
||||||
canEdit,
|
canEdit,
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ interface WorkspaceContextValue {
|
|||||||
closeOtherFileTabs: (tabId: string) => void
|
closeOtherFileTabs: (tabId: string) => void
|
||||||
closeAllFileTabs: () => void
|
closeAllFileTabs: () => void
|
||||||
reorderFileTabs: (tabs: FileWorkspaceTab[]) => 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: (
|
openWorkingTreeDiff: (
|
||||||
path?: string,
|
path?: string,
|
||||||
options?: { mode?: "auto" | "unified" | "overview" }
|
options?: { mode?: "auto" | "unified" | "overview" }
|
||||||
@@ -186,7 +192,13 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
|
|||||||
const [restored, setRestored] = useState(false)
|
const [restored, setRestored] = useState(false)
|
||||||
const [fileTabs, setFileTabs] = useState<FileWorkspaceTab[]>([])
|
const [fileTabs, setFileTabs] = useState<FileWorkspaceTab[]>([])
|
||||||
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
|
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
|
||||||
|
const [pendingFileReveal, setPendingFileReveal] = useState<{
|
||||||
|
requestId: number
|
||||||
|
path: string
|
||||||
|
line: number
|
||||||
|
} | null>(null)
|
||||||
const fileTabsRef = useRef<FileWorkspaceTab[]>([])
|
const fileTabsRef = useRef<FileWorkspaceTab[]>([])
|
||||||
|
const fileRevealRequestIdRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fileTabsRef.current = fileTabs
|
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(
|
const openFilePreview = useCallback(
|
||||||
async (rawPath: string) => {
|
async (rawPath: string, options?: { line?: number }) => {
|
||||||
if (!folderPath) return
|
if (!folderPath) return
|
||||||
const path = normalizePath(rawPath)
|
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}`
|
const tabId = `file:${path}`
|
||||||
upsertLoadingTab(
|
upsertLoadingTab(
|
||||||
loadingTab(
|
loadingTab(
|
||||||
@@ -363,6 +395,11 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestedLine) {
|
||||||
|
setPendingFileReveal((prev) =>
|
||||||
|
prev && prev.path === path ? null : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
rejectTab(tabId, error instanceof Error ? error.message : String(error))
|
rejectTab(tabId, error instanceof Error ? error.message : String(error))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -960,6 +997,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
|
|||||||
closeAllFileTabs,
|
closeAllFileTabs,
|
||||||
reorderFileTabs,
|
reorderFileTabs,
|
||||||
openFilePreview,
|
openFilePreview,
|
||||||
|
pendingFileReveal,
|
||||||
|
consumePendingFileReveal,
|
||||||
openWorkingTreeDiff,
|
openWorkingTreeDiff,
|
||||||
openBranchDiff,
|
openBranchDiff,
|
||||||
openCommitDiff,
|
openCommitDiff,
|
||||||
@@ -986,6 +1025,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
|
|||||||
closeAllFileTabs,
|
closeAllFileTabs,
|
||||||
reorderFileTabs,
|
reorderFileTabs,
|
||||||
openFilePreview,
|
openFilePreview,
|
||||||
|
pendingFileReveal,
|
||||||
|
consumePendingFileReveal,
|
||||||
openWorkingTreeDiff,
|
openWorkingTreeDiff,
|
||||||
openBranchDiff,
|
openBranchDiff,
|
||||||
openCommitDiff,
|
openCommitDiff,
|
||||||
|
|||||||
Reference in New Issue
Block a user