Refactor editor panel into focused modules (#1924)
* Squashed commits - WIP: uncommitted changes before rebase * fix: address review findings
This commit is contained in:
parent
25102cb6d9
commit
8cca091986
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue