"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import dynamic from "next/dynamic" import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react" import type { editor as MonacoEditorNs } from "monaco-editor" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { ImagePreview } from "@/components/files/image-preview" import { DiffViewer } from "@/components/diff/diff-viewer" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { cjk } from "@streamdown/cjk" import { code } from "@streamdown/code" import { math } from "@streamdown/math" import { mermaid } from "@streamdown/mermaid" import { Streamdown } from "streamdown" import { readFileBase64 } from "@/lib/api" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import "@/lib/monaco-local" const previewPlugins = { cjk, code, math, mermaid } function resolveRelativePath(base: string, relative: string): string { // Strip URL fragment (e.g. #gh-light-mode-only) and query string const cleaned = relative.replace(/[#?].*$/, "") // Preserve leading "/" for absolute paths, filter empty segments const isAbsolute = base.startsWith("/") const parts = base.split("/").filter(Boolean) for (const seg of cleaned.split("/")) { if (seg === "..") { if (parts.length > 0) parts.pop() } else if (seg !== "." && seg !== "") { parts.push(seg) } } return (isAbsolute ? "/" : "") + parts.join("/") } /** * Pre-resolve relative paths in markdown image/link syntax before Streamdown. * * rehype-harden resolves "../foo" via `new URL("../foo", "http://example.com")` * which loses directory context (e.g. "../images/a.png" from "docs/readme/" * becomes "/images/a.png" instead of "/docs/images/a.png"). * * This function resolves relative paths against the file's directory BEFORE * Streamdown processes them, using "./" prefix so rehype-harden preserves them. */ function preprocessMarkdownPaths( content: string, relativeFileDir: string ): string { const resolveUrl = (url: string): string => { // Skip absolute URLs, anchors, and already-root-relative paths if (/^https?:\/\/|^data:|^blob:|^#|^\//.test(url)) return url // Separate fragment/query from path const fragIdx = url.search(/[#?]/) const pathPart = fragIdx >= 0 ? url.slice(0, fragIdx) : url const fragment = fragIdx >= 0 ? url.slice(fragIdx) : "" // Resolve relative to file directory within project const parts = relativeFileDir.split("/").filter(Boolean) for (const seg of pathPart.split("/")) { if (seg === "..") { if (parts.length > 0) parts.pop() } else if (seg !== "." && seg !== "") { parts.push(seg) } } // "./" prefix ensures rehype-harden recognizes it as relative return "./" + parts.join("/") + fragment } // Pre-resolve image paths: ![alt](url) or ![alt](url "title") let result = content.replace( /!\[([^\]]*)\]\(([^)\s"']+)([^)]*)\)/g, (match, alt, url, rest) => { const resolved = resolveUrl(url) if (resolved === url) return match return `![${alt}](${resolved}${rest})` } ) // Pre-resolve image-wrapped link paths: [![alt](img)](url) result = result.replace( /\[(!\[[^\]]*\]\([^)]*\))\]\(([^)\s"']+)([^)]*)\)/g, (match, imgPart, url, rest) => { const resolved = resolveUrl(url) if (resolved === url) return match return `[${imgPart}](${resolved}${rest})` } ) // Pre-resolve link paths: [text](url) — negative lookbehind to skip images result = result.replace( /(? { const resolved = resolveUrl(url) if (resolved === url) return match return `[${text}](${resolved}${rest})` } ) // Pre-resolve HTML and tags result = result.replace( /<(a\s[^>]*?href|img\s[^>]*?src)=(["'])([^"']+)\2/gi, (match, prefix, quote, url) => { const resolved = resolveUrl(url) if (resolved === url) return match return `<${prefix}=${quote}${resolved}${quote}` } ) return result } const MIME_BY_EXT: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp", bmp: "image/bmp", ico: "image/x-icon", } function useLocalImageSrc( src: string | undefined, fileDir: string | null, folderPath: string | null ): string | undefined { const [dataUrl, setDataUrl] = useState(undefined) const isLocal = src && fileDir && !/^https?:\/\/|^data:|^blob:/.test(src) useEffect(() => { if (!isLocal || !src || !fileDir) return let cancelled = false // rehype-harden resolves "../foo" to "/foo" via new URL(src, "http://example.com") // Root-relative paths (starting with "/") should resolve against folderPath const absPath = src.startsWith("/") && folderPath ? resolveRelativePath(folderPath, src) : resolveRelativePath(fileDir, src) const ext = absPath.split(".").pop()?.toLowerCase() ?? "" const mime = MIME_BY_EXT[ext] ?? "image/png" readFileBase64(absPath) .then((b64) => { if (!cancelled) { setDataUrl(`data:${mime};base64,${b64}`) } }) .catch((err) => { console.error( `[PreviewImage] readFileBase64 failed for "${absPath}":`, typeof err === "object" ? JSON.stringify(err) : err ) }) return () => { cancelled = true } }, [isLocal, src, fileDir, folderPath]) if (!isLocal) return src return dataUrl } function PreviewImage({ fileDir, folderPath, ...props }: React.ComponentProps<"img"> & { fileDir: string | null folderPath: string | null }) { const src = typeof props.src === "string" ? props.src : undefined const resolvedSrc = useLocalImageSrc(src, fileDir, folderPath) // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text return } const AUTO_SAVE_DELAY_MS = 5000 function buildMonacoModelPath(path: string | null, id: string): string { if (!path) return `inmemory://model/${encodeURIComponent(id)}` const normalized = path.replace(/\\/g, "/") const encoded = normalized.split("/").map(encodeURIComponent).join("/") return `file:///${encoded}` } interface DiffOutlineFile { key: string path: string startLine: number endLine: number additions: number deletions: number hunks: DiffOutlineHunk[] } interface DiffOutlineHunk { key: string startLine: number endLine: number header: string additions: number deletions: number } interface DiffOutline { files: DiffOutlineFile[] totalAdditions: number totalDeletions: number totalHunks: number } type DiffListContext = | { kind: "commit"; commitHash: string; commitMessage: string | null } | { kind: "working"; path: string } | { kind: "branch"; branch: string; path: string } function decodeDiffTabToken(token: string): string { try { return decodeURIComponent(token) } catch { return token } } function normalizeDiffPath(rawPath: string): string | null { const trimmed = rawPath.trim().replace(/^"|"$/g, "") if (!trimmed || trimmed === "/dev/null") return null if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) { return trimmed.slice(2).replace(/\\/g, "/") } 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+(.+)$/) if (!match) return null return normalizeDiffPath(match[2]) ?? normalizeDiffPath(match[1]) } function parsePathFromFileHeader( line: string, prefix: "--- " | "+++ " ): string | null { if (!line.startsWith(prefix)) return null return normalizeDiffPath(line.slice(prefix.length)) } function parsePathFromApplyPatchLine(line: string): string | null { const prefixes = ["*** Update File: ", "*** Add File: ", "*** Delete File: "] for (const prefix of prefixes) { if (line.startsWith(prefix)) { return normalizeDiffPath(line.slice(prefix.length)) } } return null } function parseMovedPathFromApplyPatchLine(line: string): string | null { if (!line.startsWith("*** Move to: ")) return null return normalizeDiffPath(line.slice(13)) } function buildDiffOutline(content: string): DiffOutline | null { if (!content.trim()) return null const lines = content.split("\n") const files: DiffOutlineFile[] = [] let current: DiffOutlineFile | null = null let currentHunk: { startLine: number header: string additions: number deletions: number } | null = null let fileIndex = 1 let hunkIndex = 1 const createFile = ( lineNumber: number, path: string | null ): DiffOutlineFile => { const entry: DiffOutlineFile = { key: `${lineNumber}-${fileIndex}`, path: path ?? `Diff #${fileIndex}`, startLine: lineNumber, endLine: lineNumber, additions: 0, deletions: 0, hunks: [], } fileIndex += 1 return entry } const flushHunk = (endLine: number) => { if (!current || !currentHunk) return const normalizedEnd = Math.max(currentHunk.startLine, endLine) current.hunks.push({ key: `${current.key}:hunk-${hunkIndex}`, startLine: currentHunk.startLine, endLine: normalizedEnd, header: currentHunk.header, additions: currentHunk.additions, deletions: currentHunk.deletions, }) hunkIndex += 1 currentHunk = null } const flushFile = () => { if (!current) return if (currentHunk) { flushHunk(current.endLine) } if ( current.hunks.length === 0 && (current.additions > 0 || current.deletions > 0) ) { current.hunks.push({ key: `${current.key}:hunk-${hunkIndex}`, startLine: current.startLine, endLine: current.endLine, header: "Change", additions: current.additions, deletions: current.deletions, }) hunkIndex += 1 } files.push(current) current = null } for (const [index, line] of lines.entries()) { const lineNumber = index + 1 const diffGitPath = parsePathFromDiffGitLine(line) if (diffGitPath) { flushFile() current = createFile(lineNumber, diffGitPath) continue } const applyPatchPath = parsePathFromApplyPatchLine(line) if (applyPatchPath) { flushFile() current = createFile(lineNumber, applyPatchPath) continue } if (line.startsWith("--- ") && currentHunk) { flushHunk(lineNumber - 1) } const movedPath = parseMovedPathFromApplyPatchLine(line) if (movedPath && current) { current.path = movedPath } if (!current) { const minusPath = parsePathFromFileHeader(line, "--- ") if (minusPath) { current = createFile(lineNumber, minusPath) } else { const plusPath = parsePathFromFileHeader(line, "+++ ") if (plusPath) current = createFile(lineNumber, plusPath) } } else { const plusPath = parsePathFromFileHeader(line, "+++ ") if (plusPath) current.path = plusPath } if (!current) continue current.endLine = lineNumber if (line.startsWith("@@ ")) { if (currentHunk) { flushHunk(lineNumber - 1) } currentHunk = { startLine: lineNumber, header: line, additions: 0, deletions: 0, } continue } if (line.startsWith("+") && !line.startsWith("+++")) { current.additions += 1 if (currentHunk) currentHunk.additions += 1 } if (line.startsWith("-") && !line.startsWith("---")) { current.deletions += 1 if (currentHunk) currentHunk.deletions += 1 } } flushFile() if (files.length === 0) return null const totalAdditions = files.reduce((sum, file) => sum + file.additions, 0) const totalDeletions = files.reduce((sum, file) => sum + file.deletions, 0) const totalHunks = files.reduce((sum, file) => sum + file.hunks.length, 0) return { files, totalAdditions, totalDeletions, totalHunks, } } function setEditorHiddenAreas( editor: MonacoEditorNs.IStandaloneCodeEditor, ranges: { startLineNumber: number startColumn: number endLineNumber: number endColumn: number }[] ) { const hiddenAreaEditor = editor as unknown as { setHiddenAreas?: ( hiddenRanges: { startLineNumber: number startColumn: number endLineNumber: number endColumn: number }[] ) => void } hiddenAreaEditor.setHiddenAreas?.(ranges) } const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), { ssr: false, }) function normalizeLineEndings(text: string): string { return text.replace(/\r\n/g, "\n") } function splitDiffLines(text: string): string[] { if (!text) return [] return normalizeLineEndings(text).split("\n") } function lowerBound(values: number[], target: number): number { let lo = 0 let hi = values.length while (lo < hi) { const mid = (lo + hi) >>> 1 if (values[mid] < target) { lo = mid + 1 } else { hi = mid } } return lo } function computeLcsMatches( baseLines: string[], currentLines: string[] ): Array<{ baseIndex: number; currentIndex: number }> | null { const MAX_MATCHES_PER_LINE = 256 const MAX_TOKENS = 200_000 const basePositions = new Map() for (const [index, line] of baseLines.entries()) { const positions = basePositions.get(line) if (positions) { positions.push(index) } else { basePositions.set(line, [index]) } } const tokens: Array<{ baseIndex: number; currentIndex: number }> = [] for (const [currentIndex, line] of currentLines.entries()) { const positions = basePositions.get(line) if (!positions) continue if (positions.length > MAX_MATCHES_PER_LINE) continue for (let pos = positions.length - 1; pos >= 0; pos -= 1) { tokens.push({ baseIndex: positions[pos], currentIndex }) if (tokens.length > MAX_TOKENS) return null } } if (tokens.length === 0) return [] const tails: number[] = [] const tailsTokenIndex: number[] = [] const prevTokenIndex = Array(tokens.length).fill(-1) for (const [tokenIndex, token] of tokens.entries()) { const len = lowerBound(tails, token.baseIndex) tails[len] = token.baseIndex tailsTokenIndex[len] = tokenIndex if (len > 0) { prevTokenIndex[tokenIndex] = tailsTokenIndex[len - 1] } } const matches: Array<{ baseIndex: number; currentIndex: number }> = [] let cursor = tailsTokenIndex[tails.length - 1] while (cursor >= 0) { const token = tokens[cursor] matches.push({ baseIndex: token.baseIndex, currentIndex: token.currentIndex, }) cursor = prevTokenIndex[cursor] } matches.reverse() return matches } interface GitGutterLineMarkers { added: number[] modified: number[] deleted: number[] } const EMPTY_GIT_GUTTER_LINE_MARKERS: GitGutterLineMarkers = { added: [], modified: [], deleted: [], } function toSortedUniqueLineNumbers(lineNumbers: number[]): number[] { if (lineNumbers.length <= 1) return lineNumbers return [...new Set(lineNumbers)].sort((a, b) => a - b) } function appendLineRange( target: number[], startIndexInclusive: number, endIndexExclusive: number ) { for (let index = startIndexInclusive; index < endIndexExclusive; index += 1) { target.push(index + 1) } } function computeGitGutterLineMarkers( baseContent: string, currentContent: string ): GitGutterLineMarkers { const MAX_TOTAL_LINES = 20_000 if (baseContent === currentContent) { return EMPTY_GIT_GUTTER_LINE_MARKERS } const baseLines = splitDiffLines(baseContent) const currentLines = splitDiffLines(currentContent) if (baseLines.length + currentLines.length > MAX_TOTAL_LINES) { return EMPTY_GIT_GUTTER_LINE_MARKERS } if ( normalizeLineEndings(baseContent) === normalizeLineEndings(currentContent) ) { return EMPTY_GIT_GUTTER_LINE_MARKERS } if (baseLines.length === 0) { return { added: currentLines.map((_, index) => index + 1), modified: [], deleted: [], } } if (currentLines.length === 0) { return { added: [], modified: [], deleted: [1], } } const matches = computeLcsMatches(baseLines, currentLines) if (matches === null) { return EMPTY_GIT_GUTTER_LINE_MARKERS } const added: number[] = [] const modified: number[] = [] const deleted: number[] = [] let previousBase = -1 let previousCurrent = -1 const sentinels = [ ...matches, { baseIndex: baseLines.length, currentIndex: currentLines.length }, ] for (const match of sentinels) { const baseGap = match.baseIndex - previousBase - 1 const currentGap = match.currentIndex - previousCurrent - 1 if (baseGap === 0 && currentGap > 0) { appendLineRange(added, previousCurrent + 1, match.currentIndex) } else if (baseGap > 0 && currentGap === 0) { const anchorLine = Math.max( 1, Math.min(currentLines.length, match.currentIndex + 1) ) deleted.push(anchorLine) } else if (baseGap > 0 && currentGap > 0) { appendLineRange(modified, previousCurrent + 1, match.currentIndex) } previousBase = match.baseIndex previousCurrent = match.currentIndex } return { added: toSortedUniqueLineNumbers(added), modified: toSortedUniqueLineNumbers(modified), deleted: toSortedUniqueLineNumbers(deleted), } } function DiffFileList({ diffOutline, badge, description, onOpenDiff, openFilePreview, }: { diffOutline: DiffOutline badge?: string | null description?: string | null onOpenDiff: (path: string) => Promise openFilePreview: (path: string) => Promise }) { const t = useTranslations("Folder.fileWorkspacePanel") return (
{badge && ( {badge} )} {t("fileCount", { count: diffOutline.files.length })} +{diffOutline.totalAdditions} -{diffOutline.totalDeletions}
{description && (

{description}

)}
{diffOutline.files.map((file) => ( { void onOpenDiff(file.path) }} > {t("viewDiff")} { void openFilePreview(file.path) }} > {t("openFile")} ))}
) } export function FileWorkspacePanel() { const t = useTranslations("Folder.fileWorkspacePanel") const { activeFileTab, consumePendingFileReveal, pendingFileReveal, openBranchDiff, openCommitDiff, openFilePreview, openWorkingTreeDiff, previewFileTabIds, saveActiveFile, updateActiveFileContent, } = useWorkspaceContext() const { folder } = useFolderContext() const folderPath = folder?.path ?? null const activeScope = activeFileTab?.id ?? "__default__" const editorRef = useRef(null) 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>( {} ) const [collapsedHunks, setCollapsedHunks] = useState>( {} ) const renderedContent = activeFileTab?.content ?? "" const isFileTab = activeFileTab?.kind === "file" const fileReadonly = isFileTab ? Boolean(activeFileTab.readonly) : true const fileSaveState = isFileTab ? (activeFileTab.saveState ?? "idle") : "idle" const fileIsDirty = isFileTab ? Boolean(activeFileTab.isDirty) : false const canEdit = isFileTab && !fileReadonly const autoSaveTimerRef = useRef | null>(null) const autoSaveGuardRef = useRef({ canEdit: false, fileIsDirty: false, fileSaveState: "idle" as "idle" | "saving" | "error", }) const diffListContext = useMemo(() => { if (!activeFileTab) return null if (activeFileTab.kind !== "diff") return null const commitMatch = activeFileTab.id.match(/^diff:commit:([^:]+):all$/) if (commitMatch) { return { kind: "commit", commitHash: commitMatch[1], commitMessage: activeFileTab.description, } } const workingOverviewMatch = activeFileTab.id.match( /^diff:working-overview:(.+)$/ ) if (workingOverviewMatch) { return { kind: "working", path: decodeDiffTabToken(workingOverviewMatch[1]), } } const branchOverviewMatch = activeFileTab.id.match( /^diff:branch-overview:([^:]+):(.+)$/ ) if (branchOverviewMatch) { return { kind: "branch", branch: decodeDiffTabToken(branchOverviewMatch[1]), path: decodeDiffTabToken(branchOverviewMatch[2]), } } return null }, [activeFileTab]) const diffOutline = useMemo(() => { if (!activeFileTab || activeFileTab.kind !== "diff") return null return buildDiffOutline(activeFileTab.content) }, [activeFileTab]) const allHunks = useMemo( () => diffListContext ? [] : (diffOutline?.files.flatMap((file) => file.hunks) ?? []), [diffListContext, diffOutline] ) const activeHunkIndex = useMemo(() => { if (allHunks.length === 0) return -1 const containingIndex = allHunks.findIndex( (hunk) => cursorLine >= hunk.startLine && cursorLine <= hunk.endLine ) if (containingIndex >= 0) return containingIndex const firstAfterIndex = allHunks.findIndex( (hunk) => hunk.startLine > cursorLine ) if (firstAfterIndex < 0) return allHunks.length - 1 return firstAfterIndex - 1 }, [allHunks, cursorLine]) const lineNumbersMinChars = useMemo(() => { const lineCount = renderedContent.split("\n").length const digits = String(Math.max(1, lineCount)).length // Keep a one-character buffer so numbers don't visually hug the gutter edge. return Math.max(3, digits + 1) }, [renderedContent]) const hasGitBaseSnapshot = isFileTab && activeFileTab?.gitBaseContent !== undefined const gitBaseContent = hasGitBaseSnapshot ? (activeFileTab?.gitBaseContent ?? "") : "" const gitGutterLineMarkers = useMemo(() => { if (!hasGitBaseSnapshot) return EMPTY_GIT_GUTTER_LINE_MARKERS return computeGitGutterLineMarkers(gitBaseContent, renderedContent) }, [gitBaseContent, hasGitBaseSnapshot, renderedContent]) const applyGitChangeDecorations = useCallback(() => { const editorInstance = editorRef.current if (!editorInstance) return const { added, modified, deleted } = gitGutterLineMarkers if ( !isFileTab || (added.length === 0 && modified.length === 0 && deleted.length === 0) ) { gitChangeDecorationsRef.current = editorInstance.deltaDecorations( gitChangeDecorationsRef.current, [] ) return } const model = editorInstance.getModel() if (!model) return const maxLine = model.getLineCount() const toRange = (lineNumber: number) => ({ startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: 1, }) const addedDecorations = added .filter((lineNumber) => lineNumber >= 1 && lineNumber <= maxLine) .map((lineNumber) => ({ range: toRange(lineNumber), options: { isWholeLine: true, linesDecorationsClassName: "codeg-dirty-diff-glyph codeg-dirty-diff-added", }, })) const modifiedDecorations = modified .filter((lineNumber) => lineNumber >= 1 && lineNumber <= maxLine) .map((lineNumber) => ({ range: toRange(lineNumber), options: { isWholeLine: true, linesDecorationsClassName: "codeg-dirty-diff-glyph codeg-dirty-diff-modified", }, })) const deletedDecorations = deleted .filter((lineNumber) => lineNumber >= 1 && lineNumber <= maxLine) .map((lineNumber) => ({ range: { startLineNumber: lineNumber, startColumn: Number.MAX_VALUE, endLineNumber: lineNumber, endColumn: Number.MAX_VALUE, }, options: { isWholeLine: false, linesDecorationsClassName: "codeg-dirty-diff-glyph codeg-dirty-diff-deleted", }, })) const decorations = [ ...addedDecorations, ...modifiedDecorations, ...deletedDecorations, ] gitChangeDecorationsRef.current = editorInstance.deltaDecorations( gitChangeDecorationsRef.current, decorations ) }, [gitGutterLineMarkers, isFileTab]) const applyHiddenAreas = useCallback(() => { const editorInstance = editorRef.current if (!editorInstance) return if (!diffOutline || diffListContext) { setEditorHiddenAreas(editorInstance, []) return } const model = editorInstance.getModel() if (!model) return const maxLine = model.getLineCount() const ranges: { startLineNumber: number startColumn: number endLineNumber: number endColumn: number }[] = [] const addRange = (startLine: number, endLine: number) => { const safeStart = Math.max(1, startLine) const safeEnd = Math.min(maxLine, endLine) if (safeStart > safeEnd) return ranges.push({ startLineNumber: safeStart, startColumn: 1, endLineNumber: safeEnd, endColumn: 1, }) } for (const file of diffOutline.files) { const fileCollapsed = Boolean( collapsedFiles[`${activeScope}:${file.key}`] ) if (fileCollapsed) { addRange(file.startLine + 1, file.endLine) continue } for (const hunk of file.hunks) { if (!collapsedHunks[`${activeScope}:${hunk.key}`]) continue addRange(hunk.startLine + 1, hunk.endLine) } } setEditorHiddenAreas(editorInstance, ranges) }, [ activeScope, collapsedFiles, collapsedHunks, diffListContext, diffOutline, ]) const handleEditorMount = useCallback( (editorInstance: MonacoEditorNs.IStandaloneCodeEditor) => { editorRef.current = editorInstance cursorListenerRef.current?.dispose() cursorListenerRef.current = editorInstance.onDidChangeCursorPosition( (event) => { setCursorLine(event.position.lineNumber) } ) setEditorMountVersion((prev) => prev + 1) setCursorLine(editorInstance.getPosition()?.lineNumber ?? 1) applyHiddenAreas() applyGitChangeDecorations() }, [applyGitChangeDecorations, applyHiddenAreas] ) const jumpToLine = useCallback((lineNumber: number) => { const editorInstance = editorRef.current if (!editorInstance) return false 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( (hunkIndex: number) => { const hunk = allHunks[hunkIndex] if (!hunk) return jumpToLine(hunk.startLine) }, [allHunks, jumpToLine] ) const handlePrevHunk = useCallback(() => { if (allHunks.length === 0) return if (activeHunkIndex <= 0) return jumpToHunk(activeHunkIndex - 1) }, [activeHunkIndex, allHunks.length, jumpToHunk]) const handleNextHunk = useCallback(() => { if (allHunks.length === 0) return if (activeHunkIndex >= allHunks.length - 1) return jumpToHunk(activeHunkIndex + 1) }, [activeHunkIndex, allHunks.length, jumpToHunk]) const toggleFileCollapsed = useCallback( (fileKey: string) => { setCollapsedFiles((prev) => { const scopedKey = `${activeScope}:${fileKey}` return { ...prev, [scopedKey]: !prev[scopedKey], } }) }, [activeScope] ) const toggleHunkCollapsed = useCallback( (hunkKey: string) => { setCollapsedHunks((prev) => { const scopedKey = `${activeScope}:${hunkKey}` return { ...prev, [scopedKey]: !prev[scopedKey], } }) }, [activeScope] ) useEffect(() => { applyHiddenAreas() }, [applyHiddenAreas]) useEffect(() => { 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, fileIsDirty, fileSaveState, } }, [canEdit, fileIsDirty, fileSaveState]) useEffect(() => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = null } if (!canEdit || !fileIsDirty || fileSaveState !== "idle") return autoSaveTimerRef.current = setTimeout(() => { const guard = autoSaveGuardRef.current if ( !guard.canEdit || !guard.fileIsDirty || guard.fileSaveState !== "idle" ) { return } void saveActiveFile() }, AUTO_SAVE_DELAY_MS) return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = null } } }, [canEdit, fileIsDirty, fileSaveState, saveActiveFile, renderedContent]) useEffect(() => { if (!isFileTab) return const saveOnDeactivation = () => { const guard = autoSaveGuardRef.current if ( !guard.canEdit || !guard.fileIsDirty || guard.fileSaveState === "saving" ) { return } void saveActiveFile() } const onWindowBlur = () => { saveOnDeactivation() } const onVisibilityChange = () => { if (document.visibilityState !== "hidden") return saveOnDeactivation() } window.addEventListener("blur", onWindowBlur) document.addEventListener("visibilitychange", onVisibilityChange) return () => { window.removeEventListener("blur", onWindowBlur) document.removeEventListener("visibilitychange", onVisibilityChange) } }, [isFileTab, saveActiveFile]) useEffect(() => { if (!isFileTab) return const onKeyDown = (event: KeyboardEvent) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key === "s" if (!isSaveShortcut) return event.preventDefault() if (!canEdit) return void saveActiveFile() } window.addEventListener("keydown", onKeyDown) return () => { window.removeEventListener("keydown", onKeyDown) } }, [canEdit, isFileTab, saveActiveFile]) useEffect( () => () => { if (editorRef.current) { editorRef.current.deltaDecorations(gitChangeDecorationsRef.current, []) } gitChangeDecorationsRef.current = [] cursorListenerRef.current?.dispose() cursorListenerRef.current = null }, [] ) if (!activeFileTab) { return (

{t("openFileOrDiff")}

) } if (activeFileTab.kind === "rich-diff") { const isCommitDiff = activeFileTab.id.startsWith("diff:commit:") const isExternalConflictDiff = activeFileTab.id.startsWith( "diff:external-conflict:" ) const commitHash = isCommitDiff ? (activeFileTab.id.split(":")[2]?.slice(0, 7) ?? "") : "" const origLabel = isCommitDiff ? `${commitHash}~1` : isExternalConflictDiff ? t("disk") : t("head") const modLabel = isCommitDiff ? commitHash : isExternalConflictDiff ? t("unsaved") : t("workingTree") return (
{activeFileTab.loading && (
{t("loading")}
)} {!activeFileTab.loading && ( )}
) } if ( activeFileTab.kind === "diff" && activeFileTab.id.startsWith("diff:session:") ) { return (
{activeFileTab.loading && (
{t("loading")}
)}
) } // Preview mode for markdown files const isPreviewMode = isFileTab && activeFileTab && previewFileTabIds.has(activeFileTab.id) && activeFileTab.language === "markdown" // Diff overview list view (commit / directory) if (diffListContext && diffOutline) { const badge = diffListContext.kind === "commit" ? diffListContext.commitHash.slice(0, 7) : diffListContext.kind === "branch" ? diffListContext.branch : t("workingTree") const description = diffListContext.kind === "commit" ? diffListContext.commitMessage : diffListContext.kind === "branch" ? t("compareWithBranch", { path: diffListContext.path, branch: diffListContext.branch, }) : (activeFileTab.description ?? diffListContext.path) const handleOpenDiff = async (path: string) => { if (diffListContext.kind === "commit") { await openCommitDiff(diffListContext.commitHash, path) return } if (diffListContext.kind === "branch") { await openBranchDiff(diffListContext.branch, path) return } await openWorkingTreeDiff(path) } return (
{activeFileTab.loading && (
{t("loading")}
)} {!activeFileTab.loading && ( )}
) } // Image preview if (isFileTab && activeFileTab && activeFileTab.language === "image") { return } if (isPreviewMode && activeFileTab) { const absFilePath = activeFileTab.path && folderPath ? `${folderPath}/${activeFileTab.path}` : null const fileDir = absFilePath ? absFilePath.replace(/\/[^/]*$/, "") : folderPath // Pre-resolve relative paths before Streamdown/rehype-harden mangles them const relativeFileDir = activeFileTab.path?.includes("/") ? activeFileTab.path.replace(/\/[^/]*$/, "") : "" const preprocessedContent = preprocessMarkdownPaths( renderedContent, relativeFileDir ) return (
{activeFileTab.loading && (
{t("loading")}
)}
( ), // eslint-disable-next-line @typescript-eslint/no-unused-vars a: ({ node, href, children, ...aProps }) => { const isRelative = href && !/^[a-z][a-z0-9+.-]*:|^#/i.test(href) if (isRelative && href) { return ( { e.preventDefault() // After preprocessing + rehype-harden, paths are // root-relative like "/docs/images/foo.png" const clean = href.replace(/[#?].*$/, "") const target = clean .replace(/^\/+/, "") .replace(/\/\/+/g, "/") openFilePreview(target) }} > {children} ) } return ( {children} ) }, }} > {preprocessedContent}
) } return (
{activeFileTab.loading && (
{t("loading")}
)}
{diffOutline && (
{t("fileCount", { count: diffOutline.files.length })} +{diffOutline.totalAdditions} -{diffOutline.totalDeletions} {diffOutline.totalHunks > 0 && ( {t("hunkCount", { count: diffOutline.totalHunks })} )} {allHunks.length > 0 && (
)}
{diffOutline.files.map((file) => { const fileCollapsed = Boolean( collapsedFiles[`${activeScope}:${file.key}`] ) return (
{!fileCollapsed && file.hunks.length > 0 && (
{file.hunks.map((hunk) => { const hunkCollapsed = Boolean( collapsedHunks[`${activeScope}:${hunk.key}`] ) const isActive = activeHunkIndex >= 0 && allHunks[activeHunkIndex]?.key === hunk.key return (
) })}
)}
) })} {diffOutline.files.length === 0 && (
{t("noParsedDiffSections")}
)}
)}
{activeFileTab.loading ? (
{t("loadingEditor")}
) : ( { if (!isFileTab) return updateActiveFileContent(value ?? "") }} language={activeFileTab.language} theme={editorTheme} loading={
{t("loadingEditor")}
} options={{ readOnly: !canEdit, minimap: { enabled: false }, automaticLayout: true, fontSize: 13, lineNumbersMinChars, lineDecorationsWidth: 10, wordWrap: "off", scrollBeyondLastLine: false, scrollBeyondLastColumn: 8, renderLineHighlight: "line", scrollbar: { horizontal: "auto", }, }} /> )}
) }