Refactor editor panel into focused modules (#1924)

* Squashed commits

- WIP: uncommitted changes before rebase

* fix: address review findings
This commit is contained in:
Jinjing 2026-05-15 09:34:52 -07:00 committed by GitHub
parent 25102cb6d9
commit 8cca091986
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1608 additions and 1339 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,312 @@
import { useEffect, useState } from 'react'
import {
Columns2,
Copy,
Eye,
ExternalLink,
FileText,
ListTree,
MoreHorizontal,
Rows2
} from 'lucide-react'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import EditorViewToggle, {
CSV_VIEW_MODE_METADATA,
NOTEBOOK_VIEW_MODE_METADATA
} from './EditorViewToggle'
import type { EditorToggleValue } from './EditorViewToggle'
import type { EditorHeaderOpenFileState } from './editor-header'
import { getEditorHeaderCopyState } from './editor-header'
import { getMarkdownPreviewShortcutLabel } from './markdown-preview-controls'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
const revealLabel = isMac
? 'Reveal in Finder'
: isLinux
? 'Open Containing Folder'
: 'Reveal in File Explorer'
const markdownPreviewShortcutLabel = getMarkdownPreviewShortcutLabel(isMac)
type EditorPanelHeaderProps = {
activeFile: OpenFile
copiedPathVisible: boolean
isSingleDiff: boolean
isDiffSurface: boolean
isMarkdown: boolean
isCsv: boolean
isNotebook: boolean
hasEditorToggle: boolean
availableEditorToggleModes: readonly EditorToggleValue[]
effectiveToggleValue: EditorToggleValue
mdViewMode: MarkdownViewMode
hasViewModeToggle: boolean
canOpenPreviewToSide: boolean
canShowMarkdownPreview: boolean
canShowMarkdownTableOfContents: boolean
isMarkdownTableOfContentsDisabled: boolean
showMarkdownTableOfContents: boolean
sideBySide: boolean
openFileState: EditorHeaderOpenFileState
onCopyPath: () => void
onOpenDiffTargetFile: () => void
onOpenPreviewToSide: () => void
onOpenMarkdownPreview: () => void
onOpenContainingFolder: () => void
onToggleSideBySide: () => void
onEditorToggleChange: (next: EditorToggleValue) => void
onToggleMarkdownTableOfContents: () => void
onExportMarkdownToPdf: () => void
}
export function EditorPanelHeader({
activeFile,
copiedPathVisible,
isSingleDiff,
isDiffSurface,
isMarkdown,
isCsv,
isNotebook,
hasEditorToggle,
availableEditorToggleModes,
effectiveToggleValue,
mdViewMode,
hasViewModeToggle,
canOpenPreviewToSide,
canShowMarkdownPreview,
canShowMarkdownTableOfContents,
isMarkdownTableOfContentsDisabled,
showMarkdownTableOfContents,
sideBySide,
openFileState,
onCopyPath,
onOpenDiffTargetFile,
onOpenPreviewToSide,
onOpenMarkdownPreview,
onOpenContainingFolder,
onToggleSideBySide,
onEditorToggleChange,
onToggleMarkdownTableOfContents,
onExportMarkdownToPdf
}: EditorPanelHeaderProps): React.JSX.Element {
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
const headerCopyState = getEditorHeaderCopyState(activeFile)
useEffect(() => {
const closeMenu = (): void => setPathMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<div className="editor-header">
<div className="editor-header-text">
<div
className="editor-header-path-row"
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setPathMenuPoint({ x: event.clientX, y: event.clientY })
setPathMenuOpen(true)
}}
>
<button
type="button"
className="editor-header-path"
onClick={onCopyPath}
title={headerCopyState.pathTitle}
>
{headerCopyState.pathLabel}
</button>
<span
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
aria-live="polite"
>
{headerCopyState.copyToastLabel}
</span>
</div>
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: pathMenuPoint.x, top: pathMenuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" sideOffset={0} align="start">
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(activeFile.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Relative Path
</DropdownMenuItem>
<DropdownMenuSeparator />
{canShowMarkdownPreview && (
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
<Eye className="w-3.5 h-3.5 mr-1.5" />
Open Markdown Preview
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{canShowMarkdownPreview && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onOpenContainingFolder}>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
{revealLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isSingleDiff && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
onClick={onOpenDiffTargetFile}
aria-label="Open file"
disabled={!openFileState.canOpen}
>
<FileText size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{openFileState.canOpen
? isMarkdown
? 'Open file tab to use rich markdown editing'
: 'Open file tab'
: 'This diff has no modified-side file to open'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canOpenPreviewToSide && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={onOpenPreviewToSide}
aria-label="Open Preview to the Side"
>
<Eye size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
Open Preview to the Side
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isDiffSurface && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={onToggleSideBySide}
>
{sideBySide ? <Rows2 size={14} /> : <Columns2 size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{sideBySide ? 'Switch to inline diff' : 'Switch to side-by-side diff'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasEditorToggle && (
<EditorViewToggle
value={effectiveToggleValue}
modes={availableEditorToggleModes}
onChange={onEditorToggleChange}
metadataOverride={
isCsv ? CSV_VIEW_MODE_METADATA : isNotebook ? NOTEBOOK_VIEW_MODE_METADATA : undefined
}
/>
)}
{canShowMarkdownTableOfContents && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`p-1 rounded hover:bg-accent hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground ${
showMarkdownTableOfContents && !isMarkdownTableOfContentsDisabled
? 'bg-accent text-foreground'
: 'text-muted-foreground'
}`}
onClick={onToggleMarkdownTableOfContents}
disabled={isMarkdownTableOfContentsDisabled}
aria-label="Table of Contents"
aria-pressed={showMarkdownTableOfContents}
>
<ListTree size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{isMarkdownTableOfContentsDisabled
? 'Table of Contents is available in rich or preview mode'
: 'Table of Contents'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasViewModeToggle && isMarkdown && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label="More actions"
title="More actions"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
<DropdownMenuItem
// Why: the item is disabled (not hidden) only in source/Monaco
// mode, which has no document DOM to export. We intentionally
// don't poll the DOM (canExportActiveMarkdown) at render time:
// the Radix content renders in a Portal and the lookup can
// race with the active surface's paint, producing a stuck
// disabled state. exportActiveMarkdownToPdf is a safe no-op
// when no subtree is found.
disabled={mdViewMode === 'source'}
onSelect={onExportMarkdownToPdf}
>
Export as PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
}

View File

@ -0,0 +1,162 @@
import { Suspense, type JSX, type RefObject } from 'react'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
import { EditorContent } from './EditorContent'
import { EditorPanelHeader } from './EditorPanelHeader'
import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
import type { getEditorPanelRenderModel } from './editor-panel-render-model'
import type { DiffContent, FileContent } from './editor-panel-content-types'
import type { EditorToggleValue } from './EditorViewToggle'
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
type EditorPanelShellProps = {
panelRef: RefObject<HTMLDivElement | null>
activeFile: OpenFile
activeViewStateId: string | null | undefined
model: EditorPanelRenderModel
copiedPathVisible: boolean
showMarkdownTableOfContents: boolean
sideBySide: boolean
fileContents: Record<string, FileContent>
diffContents: Record<string, DiffContent>
editorDrafts: Record<string, string>
pendingEditorReveal: ReturnType<typeof useAppStore.getState>['pendingEditorReveal']
renameDialogFile: OpenFile | null
renameError: string | null
disableRenameBrowse: boolean
onCopyPath: () => void
onOpenDiffTargetFile: () => void
onOpenPreviewToSide: () => void
onOpenMarkdownPreview: () => void
onOpenContainingFolder: () => void
onToggleSideBySide: () => void
onEditorToggleChange: (next: EditorToggleValue) => void
onToggleMarkdownTableOfContents: () => void
onExportMarkdownToPdf: () => void
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onSave: (content: string) => Promise<void>
onReloadFileContent: (file: OpenFile) => void
onCloseMarkdownTableOfContents: () => void
onCloseRenameDialog: () => void
onRenameConfirm: (newRelPath: string) => Promise<void>
}
export function EditorPanelShell({
panelRef,
activeFile,
activeViewStateId,
model,
copiedPathVisible,
showMarkdownTableOfContents,
sideBySide,
fileContents,
diffContents,
editorDrafts,
pendingEditorReveal,
renameDialogFile,
renameError,
disableRenameBrowse,
onCopyPath,
onOpenDiffTargetFile,
onOpenPreviewToSide,
onOpenMarkdownPreview,
onOpenContainingFolder,
onToggleSideBySide,
onEditorToggleChange,
onToggleMarkdownTableOfContents,
onExportMarkdownToPdf,
onContentChange,
onDirtyStateHint,
onSave,
onReloadFileContent,
onCloseMarkdownTableOfContents,
onCloseRenameDialog,
onRenameConfirm
}: EditorPanelShellProps): JSX.Element {
return (
<div ref={panelRef} className="flex flex-col flex-1 min-w-0 min-h-0">
{!model.isCombinedDiff && (
<EditorPanelHeader
activeFile={activeFile}
copiedPathVisible={copiedPathVisible}
isSingleDiff={model.isSingleDiff}
isDiffSurface={model.isDiffSurface}
isMarkdown={model.isMarkdown}
isCsv={model.isCsv}
isNotebook={model.isNotebook}
hasEditorToggle={model.hasEditorToggle}
availableEditorToggleModes={model.availableEditorToggleModes}
effectiveToggleValue={model.effectiveToggleValue}
mdViewMode={model.mdViewMode}
hasViewModeToggle={model.hasViewModeToggle}
canOpenPreviewToSide={model.canOpenPreviewToSide}
canShowMarkdownPreview={model.canShowMarkdownPreview}
canShowMarkdownTableOfContents={model.canShowMarkdownTableOfContents}
isMarkdownTableOfContentsDisabled={model.isMarkdownTableOfContentsDisabled}
showMarkdownTableOfContents={showMarkdownTableOfContents}
sideBySide={sideBySide}
openFileState={model.openFileState}
onCopyPath={onCopyPath}
onOpenDiffTargetFile={onOpenDiffTargetFile}
onOpenPreviewToSide={onOpenPreviewToSide}
onOpenMarkdownPreview={onOpenMarkdownPreview}
onOpenContainingFolder={onOpenContainingFolder}
onToggleSideBySide={onToggleSideBySide}
onEditorToggleChange={onEditorToggleChange}
onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents}
onExportMarkdownToPdf={onExportMarkdownToPdf}
/>
)}
<Suspense fallback={<EditorLoadingFallback />}>
<EditorContent
activeFile={activeFile}
viewStateScopeId={activeViewStateId ?? activeFile.id}
fileContents={fileContents}
diffContents={diffContents}
editBuffers={editorDrafts}
worktreeEntries={model.worktreeEntries}
resolvedLanguage={model.resolvedLanguage}
isMarkdown={model.isMarkdown}
isMermaid={model.isMermaid}
isCsv={model.isCsv}
isNotebook={model.isNotebook}
mdViewMode={model.mdViewMode}
isChangesMode={model.isDiffSurface && !model.isSingleDiff}
sideBySide={sideBySide}
pendingEditorReveal={pendingEditorReveal}
handleContentChange={onContentChange}
handleDirtyStateHint={onDirtyStateHint}
handleSave={onSave}
reloadFileContent={onReloadFileContent}
showMarkdownTableOfContents={showMarkdownTableOfContents}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
/>
</Suspense>
<UntitledFileRenameDialog
open={renameDialogFile !== null}
currentName={renameDialogFile?.relativePath ?? ''}
worktreePath={
renameDialogFile
? (findWorktreeById(useAppStore.getState().worktreesByRepo, renameDialogFile.worktreeId)
?.path ?? '')
: ''
}
disableBrowse={disableRenameBrowse}
externalError={renameError}
onClose={onCloseRenameDialog}
onConfirm={onRenameConfirm}
/>
</div>
)
}
function EditorLoadingFallback(): JSX.Element {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading editor...
</div>
)
}

View File

@ -0,0 +1,11 @@
import type { GitDiffResult } from '../../../../shared/types'
export type FileContent = {
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
loadError?: string
}
export type DiffContent = GitDiffResult

View File

@ -0,0 +1,23 @@
import { exportActiveMarkdownToPdf } from './export-active-markdown'
// Why: the "File -> Export as PDF..." menu IPC fans out to every EditorPanel
// instance, and split-pane layouts mount N panels concurrently. This ref-counted
// singleton keeps exactly one renderer subscription alive while any panel exists.
let exportPdfListenerOwners = 0
let exportPdfListenerUnsubscribe: (() => void) | null = null
export function acquireExportPdfListener(): () => void {
exportPdfListenerOwners += 1
if (exportPdfListenerOwners === 1) {
exportPdfListenerUnsubscribe = window.api.ui.onExportPdfRequested(() => {
void exportActiveMarkdownToPdf()
})
}
return () => {
exportPdfListenerOwners -= 1
if (exportPdfListenerOwners === 0 && exportPdfListenerUnsubscribe) {
exportPdfListenerUnsubscribe()
exportPdfListenerUnsubscribe = null
}
}
}

View File

@ -0,0 +1,14 @@
import type { OpenFile } from '@/store/slices/editor'
export function isAbsolutePathLike(value: string): boolean {
return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value)
}
export function canUseChangesModeForFile(file: OpenFile): boolean {
return (
file.mode === 'edit' &&
!file.isUntitled &&
file.relativePath !== file.filePath &&
!isAbsolutePathLike(file.relativePath)
)
}

View File

@ -0,0 +1,129 @@
import { detectLanguage } from '@/lib/language-detect'
import { canPreviewLanguage } from '@/lib/file-preview'
import type { useAppStore } from '@/store'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import {
canOpenMarkdownPreview,
getDefaultMarkdownViewMode,
getEditorToggleModes,
getMarkdownViewModes
} from './markdown-preview-controls'
import { getEditorHeaderOpenFileState } from './editor-header'
import type { EditorToggleValue } from './EditorViewToggle'
import type { FileContent } from './editor-panel-content-types'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
type StoreState = ReturnType<typeof useAppStore.getState>
type EditorPanelRenderModelParams = {
activeFile: OpenFile
fileContents: Record<string, FileContent>
gitStatusByWorktree: StoreState['gitStatusByWorktree']
gitBranchChangesByWorktree: StoreState['gitBranchChangesByWorktree']
markdownViewMode: StoreState['markdownViewMode']
isChangesMode: boolean
}
export function getEditorPanelRenderModel({
activeFile,
fileContents,
gitStatusByWorktree,
gitBranchChangesByWorktree,
markdownViewMode,
isChangesMode
}: EditorPanelRenderModelParams) {
const isSingleDiff =
activeFile.mode === 'diff' &&
activeFile.diffSource !== undefined &&
activeFile.diffSource !== 'combined-uncommitted' &&
activeFile.diffSource !== 'combined-branch'
const isCombinedDiff =
activeFile.mode === 'diff' &&
(activeFile.diffSource === 'combined-uncommitted' ||
activeFile.diffSource === 'combined-branch')
const resolvedLanguage =
activeFile.mode === 'diff'
? detectLanguage(activeFile.relativePath)
: detectLanguage(activeFile.filePath)
const worktreeEntries = gitStatusByWorktree[activeFile.worktreeId] ?? []
const branchEntries = gitBranchChangesByWorktree[activeFile.worktreeId] ?? []
const matchingWorktreeEntry =
activeFile.mode === 'diff' && activeFile.diffSource !== 'branch'
? (worktreeEntries.find(
(entry) =>
entry.path === activeFile.relativePath &&
(activeFile.diffSource === 'staged'
? entry.area === 'staged'
: entry.area === 'unstaged')
) ?? null)
: null
const matchingBranchEntry =
activeFile.mode === 'diff' && activeFile.diffSource === 'branch'
? (branchEntries.find((entry) => entry.path === activeFile.relativePath) ?? null)
: null
const markdownViewModes = getMarkdownViewModes({
language: resolvedLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
const hasViewModeToggle = markdownViewModes.length > 0
const defaultMarkdownViewMode = getDefaultMarkdownViewMode({
language: resolvedLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
const storedMarkdownViewMode = markdownViewMode[activeFile.id]
const mdViewMode: MarkdownViewMode =
hasViewModeToggle &&
storedMarkdownViewMode !== undefined &&
markdownViewModes.includes(storedMarkdownViewMode)
? storedMarkdownViewMode
: defaultMarkdownViewMode
const editorToggleModes = getEditorToggleModes({
language: resolvedLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
const isBinaryEditSurface =
activeFile.mode === 'edit' && fileContents[activeFile.id]?.isBinary === true
const availableEditorToggleModes =
isBinaryEditSurface || !canUseChangesModeForFile(activeFile)
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
const effectiveToggleValue: EditorToggleValue = isChangesMode
? 'changes'
: hasViewModeToggle
? mdViewMode
: 'edit'
return {
isSingleDiff,
isDiffSurface: isSingleDiff || isChangesMode,
isCombinedDiff,
worktreeEntries,
resolvedLanguage,
openFileState: getEditorHeaderOpenFileState(
activeFile,
matchingWorktreeEntry,
matchingBranchEntry
),
isMarkdown: resolvedLanguage === 'markdown',
isMermaid: resolvedLanguage === 'mermaid',
isCsv: resolvedLanguage === 'csv' || resolvedLanguage === 'tsv',
isNotebook: resolvedLanguage === 'notebook',
canOpenPreviewToSide: activeFile.mode === 'edit' && canPreviewLanguage(resolvedLanguage),
mdViewMode,
hasViewModeToggle,
availableEditorToggleModes,
hasEditorToggle: availableEditorToggleModes.length > 1,
effectiveToggleValue,
isMarkdownTableOfContentsDisabled: hasViewModeToggle && mdViewMode === 'source',
canShowMarkdownTableOfContents:
resolvedLanguage === 'markdown' &&
(hasViewModeToggle || activeFile.mode === 'markdown-preview'),
canShowMarkdownPreview: canOpenMarkdownPreview({
language: resolvedLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
}
}

View File

@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react'
import * as monaco from 'monaco-editor'
import type { OpenFile } from '@/store/slices/editor'
import { cursorPositionCache, diffViewStateCache, scrollTopCache } from '@/lib/scroll-cache'
function deleteCacheEntriesByPrefix<T>(cache: Map<string, T>, prefix: string): void {
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key)
}
}
}
export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void {
const prevOpenFilesRef = useRef<Map<string, OpenFile>>(new Map())
useEffect(() => {
const currentFilesById = new Map(openFiles.map((f) => [f.id, f]))
for (const [prevId, prevFile] of prevOpenFilesRef.current) {
if (!currentFilesById.has(prevId)) {
disposeClosedEditorTab(prevId, prevFile)
}
}
prevOpenFilesRef.current = currentFilesById
}, [openFiles])
}
function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void {
switch (prevFile.mode) {
case 'edit':
// Why: the edit model URI is constructed via monaco.Uri.parse(filePath)
// to match @monaco-editor/react's `path` prop convention.
monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose()
scrollTopCache.delete(prevFile.filePath)
deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`)
// Why: markdown and mermaid surfaces keep mode-scoped scroll positions.
scrollTopCache.delete(`${prevFile.filePath}:rich`)
scrollTopCache.delete(`${prevFile.filePath}:preview`)
scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`)
cursorPositionCache.delete(prevFile.filePath)
deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`)
break
case 'markdown-preview':
// Why: preview tabs own pane-scoped preview scroll cache entries even
// though they do not retain Monaco models.
scrollTopCache.delete(`${prevFile.id}:preview`)
deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`)
break
case 'diff':
// Why: kept diff models are keyed by tab id because one file can appear
// in multiple diff tabs with different contents.
monaco.editor.getModel(monaco.Uri.parse(`diff:original:${prevId}`))?.dispose()
monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose()
diffViewStateCache.delete(prevId)
deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`)
scrollTopCache.delete(`${prevId}:preview`)
deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`)
break
case 'conflict-review':
break
}
}

View File

@ -0,0 +1,50 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor-autosave'
import type { FileContent } from './editor-panel-content-types'
type UseEditorCmdSaveRequestParams = {
activeFile: OpenFile | null
openFiles: OpenFile[]
fileContents: Record<string, FileContent>
handleSave: (content: string) => Promise<void>
}
export function useEditorCmdSaveRequest({
activeFile,
openFiles,
fileContents,
handleSave
}: UseEditorCmdSaveRequestParams): void {
useEffect(() => {
const handler = (): void => {
if (!activeFile) {
return
}
const saveTargetFile =
activeFile.mode === 'markdown-preview'
? (openFiles.find(
(openFile) =>
openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit'
) ?? null)
: activeFile
if (!saveTargetFile) {
return
}
// Why: a markdown preview tab is read-only but fronts the same document,
// so Cmd/Ctrl+S should save the source editor's current draft.
const state = useAppStore.getState()
const draft = state.editorDrafts[saveTargetFile.id]
if (!draft && !saveTargetFile.isUntitled && !saveTargetFile.isDirty) {
return
}
const fallbackContent =
draft ??
(activeFile.mode === 'markdown-preview' ? fileContents[activeFile.id]?.content : '')
void handleSave(fallbackContent ?? '')
}
window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
return () => window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
}, [activeFile, fileContents, handleSave, openFiles])
}

View File

@ -0,0 +1,289 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
getRuntimeGitBranchDiff,
getRuntimeGitDiff,
getRuntimeGitScope
} from '@/runtime/runtime-git-client'
import type { DiffContent, FileContent } from './editor-panel-content-types'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import {
useEditorPanelExternalContentEvents,
usePruneClosedEditorContent
} from './useEditorPanelExternalContentEvents'
import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry'
const inFlightFileReads = new Map<string, Promise<FileContent>>()
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
type GitStatusByWorktree = ReturnType<typeof useAppStore.getState>['gitStatusByWorktree']
type EditorViewModeByFile = ReturnType<typeof useAppStore.getState>['editorViewMode']
type UseEditorPanelContentStateParams = {
activeFile: OpenFile | null
isChangesMode: boolean
openFiles: OpenFile[]
gitStatusByWorktree: GitStatusByWorktree
editorViewMode: EditorViewModeByFile
}
type UseEditorPanelContentStateResult = {
fileContents: Record<string, FileContent>
diffContents: Record<string, DiffContent>
reloadFileContent: (file: OpenFile) => void
}
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
return `${connectionId ?? ''}::${filePath}`
}
function inFlightDiffKey(
file: OpenFile,
connectionId: string | undefined,
compareAgainstHead = false
): string {
const branch =
file.diffSource === 'branch' && file.branchCompare
? `${file.branchCompare.baseOid ?? ''}..${file.branchCompare.headOid ?? ''}::${file.branchOldPath ?? ''}`
: ''
return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}`
}
export function useEditorPanelContentState({
activeFile,
isChangesMode,
openFiles,
gitStatusByWorktree,
editorViewMode
}: UseEditorPanelContentStateParams): UseEditorPanelContentStateResult {
const [fileContents, setFileContents] = useState<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
const fileLoadRetryAttemptsRef = useRef<Record<string, number>>({})
const openFilesRef = useRef(openFiles)
openFilesRef.current = openFiles
const editorViewModeRef = useRef(editorViewMode)
editorViewModeRef.current = editorViewMode
const loadFileContent = useCallback(
async (filePath: string, id: string, worktreeId?: string): Promise<void> => {
try {
const connectionId = getConnectionId(worktreeId ?? null) ?? undefined
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
const activeSettings = useAppStore.getState().settings
const readSettings = settingsForRuntimeOwner(
activeSettings,
restoredOpenFile?.runtimeEnvironmentId
)
if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) {
if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) {
// Why: restored external-file tabs contain client-local absolute
// paths. Remote runtime and SSH workspaces cannot read those paths
// without an explicit upload/import flow.
throw new Error('External local files are not available for remote workspaces.')
}
// Why: restored external-file tabs need their main-process path grant
// refreshed because that authorization is only held in memory.
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
const readScope = getRuntimeFileReadScope(readSettings, connectionId)
const key = inFlightReadKey(readScope, filePath)
let pending = inFlightFileReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
settings: readSettings,
filePath,
relativePath: restoredOpenFile?.relativePath,
worktreeId,
connectionId
}) as Promise<FileContent>
inFlightFileReads.set(key, pending)
queueMicrotask(() => {
if (inFlightFileReads.get(key) === pending) {
inFlightFileReads.delete(key)
}
})
}
const result = await pending
delete fileLoadRetryAttemptsRef.current[id]
setFileContents((prev) => ({ ...prev, [id]: result }))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFileContents((prev) => ({
...prev,
[id]: { content: '', isBinary: false, loadError: message }
}))
}
},
[]
)
const loadDiffContent = useCallback(async (file: OpenFile | null): Promise<void> => {
if (!file || (file.mode === 'edit' && !canUseChangesModeForFile(file))) {
return
}
try {
const worktreePath = file.filePath.slice(
0,
file.filePath.length - file.relativePath.length - 1
)
const branchCompare =
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
? file.branchCompare
: null
const connectionId = getConnectionId(file.worktreeId) ?? undefined
const activeSettings = useAppStore.getState().settings
const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId)
const gitScope = getRuntimeGitScope(fileSettings, connectionId)
const effectiveDiffSource: typeof file.diffSource =
file.mode === 'edit' ? 'unstaged' : file.diffSource
const compareAgainstHead = file.mode === 'edit'
const key = inFlightDiffKey(
{ ...file, diffSource: effectiveDiffSource },
gitScope,
compareAgainstHead
)
let pending = inFlightDiffReads.get(key)
if (!pending) {
pending = (
effectiveDiffSource === 'branch' && branchCompare
? getRuntimeGitBranchDiff(
{
settings: fileSettings,
worktreeId: file.worktreeId,
worktreePath,
connectionId
},
{
compare: {
baseRef: branchCompare.baseRef,
baseOid: branchCompare.baseOid!,
headOid: branchCompare.headOid!,
mergeBase: branchCompare.mergeBase!
},
filePath: file.relativePath,
oldPath: file.branchOldPath
}
)
: getRuntimeGitDiff(
{
settings: fileSettings,
worktreeId: file.worktreeId,
worktreePath,
connectionId
},
{
filePath: file.relativePath,
staged: effectiveDiffSource === 'staged',
compareAgainstHead
}
)
) as Promise<DiffContent>
inFlightDiffReads.set(key, pending)
queueMicrotask(() => {
if (inFlightDiffReads.get(key) === pending) {
inFlightDiffReads.delete(key)
}
})
}
const result = await pending
setDiffContents((prev) => ({ ...prev, [file.id]: result }))
} catch (err) {
setDiffContents((prev) => ({
...prev,
[file.id]: {
kind: 'text',
originalContent: '',
modifiedContent: `Error loading diff: ${err}`,
originalIsBinary: false,
modifiedIsBinary: false
}
}))
}
}, [])
const reloadFileContent = useCallback(
(file: OpenFile): void => {
delete fileLoadRetryAttemptsRef.current[file.id]
setFileContents((prev) => {
if (!prev[file.id]) {
return prev
}
const next = { ...prev }
delete next[file.id]
return next
})
void loadFileContent(file.filePath, file.id, file.worktreeId)
},
[loadFileContent]
)
useEffect(() => {
if (!activeFile || activeFile.mode === 'conflict-review') {
return
}
if (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') {
if (activeFile.conflict?.kind === 'conflict-placeholder') {
return
}
if (!fileContents[activeFile.id]) {
void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId)
}
if (isChangesMode && !diffContents[activeFile.id]) {
void loadDiffContent(activeFile)
}
} else if (
activeFile.mode === 'diff' &&
activeFile.diffSource !== undefined &&
activeFile.diffSource !== 'combined-uncommitted' &&
activeFile.diffSource !== 'combined-branch' &&
!diffContents[activeFile.id]
) {
void loadDiffContent(activeFile)
}
}, [activeFile?.id, isChangesMode]) // eslint-disable-line react-hooks/exhaustive-deps
useEditorPanelFileLoadRetry({
activeFile,
fileContents,
fileLoadRetryAttemptsRef,
loadFileContent,
openFilesRef,
setFileContents
})
const changesStatusEntries = activeFile?.worktreeId
? gitStatusByWorktree[activeFile.worktreeId]
: undefined
useEffect(() => {
if (!isChangesMode || !activeFile?.id) {
return
}
const current = openFilesRef.current.find((f) => f.id === activeFile.id)
if (current) {
void loadDiffContent(current)
}
}, [
changesStatusEntries,
isChangesMode,
activeFile?.id,
activeFile?.worktreeId,
activeFile?.relativePath,
loadDiffContent
])
useEditorPanelExternalContentEvents({
loadDiffContent,
loadFileContent,
openFilesRef,
editorViewModeRef,
setFileContents,
setDiffContents
})
usePruneClosedEditorContent(openFiles, fileLoadRetryAttemptsRef, setFileContents, setDiffContents)
return { fileContents, diffContents, reloadFileContent }
}

View File

@ -0,0 +1,132 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import {
getOpenFilesForExternalFileChange,
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
ORCA_EDITOR_FILE_SAVED_EVENT,
type EditorFileSavedDetail,
type EditorPathMutationTarget
} from './editor-autosave'
import type { DiffContent, FileContent } from './editor-panel-content-types'
type EditorViewModeByFile = ReturnType<typeof useAppStore.getState>['editorViewMode']
type UseEditorPanelExternalContentEventsParams = {
loadDiffContent: (file: OpenFile | null) => Promise<void>
loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise<void>
openFilesRef: MutableRefObject<OpenFile[]>
editorViewModeRef: MutableRefObject<EditorViewModeByFile>
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
setDiffContents: Dispatch<SetStateAction<Record<string, DiffContent>>>
}
export function useEditorPanelExternalContentEvents({
loadDiffContent,
loadFileContent,
openFilesRef,
editorViewModeRef,
setFileContents,
setDiffContents
}: UseEditorPanelExternalContentEventsParams): void {
useEffect(() => {
const handler = (event: Event): void => {
const detail = (event as CustomEvent<EditorPathMutationTarget>).detail
if (!detail) {
return
}
for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) {
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
void loadFileContent(file.filePath, file.id, file.worktreeId)
if (editorViewModeRef.current[file.id] === 'changes') {
void loadDiffContent(file)
}
} else if (
file.mode === 'diff' &&
file.diffSource !== 'combined-uncommitted' &&
file.diffSource !== 'combined-branch'
) {
void loadDiffContent(file)
}
}
}
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
return () =>
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
}, [editorViewModeRef, loadDiffContent, loadFileContent, openFilesRef])
useEffect(() => {
const handler = (event: Event): void => {
const detail = (event as CustomEvent<EditorFileSavedDetail>).detail
if (!detail) {
return
}
const file = openFilesRef.current.find((openFile) => openFile.id === detail.fileId)
if (!file) {
return
}
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
setFileContents((prev) => ({
...prev,
[file.id]: { content: detail.content, isBinary: false }
}))
}
updateSavedPreviewTabs(openFilesRef.current, detail, setFileContents)
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
return
}
setDiffContents((prev) => {
const existing = prev[file.id]
if (!existing || existing.kind !== 'text') {
return prev
}
return { ...prev, [file.id]: { ...existing, modifiedContent: detail.content } }
})
}
window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener)
return () => window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener)
}, [openFilesRef, setDiffContents, setFileContents])
}
function updateSavedPreviewTabs(
openFiles: OpenFile[],
detail: EditorFileSavedDetail,
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
): void {
const previewTabs = openFiles.filter(
(openFile) =>
openFile.mode === 'markdown-preview' && openFile.markdownPreviewSourceFileId === detail.fileId
)
if (previewTabs.length === 0) {
return
}
setFileContents((prev) => {
const next = { ...prev }
for (const previewTab of previewTabs) {
next[previewTab.id] = { content: detail.content, isBinary: false }
}
return next
})
}
export function usePruneClosedEditorContent(
openFiles: OpenFile[],
fileLoadRetryAttemptsRef: MutableRefObject<Record<string, number>>,
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>,
setDiffContents: Dispatch<SetStateAction<Record<string, DiffContent>>>
): void {
useEffect(() => {
const openIds = new Set(openFiles.map((f) => f.id))
for (const fileId of Object.keys(fileLoadRetryAttemptsRef.current)) {
if (!openIds.has(fileId)) {
delete fileLoadRetryAttemptsRef.current[fileId]
}
}
setFileContents((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
)
setDiffContents((prev) =>
Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
)
}, [fileLoadRetryAttemptsRef, openFiles, setDiffContents, setFileContents])
}

View File

@ -0,0 +1,80 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { FileContent } from './editor-panel-content-types'
const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
type UseEditorPanelFileLoadRetryParams = {
activeFile: OpenFile | null
fileContents: Record<string, FileContent>
fileLoadRetryAttemptsRef: MutableRefObject<Record<string, number>>
loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise<void>
openFilesRef: MutableRefObject<OpenFile[]>
setFileContents: Dispatch<SetStateAction<Record<string, FileContent>>>
}
function shouldRetryFileLoadError(message: string): boolean {
const lower = message.toLowerCase()
return (
!lower.includes('access denied') &&
!lower.includes('enoent') &&
!lower.includes('no such file') &&
!lower.includes('file too large')
)
}
export function useEditorPanelFileLoadRetry({
activeFile,
fileContents,
fileLoadRetryAttemptsRef,
loadFileContent,
openFilesRef,
setFileContents
}: UseEditorPanelFileLoadRetryParams): void {
const activeFileLoadRetryId = activeFile?.id ?? null
const activeFileLoadError = activeFileLoadRetryId
? fileContents[activeFileLoadRetryId]?.loadError
: undefined
useEffect(() => {
if (
!activeFileLoadRetryId ||
!activeFileLoadError ||
!shouldRetryFileLoadError(activeFileLoadError)
) {
return
}
const retryCount = fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] ?? 0
if (retryCount >= FILE_LOAD_RETRY_DELAYS_MS.length) {
return
}
const delayMs = FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0]
fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
const timeoutId = window.setTimeout(() => {
const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId)
if (
!currentFile ||
(currentFile.mode !== 'edit' && currentFile.mode !== 'markdown-preview')
) {
return
}
setFileContents((prev) => {
if (prev[currentFile.id]?.loadError !== activeFileLoadError) {
return prev
}
const next = { ...prev }
delete next[currentFile.id]
return next
})
void loadFileContent(currentFile.filePath, currentFile.id, currentFile.worktreeId)
}, delayMs)
return () => window.clearTimeout(timeoutId)
}, [
activeFileLoadRetryId,
activeFileLoadError,
fileLoadRetryAttemptsRef,
loadFileContent,
openFilesRef,
setFileContents
])
}

View File

@ -0,0 +1,80 @@
import { useEffect, type RefObject } from 'react'
import { detectLanguage } from '@/lib/language-detect'
import type { OpenFile } from '@/store/slices/editor'
import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-preview-controls'
type UseMarkdownPreviewShortcutParams = {
activeFile: OpenFile | null
panelRef: RefObject<HTMLDivElement | null>
isMac: boolean
openMarkdownPreview: (file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string
language: string
}) => void
}
export function useMarkdownPreviewShortcut({
activeFile,
panelRef,
isMac,
openMarkdownPreview
}: UseMarkdownPreviewShortcutParams): void {
const activeFilePath = activeFile?.filePath ?? null
const activeFileRelativePath = activeFile?.relativePath ?? null
const activeFileWorktreeId = activeFile?.worktreeId ?? null
const activeFileMode = activeFile?.mode ?? null
const activeFileDiffSource = activeFile?.diffSource
const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId
useEffect(() => {
if (!activeFilePath || !activeFileRelativePath || !activeFileWorktreeId || !activeFileMode) {
return
}
const shortcutLanguage =
activeFileMode === 'diff'
? detectLanguage(activeFileRelativePath)
: detectLanguage(activeFilePath)
const canShowMarkdownPreview = canOpenMarkdownPreview({
language: shortcutLanguage,
mode: activeFileMode,
diffSource: activeFileDiffSource
})
if (!canShowMarkdownPreview) {
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || !isMarkdownPreviewShortcut(event, isMac)) {
return
}
const root = panelRef.current
const target = event.target
if (!root || !(target instanceof Node) || !root.contains(target)) {
return
}
event.preventDefault()
event.stopPropagation()
openMarkdownPreview({
filePath: activeFilePath,
relativePath: activeFileRelativePath,
worktreeId: activeFileWorktreeId,
runtimeEnvironmentId: activeFileRuntimeEnvironmentId,
language: shortcutLanguage
})
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [
activeFileDiffSource,
activeFileMode,
activeFilePath,
activeFileRelativePath,
activeFileRuntimeEnvironmentId,
activeFileWorktreeId,
isMac,
openMarkdownPreview,
panelRef
])
}

View File

@ -0,0 +1,135 @@
import { useCallback, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { dirname, joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import {
createRuntimePath,
renameRuntimePath,
runtimePathExists
} from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
type UseUntitledFileRenameParams = {
openFiles: OpenFile[]
closeFile: (filePath: string) => void
openFile: (file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string
language: string
mode: 'edit'
}) => void
clearUntitled: (fileId: string) => void
}
type UseUntitledFileRenameResult = {
renameDialogFileId: string | null
renameDialogFile: OpenFile | null
renameError: string | null
requestRenameForFile: (fileId: string) => void
closeRenameDialog: () => void
handleRenameConfirm: (newRelPath: string) => Promise<void>
}
export function useUntitledFileRename({
openFiles,
closeFile,
openFile,
clearUntitled
}: UseUntitledFileRenameParams): UseUntitledFileRenameResult {
const [renameDialogFileId, setRenameDialogFileId] = useState<string | null>(null)
const [renameError, setRenameError] = useState<string | null>(null)
const renameDialogFile = renameDialogFileId
? (openFiles.find((f) => f.id === renameDialogFileId) ?? null)
: null
const closeRenameDialog = useCallback((): void => {
setRenameDialogFileId(null)
setRenameError(null)
}, [])
const handleRenameConfirm = useCallback(
async (newRelPath: string) => {
if (!renameDialogFile) {
return
}
const oldPath = renameDialogFile.filePath
// Why: derive the worktree root from the old relative path so nested
// untitled saves resolve relative to the worktree, not the current folder.
const worktreeRoot = oldPath.slice(
0,
oldPath.length - renameDialogFile.relativePath.length - 1
)
const newPath = joinPath(worktreeRoot, newRelPath)
const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined
const fileContext = {
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
renameDialogFile.runtimeEnvironmentId
),
worktreeId: renameDialogFile.worktreeId,
worktreePath: worktreeRoot,
connectionId
}
if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) {
setRenameError('A file with that name already exists')
return
}
await requestEditorSaveQuiesce({ fileId: renameDialogFile.id })
const draft = useAppStore.getState().editorDrafts[renameDialogFile.id]
if (draft !== undefined) {
try {
await requestEditorFileSave({ fileId: renameDialogFile.id, fallbackContent: draft })
} catch {
setRenameError('Failed to save file')
return
}
}
if (newPath === oldPath) {
clearUntitled(renameDialogFile.id)
closeRenameDialog()
return
}
const newDir = dirname(newPath)
if (newDir !== worktreeRoot && !(await runtimePathExists(fileContext, newDir))) {
await createRuntimePath(fileContext, newDir, 'directory')
}
try {
await renameRuntimePath(fileContext, oldPath, newPath)
} catch (err) {
setRenameError(err instanceof Error ? err.message : 'Failed to rename file')
return
}
closeFile(oldPath)
openFile({
filePath: newPath,
relativePath: newRelPath,
worktreeId: renameDialogFile.worktreeId,
runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId,
language: detectLanguage(newRelPath),
mode: 'edit'
})
closeRenameDialog()
},
[clearUntitled, closeFile, closeRenameDialog, openFile, renameDialogFile]
)
return {
renameDialogFileId,
renameDialogFile,
renameError,
requestRenameForFile: setRenameDialogFileId,
closeRenameDialog,
handleRenameConfirm
}
}