refactor(editor): split notebook, editor, and external-watch surfaces (#16143)

* refactor(editor): split editor and watch surfaces

* fix(editor): revert behavior changes smuggled into the surface split

Restore merge-base React keys in IpynbCellOutputs: the content-identity keys
JSON.stringify'd every output value, including raw base64 image payloads, on
every keystroke.

Collapse the duplicated lazy() declarations into editor-lazy-views so each
viewer keeps a single React.lazy identity across the extracted surfaces.
This commit is contained in:
Neil
2026-08-25 00:30:47 -07:00
committed by GitHub
parent 6103b78e3c
commit e0bc4e1126
24 changed files with 3422 additions and 2513 deletions
-3
View File
@@ -72,8 +72,6 @@ inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
@@ -96,7 +94,6 @@ inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
@@ -0,0 +1,286 @@
import React from 'react'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import type { OpenFile, PendingEditorReveal } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
import { ImageViewer, MonacoEditor } from './editor-lazy-views'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import type { FileContent } from './editor-panel-content-types'
import { translate } from '@/i18n/i18n'
import type { EditorConflictNavigation } from './useEditorConflictNavigation'
export function EditorConflictReviewSurface({
activeFile,
viewStateScopeId,
fileContents,
editBuffers,
openFiles,
worktreeEntries,
pendingEditorReveal,
getConflictNavigation,
handleContentChangeForFile,
handleSaveForFile,
reloadContent
}: {
activeFile: OpenFile
viewStateScopeId: string
fileContents: Record<string, FileContent>
editBuffers: Record<string, string>
openFiles: OpenFile[]
worktreeEntries: GitStatusEntry[]
pendingEditorReveal: PendingEditorReveal | null
getConflictNavigation: (file: OpenFile, content: string) => EditorConflictNavigation | undefined
handleContentChangeForFile: (file: OpenFile, content: string) => void
handleSaveForFile: (file: OpenFile, content: string) => Promise<boolean>
reloadContent: (file: OpenFile) => void
}): React.JSX.Element {
const openConflictReviewFile = useAppStore((s) => s.openConflictReviewFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const selectedConflictReviewFile = activeFile.conflictReview?.selectedFileId
? (openFiles.find((file) => file.id === activeFile.conflictReview?.selectedFileId) ?? null)
: null
const openConflictEntry = React.useCallback(
(entry: GitStatusEntry) => {
openConflictReviewFile(
activeFile.id,
activeFile.worktreeId,
activeFile.filePath,
entry,
detectLanguage(entry.path)
)
},
[activeFile.filePath, activeFile.id, activeFile.worktreeId, openConflictReviewFile]
)
const createContentFile = (entry: GitStatusEntry): OpenFile => {
const absolutePath = joinPath(activeFile.filePath, entry.path)
const conflict =
entry.conflictKind && entry.conflictStatus && entry.conflictStatusSource
? entry.status === 'deleted'
? {
kind: 'conflict-placeholder' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource,
message: translate(
'auto.components.editor.EditorContent.8b1a605bae',
'This file is in a conflict state, but no working-tree file is available to edit.'
),
guidance: 'Resolve the conflict in Git or restore one side before reopening it.'
}
: {
kind: 'conflict-editable' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource
}
: undefined
return {
id: absolutePath,
filePath: absolutePath,
relativePath: entry.path,
worktreeId: activeFile.worktreeId,
language: detectLanguage(entry.path),
isDirty: false,
mode: 'edit',
conflict
}
}
const renderEditorContent = ({
contentFile,
entry,
className,
viewStateKeySuffix,
readOnly = false,
autoHeight = false
}: {
contentFile: OpenFile
entry: GitStatusEntry | null
className: string
viewStateKeySuffix: string
readOnly?: boolean
autoHeight?: boolean
}): React.JSX.Element => {
if (contentFile.conflict?.kind === 'conflict-placeholder') {
return (
<div className={className}>
<ConflictPlaceholderView file={contentFile} />
</div>
)
}
const fileContent = fileContents[contentFile.id]
if (!fileContent) {
return (
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')}
</div>
</div>
)
}
if (fileContent.loadError) {
return (
<div className={className}>
<EditorFileLoadErrorView
message={fileContent.loadError}
onRetry={() => reloadContent(contentFile)}
/>
</div>
)
}
if (fileContent.isBinary) {
if (fileContent.isImage) {
return (
<div className={className}>
<ImageViewer
content={fileContent.content}
filePath={contentFile.filePath}
mimeType={fileContent.mimeType}
/>
</div>
)
}
return (
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.editor.EditorContent.b9de81ba52',
'Binary file — cannot display'
)}
</div>
</div>
)
}
const selectedLanguage = detectLanguage(contentFile.relativePath)
const monacoLanguage = selectedLanguage === 'notebook' ? 'json' : selectedLanguage
const selectedViewStateKey = `${contentFile.filePath}::${viewStateScopeId}:${viewStateKeySuffix}`
const selectedContent = editBuffers[contentFile.id] ?? fileContent.content
return (
<div className={className}>
{contentFile.conflict && (
<ConflictBanner
file={contentFile}
entry={entry}
conflictNavigation={getConflictNavigation(contentFile, selectedContent)}
/>
)}
<div className={autoHeight ? 'shrink-0' : 'min-h-0 flex-1'}>
<MonacoEditor
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
fileId={contentFile.id}
filePath={contentFile.filePath}
viewStateKey={selectedViewStateKey}
relativePath={contentFile.relativePath}
content={selectedContent}
language={monacoLanguage}
onContentChange={
readOnly ? () => {} : (content) => handleContentChangeForFile(contentFile, content)
}
onSave={readOnly ? () => {} : (content) => handleSaveForFile(contentFile, content)}
worktreeId={contentFile.worktreeId}
markdownAnnotationsEnabled={false}
conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'}
readOnly={readOnly}
autoHeight={autoHeight}
revealLine={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.matchLength
: undefined
}
/>
</div>
</div>
)
}
const renderSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
const selectedConflictEntry =
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
return renderEditorContent({
contentFile: selectedFile,
entry: selectedConflictEntry,
className: 'flex min-h-0 flex-1 flex-col',
viewStateKeySuffix: 'selected'
})
}
const renderInlineFile = (entry: GitStatusEntry): React.JSX.Element =>
renderEditorContent({
contentFile: createContentFile(entry),
entry,
className: 'flex min-h-[120px] flex-col border-b border-border last:border-b-0',
viewStateKeySuffix: `overview:${entry.path}`,
readOnly: true,
autoHeight: true
})
const renderAllContent = (): React.JSX.Element => {
const snapshotEntries = activeFile.conflictReview?.entries ?? []
const liveEntriesByPath = new Map(worktreeEntries.map((entry) => [entry.path, entry]))
const unresolvedEntries = snapshotEntries.flatMap((entry) => {
const liveEntry = liveEntriesByPath.get(entry.path)
return liveEntry?.conflictStatus === 'unresolved' && liveEntry.conflictKind ? [liveEntry] : []
})
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-editor-surface scrollbar-sleek">
{unresolvedEntries.map(renderInlineFile)}
</div>
)
}
return (
<ConflictReviewPanel
file={activeFile}
liveEntries={worktreeEntries}
onOpenEntry={openConflictEntry}
selectedFile={selectedConflictReviewFile}
selectedContent={
selectedConflictReviewFile
? renderSelectedContent(selectedConflictReviewFile)
: renderAllContent()
}
onDismiss={() => closeFile(activeFile.id)}
onRefreshSnapshot={() =>
openConflictReview(
activeFile.worktreeId,
activeFile.filePath,
worktreeEntries
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
.map((entry) => ({ path: entry.path, conflictKind: entry.conflictKind! })),
'live-summary'
)
}
onReturnToSourceControl={() => setRightSidebarTab('source-control')}
/>
)
}
function matchesPendingEditorReveal(
reveal: PendingEditorReveal | null,
file: Pick<OpenFile, 'id' | 'filePath'>
): reveal is PendingEditorReveal {
if (!reveal) {
return false
}
return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath
}
@@ -7,6 +7,19 @@ const lifecycle = vi.hoisted(() => ({
events: [] as string[],
diffModelKeys: [] as string[],
models: new Map<string, { content: string; undo: string[] }>(),
notebookProps: [] as {
fileId: string
filePath: string
worktreeId: string
scrollCacheKey: string
onContentChange: (content: string) => void
onSave: (content: string) => Promise<boolean>
}[],
richMarkdownProps: [] as {
externalSshTargetId?: string
runtimeEnvironmentId?: string
worktreeId: string
}[],
mountedProps: [] as {
filePath: string
readOnly?: boolean
@@ -34,6 +47,20 @@ vi.mock('@/lib/lazy-with-retry', async () => {
return null
}
}
if (factory.toString().includes('/IpynbViewer.tsx')) {
return function MockIpynbViewer(props: (typeof lifecycle.notebookProps)[number]) {
lifecycle.notebookProps.push(props)
return null
}
}
if (factory.toString().includes('/RichMarkdownEditor.tsx')) {
return function MockRichMarkdownEditor(
props: (typeof lifecycle.richMarkdownProps)[number]
) {
lifecycle.richMarkdownProps.push(props)
return null
}
}
if (!factory.toString().includes('/MonacoEditor.tsx')) {
return () => null
}
@@ -187,6 +214,8 @@ afterEach(() => {
lifecycle.diffModelKeys.length = 0
lifecycle.models.clear()
lifecycle.mountedProps.length = 0
lifecycle.notebookProps.length = 0
lifecycle.richMarkdownProps.length = 0
})
describe('EditorContent Monaco lifecycle boundary', () => {
@@ -261,4 +290,47 @@ describe('EditorContent Monaco lifecycle boundary', () => {
// armed and a later rich-mode remount of this pane steals focus back.
expect(lifecycle.mountedProps.at(0)?.viewStateId).toBe('same-pane')
})
it('preserves notebook save ownership and worktree routing across the extracted surface', () => {
const notebook = file('/repo/analysis.ipynb', { language: 'notebook' })
const notebookProps = props(notebook, '{"cells": []}')
render(
<EditorContent {...notebookProps} resolvedLanguage="notebook" isNotebook mdViewMode="rich" />
)
expect(lifecycle.notebookProps).toHaveLength(1)
expect(lifecycle.notebookProps[0]).toMatchObject({
fileId: notebook.id,
filePath: notebook.filePath,
worktreeId: notebook.worktreeId,
scrollCacheKey: `${notebook.filePath}::same-pane:notebook`,
onContentChange: notebookProps.handleContentChange,
onSave: notebookProps.handleSave
})
})
it('forwards SSH and runtime ownership to rich markdown after extraction', () => {
const markdown = file('/repo/notes.md', {
language: 'markdown',
externalSshTargetId: 'ssh-target',
runtimeEnvironmentId: 'runtime-environment'
})
render(
<EditorContent
{...props(markdown, '# Notes')}
resolvedLanguage="markdown"
isMarkdown
mdViewMode="rich"
/>
)
expect(lifecycle.richMarkdownProps).toHaveLength(1)
expect(lifecycle.richMarkdownProps[0]).toMatchObject({
externalSshTargetId: 'ssh-target',
runtimeEnvironmentId: 'runtime-environment',
worktreeId: markdown.worktreeId
})
})
})
@@ -1,59 +1,24 @@
/* eslint-disable max-lines -- Why: dispatch surface for every editor mode; keeping the mode-selection branches colocated beats scattering the switch across per-mode wrappers. */
import React from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { ChangesModeView } from './ChangesModeView'
import {
ConflictBanner,
ConflictPlaceholderView,
ConflictReviewPanel,
getNextConflictNavigationIndex
} from './ConflictComponents'
import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor'
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import { useMarkdownDocuments } from './useMarkdownDocuments'
import {
findGitConflictBlocks,
getGitConflictMarkerLineLength
} from './monaco-conflict-decorations'
import { getDiffContentSignature } from './diff-content-signature'
import { translate } from '@/i18n/i18n'
import { CheckRunDetailsPanel } from './CheckRunDetailsPanel'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import { CombinedDiffViewer, MarkdownPreview } from './editor-lazy-views'
import { EditorConflictReviewSurface } from './EditorConflictReviewSurface'
import { EditorDiffFileSurface } from './EditorDiffFileSurface'
import { EditorEditFileSurface } from './EditorEditFileSurface'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import type { FileContent } from './editor-panel-content-types'
import { translate } from '@/i18n/i18n'
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
import { useMarkdownDocuments } from './useMarkdownDocuments'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), {
reloadKey: 'rich-markdown-editor'
})
const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
const ImageViewer = lazy(() => import('./ImageViewer'))
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
const MermaidViewer = lazy(() => import('./MermaidViewer'))
const CsvViewer = lazy(() => import('./CsvViewer'))
const IpynbViewer = lazy(() => import('./IpynbViewer'))
// Why: module-level for a stable no-op identity so read-only tabs don't rebuild callbacks each render.
const noopEditorContentChange = (_content: string): void => {}
const noopEditorSave = async (_content: string): Promise<boolean> => false
const noopCloseMarkdownTableOfContents = (): void => {}
export function getMarkdownSourceLineOffset(frontMatterRaw: string): number {
let offset = 0
for (let index = 0; index < frontMatterRaw.length; index++) {
const code = frontMatterRaw.charCodeAt(index)
if (code === 13) {
offset++
if (frontMatterRaw.charCodeAt(index + 1) === 10) {
@@ -61,61 +26,13 @@ export function getMarkdownSourceLineOffset(frontMatterRaw: string): number {
}
continue
}
if (code === 10) {
offset++
}
}
return offset
}
type FileContent = {
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
loadError?: string
}
const noopCloseMarkdownTableOfContents = (): void => {}
function matchesPendingEditorReveal(
reveal: PendingEditorReveal | null,
file: Pick<OpenFile, 'id' | 'filePath'>
): reveal is PendingEditorReveal {
if (!reveal) {
return false
}
return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath
}
function FileLoadErrorView({
message,
onRetry
}: {
message: string
onRetry: () => void
}): React.JSX.Element {
return (
<div className="flex h-full items-center justify-center bg-editor-surface p-6 text-sm text-muted-foreground">
<div className="flex max-w-xl items-start gap-3 rounded-md border border-border bg-background p-4">
<AlertCircle className="mt-0.5 size-4 flex-shrink-0 text-destructive" />
<div className="min-w-0">
<div className="font-medium text-foreground">
{translate('auto.components.editor.EditorContent.39f018b052', 'Unable to load file')}
</div>
<div className="mt-1 break-words">{message}</div>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={onRetry}>
<RefreshCw className="size-3.5" />
{translate('auto.components.editor.EditorContent.2a512bb46a', 'Retry')}
</Button>
</div>
</div>
</div>
)
}
export function EditorContent({
activeFile,
viewStateScopeId,
@@ -181,31 +98,17 @@ export function EditorContent({
viewStateScopeId === activeFile.id
? `${activeFile.id}:preview`
: `${activeFile.id}::${viewStateScopeId}:preview`
// Why: only the single-pane edit path gets PDF scroll memory — the diff and
// conflict-review paths mount several viewers on one path (see PdfViewer).
// Why: only the single-pane edit path gets PDF scroll memory — diff and conflict review mount several viewers on one path.
const pdfViewStateKey =
viewStateScopeId === activeFile.id
? `${activeFile.filePath}:pdf`
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
const openConflictReviewFile = useAppStore((s) => s.openConflictReviewFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
const reloadOpenCheckRunDetailsTab = useAppStore((s) => s.reloadOpenCheckRunDetailsTab)
const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState<
Record<string, number>
>({})
const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab)
const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
const getConflictNavigation = useEditorConflictNavigation()
const activeConflictEntry =
worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null
const selectedConflictReviewFile =
activeFile.mode === 'conflict-review' && activeFile.conflictReview?.selectedFileId
? (openFiles.find((file) => file.id === activeFile.conflictReview?.selectedFileId) ?? null)
: null
const isCombinedDiff =
activeFile.mode === 'diff' &&
(activeFile.diffSource === 'combined-all' ||
@@ -213,408 +116,6 @@ export function EditorContent({
activeFile.diffSource === 'combined-branch' ||
activeFile.diffSource === 'combined-commit')
const getConflictNavigation = React.useCallback(
(file: OpenFile, content: string) => {
const blocks = findGitConflictBlocks(content)
if (blocks.length === 0) {
return undefined
}
const currentIndex = conflictNavigationIndexByFile[file.id] ?? null
return {
currentIndex,
total: blocks.length,
onJump: (direction: 'previous' | 'next') => {
const nextIndex = getNextConflictNavigationIndex({
currentIndex,
direction,
total: blocks.length
})
if (nextIndex === null) {
return
}
const line = blocks[nextIndex].startLine
const markerLineLength = getGitConflictMarkerLineLength(content, line)
setConflictNavigationIndexByFile((prev) => ({ ...prev, [file.id]: nextIndex }))
// Why: clear first so a repeated same-location reveal still changes the prop and re-runs the editor's reveal effect.
setPendingEditorReveal(null)
queueMicrotask(() => {
setPendingEditorReveal({
filePath: file.filePath,
line,
column: 1,
matchLength: markerLineLength
})
})
}
}
},
[conflictNavigationIndexByFile, setPendingEditorReveal]
)
const openConflictEntry = React.useCallback(
(entry: GitStatusEntry) => {
if (activeFile.mode !== 'conflict-review') {
return
}
openConflictReviewFile(
activeFile.id,
activeFile.worktreeId,
activeFile.filePath,
entry,
detectLanguage(entry.path)
)
},
[
activeFile.filePath,
activeFile.id,
activeFile.mode,
activeFile.worktreeId,
openConflictReviewFile
]
)
const createConflictReviewContentFile = (entry: GitStatusEntry): OpenFile => {
const absolutePath = joinPath(activeFile.filePath, entry.path)
const conflict =
entry.conflictKind && entry.conflictStatus && entry.conflictStatusSource
? entry.status === 'deleted'
? {
kind: 'conflict-placeholder' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource,
message: translate(
'auto.components.editor.EditorContent.8b1a605bae',
'This file is in a conflict state, but no working-tree file is available to edit.'
),
guidance: 'Resolve the conflict in Git or restore one side before reopening it.'
}
: {
kind: 'conflict-editable' as const,
conflictKind: entry.conflictKind,
conflictStatus: entry.conflictStatus,
conflictStatusSource: entry.conflictStatusSource
}
: undefined
return {
id: absolutePath,
filePath: absolutePath,
relativePath: entry.path,
worktreeId: activeFile.worktreeId,
language: detectLanguage(entry.path),
isDirty: false,
mode: 'edit',
conflict
}
}
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
// Why: without a key React reuses the instance and skips cleanup (scroll snapshot); key forces a remount per pane+path.
<MonacoEditor
key={`${viewStateScopeId}\u0000${activeFile.filePath}`}
fileId={activeFile.id}
filePath={activeFile.filePath}
viewStateKey={editorViewStateKey}
viewStateId={viewStateScopeId}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={monacoLanguage}
// Why: read-only tabs no-op the change/save callbacks so no draft, dirty state, or write can occur.
readOnly={activeFile.readOnly === true}
liveTail={activeFile.liveTail === true}
onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange}
onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave}
worktreeId={activeFile.worktreeId}
markdownAnnotationsEnabled={markdownAnnotationsEnabled && isMarkdown}
conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'}
revealLine={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.matchLength
: undefined
}
markdownDocuments={isMarkdown ? md.markdownDocuments : undefined}
/>
)
const renderMarkdownContent = (fc: FileContent): React.JSX.Element => {
const currentContent = editBuffers[activeFile.id] ?? fc.content
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
const renderMode = getMarkdownRenderMode({
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(currentContent),
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
viewMode: mdViewMode
})
if (activeFile.conflict?.conflictStatus === 'unresolved') {
// Why: rich/preview modes hide the conflict-marker source text the user must edit directly.
return <div className="h-full min-h-0">{renderMonacoEditor(fc)}</div>
}
// Why: banner explains why the "rich" view is showing Monaco source (size forced a source-mode fallback).
if (renderMode === 'source' && mdViewMode === 'rich') {
const richFallbackMessage =
richModeUnsupportedMessage ??
'File is too large for rich editing. Showing source mode instead.'
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-blue-500/10 px-3 py-2 text-xs text-blue-950 dark:text-blue-100">
{richFallbackMessage}
</div>
<div className="min-h-0 flex-1 h-full">{renderMonacoEditor(fc)}</div>
</div>
)
}
if (renderMode === 'rich-editor') {
// Why: Tiptap has no front-matter node and would drop it, so strip it here and recombine on change/save.
const fm = extractFrontMatter(currentContent)
const editorContent = fm ? fm.body : currentContent
const onContentChangeWithFm = fm
? (body: string): void => handleContentChange(prependFrontMatter(fm.raw, body))
: handleContentChange
const onSaveWithFm = fm
? (body: string): Promise<boolean> => md.mdSave(prependFrontMatter(fm.raw, body))
: md.mdSave
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
{/* Why: keyed for remount like MonacoEditor; boundary contains a TipTap render crash (issue #826) to this pane. */}
<RichMarkdownErrorBoundary key={viewStateScopeId} fileId={activeFile.id}>
<RichMarkdownEditor
fileId={activeFile.id}
viewStateId={viewStateScopeId}
content={editorContent}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
externalSshTargetId={activeFile.externalSshTargetId}
runtimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:rich`}
onContentChange={onContentChangeWithFm}
onDirtyStateHint={handleDirtyStateHint}
onSave={onSaveWithFm}
onOpenDocLink={md.onOpenDocLink}
markdownDocuments={md.markdownDocuments}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
markdownAnnotationFilePath={activeFile.relativePath}
markdownSourceLineOffset={fm ? getMarkdownSourceLineOffset(fm.raw) : 0}
markdownReviewContent={currentContent}
// Why: banner goes below the toolbar (inside the editor shell) so formatting controls stay at the top of the pane.
headerSlot={
fm && showMarkdownFrontmatter ? <FrontMatterBanner raw={fm.raw} /> : null
}
/>
</RichMarkdownErrorBoundary>
</div>
</div>
)
}
if (renderMode === 'preview') {
const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage
return (
<div className="flex h-full min-h-0 flex-col">
{shouldExplainRichFallback ? (
<div className="border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{richModeUnsupportedMessage}
</div>
) : null}
{/* Why: fall back to the stable preview renderer when Tiptap can't safely own the document. */}
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={currentContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
{...md.previewProps}
/>
</div>
</div>
)
}
// Why: Monaco with height="100%" sizes to its immediate parent, so the wrapper needs an explicit height or it collapses.
return <div className="h-full min-h-0">{renderMonacoEditor(fc)}</div>
}
const renderConflictReviewEditorContent = ({
contentFile,
entry,
className,
viewStateKeySuffix,
readOnly = false,
autoHeight = false
}: {
contentFile: OpenFile
entry: GitStatusEntry | null
className: string
viewStateKeySuffix: string
readOnly?: boolean
autoHeight?: boolean
}): React.JSX.Element => {
if (contentFile.conflict?.kind === 'conflict-placeholder') {
return (
<div className={className}>
<ConflictPlaceholderView file={contentFile} />
</div>
)
}
const fc = fileContents[contentFile.id]
if (!fc) {
return (
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')}
</div>
</div>
)
}
if (fc.loadError) {
return (
<div className={className}>
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(contentFile)} />
</div>
)
}
if (fc.isBinary) {
if (fc.isImage) {
return (
<div className={className}>
<ImageViewer
content={fc.content}
filePath={contentFile.filePath}
mimeType={fc.mimeType}
/>
</div>
)
}
return (
<div className={className}>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.editor.EditorContent.b9de81ba52',
'Binary file — cannot display'
)}
</div>
</div>
)
}
const selectedLanguage = detectLanguage(contentFile.relativePath)
const monacoSelectedLanguage = selectedLanguage === 'notebook' ? 'json' : selectedLanguage
const selectedViewStateKey = `${contentFile.filePath}::${viewStateScopeId}:${viewStateKeySuffix}`
const selectedContent = editBuffers[contentFile.id] ?? fc.content
return (
<div className={className}>
{contentFile.conflict && (
<ConflictBanner
file={contentFile}
entry={entry}
conflictNavigation={getConflictNavigation(contentFile, selectedContent)}
/>
)}
<div className={autoHeight ? 'shrink-0' : 'min-h-0 flex-1'}>
<MonacoEditor
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
fileId={contentFile.id}
filePath={contentFile.filePath}
viewStateKey={selectedViewStateKey}
relativePath={contentFile.relativePath}
content={selectedContent}
language={monacoSelectedLanguage}
onContentChange={
readOnly ? () => {} : (content) => handleContentChangeForFile(contentFile, content)
}
onSave={readOnly ? () => {} : (content) => handleSaveForFile(contentFile, content)}
worktreeId={contentFile.worktreeId}
markdownAnnotationsEnabled={false}
conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'}
readOnly={readOnly}
autoHeight={autoHeight}
revealLine={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.matchLength
: undefined
}
/>
</div>
</div>
)
}
const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => {
const selectedConflictEntry =
worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null
return renderConflictReviewEditorContent({
contentFile: selectedFile,
entry: selectedConflictEntry,
className: 'flex min-h-0 flex-1 flex-col',
viewStateKeySuffix: 'selected'
})
}
const renderConflictReviewInlineFile = (entry: GitStatusEntry): React.JSX.Element => {
const contentFile = createConflictReviewContentFile(entry)
return renderConflictReviewEditorContent({
contentFile,
entry,
className: 'flex min-h-[120px] flex-col border-b border-border last:border-b-0',
viewStateKeySuffix: `overview:${entry.path}`,
readOnly: true,
autoHeight: true
})
}
const renderConflictReviewAllContent = (): React.JSX.Element => {
const snapshotEntries = activeFile.conflictReview?.entries ?? []
const liveEntriesByPath = new Map(worktreeEntries.map((entry) => [entry.path, entry]))
const unresolvedEntries = snapshotEntries.flatMap((entry) => {
const liveEntry = liveEntriesByPath.get(entry.path)
return liveEntry?.conflictStatus === 'unresolved' && liveEntry.conflictKind ? [liveEntry] : []
})
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-editor-surface scrollbar-sleek">
{unresolvedEntries.map(renderConflictReviewInlineFile)}
</div>
)
}
if (activeFile.mode === 'check-details') {
const checkRunDetails = activeFile.checkRunDetails
if (!checkRunDetails) {
@@ -628,14 +129,13 @@ export function EditorContent({
)
}
const details = checkRunDetails.details
const openUrl = details?.detailsUrl ?? details?.url ?? checkRunDetails.check.url
return (
<CheckRunDetailsPanel
check={checkRunDetails.check}
details={checkRunDetails.details}
details={details}
loading={checkRunDetails.loading}
error={checkRunDetails.error}
openUrl={openUrl}
openUrl={details?.detailsUrl ?? details?.url ?? checkRunDetails.check.url}
worktreeId={activeFile.worktreeId}
onRefresh={() => {
void reloadOpenCheckRunDetailsTab(activeFile.id)
@@ -646,31 +146,18 @@ export function EditorContent({
if (activeFile.mode === 'conflict-review') {
return (
<ConflictReviewPanel
file={activeFile}
liveEntries={worktreeEntries}
onOpenEntry={openConflictEntry}
selectedFile={selectedConflictReviewFile}
selectedContent={
selectedConflictReviewFile
? renderConflictReviewSelectedContent(selectedConflictReviewFile)
: renderConflictReviewAllContent()
}
onDismiss={() => closeFile(activeFile.id)}
onRefreshSnapshot={() =>
openConflictReview(
activeFile.worktreeId,
activeFile.filePath,
worktreeEntries
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
.map((entry) => ({
path: entry.path,
conflictKind: entry.conflictKind!
})),
'live-summary'
)
}
onReturnToSourceControl={() => setRightSidebarTab('source-control')}
<EditorConflictReviewSurface
activeFile={activeFile}
viewStateScopeId={viewStateScopeId}
fileContents={fileContents}
editBuffers={editBuffers}
openFiles={openFiles}
worktreeEntries={worktreeEntries}
pendingEditorReveal={pendingEditorReveal}
getConflictNavigation={getConflictNavigation}
handleContentChangeForFile={handleContentChangeForFile}
handleSaveForFile={handleSaveForFile}
reloadContent={reloadContent}
/>
)
}
@@ -686,18 +173,23 @@ export function EditorContent({
}
if (activeFile.mode === 'markdown-preview') {
const fc = fileContents[activeFile.id]
if (!fc) {
const fileContent = fileContents[activeFile.id]
if (!fileContent) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate('auto.components.editor.EditorContent.37a0e81fa6', 'Loading preview...')}
</div>
)
}
if (fc.loadError) {
return <FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(activeFile)} />
if (fileContent.loadError) {
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
onRetry={() => reloadContent(activeFile)}
/>
)
}
if (fc.isBinary) {
if (fileContent.isBinary) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
{translate(
@@ -708,12 +200,11 @@ export function EditorContent({
)
}
const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath
const previewContent = editBuffers[previewSourceFileId] ?? fc.content
return (
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={previewContent}
content={editBuffers[previewSourceFileId] ?? fileContent.content}
filePath={activeFile.filePath}
sourceFileId={previewSourceFileId}
sourceWorktreeId={activeFile.worktreeId}
@@ -723,273 +214,66 @@ export function EditorContent({
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
{...md.previewProps}
{...markdownDocuments.previewProps}
/>
</div>
)
}
if (activeFile.mode === 'edit') {
if (activeFile.conflict?.kind === 'conflict-placeholder') {
return <ConflictPlaceholderView file={activeFile} />
}
const fc = fileContents[activeFile.id]
if (!fc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')}
</div>
)
}
if (fc.loadError) {
return <FileLoadErrorView message={fc.loadError} onRetry={() => reloadContent(activeFile)} />
}
if (fc.isBinary) {
if (fc.isImage) {
return (
<ImageViewer
content={fc.content}
filePath={activeFile.filePath}
mimeType={fc.mimeType}
scrollCacheKey={pdfViewStateKey}
/>
)
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate(
'auto.components.editor.EditorContent.b9de81ba52',
'Binary file — cannot display'
)}
</div>
)
}
const externalChangeBanner =
activeFile.externalMutation === 'changed' ? (
<ExternalFileChangeBanner
file={activeFile}
currentContent={editBuffers[activeFile.id] ?? fc.content}
reloadContent={reloadContent}
/>
) : null
if (isChangesMode) {
const changesView = (
<ChangesModeView
activeFile={activeFile}
dc={diffContents[activeFile.id]}
modifiedContent={editBuffers[activeFile.id] ?? fc.content}
activeConflictEntry={activeConflictEntry}
resolvedLanguage={monacoLanguage}
sideBySide={sideBySide}
viewStateScopeId={viewStateScopeId}
diffViewStateKey={diffViewStateKey}
onContentChange={handleContentChange}
onSave={isMarkdown ? md.mdSave : handleSave}
/>
)
if (!externalChangeBanner) {
return changesView
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{externalChangeBanner}
<div className="min-h-0 flex-1">{changesView}</div>
</div>
)
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{externalChangeBanner}
{activeFile.conflict && (
<ConflictBanner
file={activeFile}
entry={activeConflictEntry}
conflictNavigation={getConflictNavigation(
activeFile,
editBuffers[activeFile.id] ?? fc.content
)}
/>
)}
<div className="min-h-0 flex-1 relative">
{isMarkdown ? (
renderMarkdownContent(fc)
) : isMermaid && mdViewMode === 'rich' ? (
<MermaidViewer
key={activeFile.id}
content={editBuffers[activeFile.id] ?? fc.content}
filePath={activeFile.filePath}
/>
) : isCsv && mdViewMode === 'rich' ? (
<CsvViewer
key={activeFile.id}
content={editBuffers[activeFile.id] ?? fc.content}
filePath={activeFile.filePath}
/>
) : isNotebook && mdViewMode === 'rich' ? (
<IpynbViewer
key={activeFile.id}
content={editBuffers[activeFile.id] ?? fc.content}
fileId={activeFile.id}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
scrollCacheKey={`${editorViewStateKey}:notebook`}
onContentChange={handleContentChange}
onDirtyStateHint={handleDirtyStateHint}
onSave={handleSave}
/>
) : (
renderMonacoEditor(fc)
)}
</div>
</div>
)
}
// Diff mode
const dc = diffContents[activeFile.id]
if (!dc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate('auto.components.editor.EditorContent.c88c73a0d3', 'Loading diff...')}
</div>
)
}
const isEditable = activeFile.diffSource === 'unstaged'
if (dc.kind === 'binary') {
if (dc.isImage) {
return (
<ImageDiffViewer
originalContent={dc.originalContent}
modifiedContent={dc.modifiedContent}
filePath={activeFile.relativePath}
mimeType={dc.mimeType}
sideBySide={sideBySide}
/>
)
}
return (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
{translate('auto.components.editor.EditorContent.78541e254e', 'Binary file changed')}
</div>
<div className="text-xs text-muted-foreground">
{activeFile.diffSource === 'branch'
? translate(
'auto.components.editor.EditorContent.3c6e71df22',
'Text diff is unavailable for this file in branch compare.'
)
: translate(
'auto.components.editor.EditorContent.8a0898ae4c',
'Text diff is unavailable for this file.'
)}
</div>
</div>
</div>
)
}
const modifiedDiffBuffer = editBuffers[activeFile.id]
const modifiedDiffContent = modifiedDiffBuffer ?? dc.modifiedContent
const largeDiffSaveContentAvailable = !(
dc.largeDiffRenderLimit?.limited === true &&
modifiedDiffBuffer === undefined &&
dc.modifiedContent.length === 0
)
// Why: shared by both diff sub-branches (preview and source) so preview mode surfaces the external change too.
const diffExternalChangeBanner =
activeFile.externalMutation === 'changed' ? (
<ExternalFileChangeBanner
file={activeFile}
currentContent={modifiedDiffContent}
<EditorEditFileSurface
activeFile={activeFile}
viewStateScopeId={viewStateScopeId}
editorViewStateKey={editorViewStateKey}
diffViewStateKey={diffViewStateKey}
pdfViewStateKey={pdfViewStateKey}
fileContent={fileContents[activeFile.id]}
diffContent={diffContents[activeFile.id]}
editBuffer={editBuffers[activeFile.id]}
activeConflictEntry={activeConflictEntry}
monacoLanguage={monacoLanguage}
isMarkdown={isMarkdown}
isMermaid={isMermaid}
isCsv={isCsv}
isNotebook={isNotebook}
mdViewMode={mdViewMode}
isChangesMode={isChangesMode}
sideBySide={sideBySide}
showMarkdownTableOfContents={showMarkdownTableOfContents}
showMarkdownFrontmatter={showMarkdownFrontmatter}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
pendingEditorReveal={pendingEditorReveal}
markdownDocuments={markdownDocuments}
getConflictNavigation={getConflictNavigation}
getMarkdownSourceLineOffset={getMarkdownSourceLineOffset}
handleContentChange={handleContentChange}
handleDirtyStateHint={handleDirtyStateHint}
handleSave={handleSave}
reloadContent={reloadContent}
/>
) : null
if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) {
return (
<div className="flex h-full min-h-0 flex-col">
{diffExternalChangeBanner}
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{/* Why: markdown preview can't show additions and deletions at once, so it shows only the modified side. */}
{translate(
'auto.components.editor.EditorContent.9640d1d3db',
'Previewing the modified version of this diff. Switch to source mode to inspect changes.'
)}
</div>
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={modifiedDiffContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${diffViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
{...md.previewProps}
/>
</div>
</div>
)
}
// Why: key off fetched diff content + reload nonce (not the edit buffer) so Monaco reloads refreshed blobs but keeps undo.
const diffReloadNonce = activeFile.diffContentReloadNonce ?? 0
const originalModelKey = `${diffViewStateKey}:original:${getDiffContentSignature(dc.originalContent)}`
const modifiedModelKey = `${diffViewStateKey}:modified:${getDiffContentSignature(dc.modifiedContent)}:${diffReloadNonce}`
const diffViewer = (
<DiffViewer
// Why: content refreshes via modifiedModelKey; keying off content too would remount Monaco and flash on every save.
key={`${viewStateScopeId}:${diffReloadNonce}`}
modelKey={diffViewStateKey}
originalModelKey={originalModelKey}
modifiedModelKey={modifiedModelKey}
originalContent={dc.originalContent}
modifiedContent={modifiedDiffContent}
largeDiffRenderLimit={dc.largeDiffRenderLimit}
largeDiffSaveContentAvailable={largeDiffSaveContentAvailable}
language={monacoLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
return (
<EditorDiffFileSurface
activeFile={activeFile}
diffContent={diffContents[activeFile.id]}
editBuffer={editBuffers[activeFile.id]}
resolvedLanguage={monacoLanguage}
sideBySide={sideBySide}
editable={isEditable}
worktreeId={activeFile.worktreeId}
onContentChange={isEditable ? handleContentChange : undefined}
onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined}
viewStateScopeId={viewStateScopeId}
diffViewStateKey={diffViewStateKey}
mdViewMode={mdViewMode}
isMarkdown={isMarkdown}
showMarkdownTableOfContents={showMarkdownTableOfContents}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
markdownDocuments={markdownDocuments}
onContentChange={handleContentChange}
onSave={handleSave}
reloadContent={reloadContent}
/>
)
// Why: editable diffs get the changed-on-disk banner; its reload refetches the diff body, not plain file content.
if (activeFile.externalMutation !== 'changed') {
return diffViewer
}
return (
// Why: parent isn't a flex container, so flex-1 collapses to 0px — use h-full here and a flex column inside.
<div className="flex h-full min-h-0 flex-col">
{diffExternalChangeBanner}
<div className="flex min-h-0 flex-1 flex-col">{diffViewer}</div>
</div>
)
}
// Why: no collapsible state — layout shifts would interfere with ProseMirror's scroll management.
function FrontMatterBanner({ raw }: { raw: string }): React.JSX.Element {
// Strip the opening/closing delimiters to show only the YAML/TOML content.
const inner = raw
.replace(/^(?:---|\+\+\+)\r?\n/, '')
.replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '')
.trim()
return (
<div className="border-b border-border/60 bg-muted/40 px-3 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{translate('auto.components.editor.EditorContent.e4b074749d', 'Front Matter')}
<span className="ml-2 font-normal normal-case tracking-normal opacity-70">
{translate('auto.components.editor.EditorContent.56dba34e1a', '(edit in source mode)')}
</span>
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground font-mono scrollbar-editor">
{inner}
</pre>
</div>
)
}
@@ -0,0 +1,172 @@
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
import { getDiffContentSignature } from './diff-content-signature'
import { DiffViewer, ImageDiffViewer, MarkdownPreview } from './editor-lazy-views'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
type MarkdownDocumentsController = ReturnType<typeof useMarkdownDocuments>
export function EditorDiffFileSurface({
activeFile,
diffContent,
editBuffer,
resolvedLanguage,
sideBySide,
viewStateScopeId,
diffViewStateKey,
mdViewMode,
isMarkdown,
showMarkdownTableOfContents,
onCloseMarkdownTableOfContents,
markdownAnnotationsEnabled,
markdownDocuments,
onContentChange,
onSave,
reloadContent
}: {
activeFile: OpenFile
diffContent: GitDiffResult | undefined
editBuffer: string | undefined
resolvedLanguage: string
sideBySide: boolean
viewStateScopeId: string
diffViewStateKey: string
mdViewMode: 'source' | 'preview' | 'rich'
isMarkdown: boolean
showMarkdownTableOfContents: boolean
onCloseMarkdownTableOfContents: () => void
markdownAnnotationsEnabled: boolean
markdownDocuments: MarkdownDocumentsController
onContentChange: (content: string) => void
onSave: (content: string) => Promise<boolean>
reloadContent: (file: OpenFile) => void
}): React.JSX.Element {
if (!diffContent) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate('auto.components.editor.EditorContent.c88c73a0d3', 'Loading diff...')}
</div>
)
}
const isEditable = activeFile.diffSource === 'unstaged'
if (diffContent.kind === 'binary') {
if (diffContent.isImage) {
return (
<ImageDiffViewer
originalContent={diffContent.originalContent}
modifiedContent={diffContent.modifiedContent}
filePath={activeFile.relativePath}
mimeType={diffContent.mimeType}
sideBySide={sideBySide}
/>
)
}
return (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
{translate('auto.components.editor.EditorContent.78541e254e', 'Binary file changed')}
</div>
<div className="text-xs text-muted-foreground">
{activeFile.diffSource === 'branch'
? translate(
'auto.components.editor.EditorContent.3c6e71df22',
'Text diff is unavailable for this file in branch compare.'
)
: translate(
'auto.components.editor.EditorContent.8a0898ae4c',
'Text diff is unavailable for this file.'
)}
</div>
</div>
</div>
)
}
const modifiedDiffContent = editBuffer ?? diffContent.modifiedContent
const largeDiffSaveContentAvailable = !(
diffContent.largeDiffRenderLimit?.limited === true &&
editBuffer === undefined &&
diffContent.modifiedContent.length === 0
)
const externalChangeBanner =
activeFile.externalMutation === 'changed' ? (
<ExternalFileChangeBanner
file={activeFile}
currentContent={modifiedDiffContent}
reloadContent={reloadContent}
/>
) : null
if (
isMarkdown &&
mdViewMode === 'preview' &&
diffContent.largeDiffRenderLimit?.limited !== true
) {
return (
<div className="flex h-full min-h-0 flex-col">
{externalChangeBanner}
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{/* Why: markdown preview can't show additions and deletions at once, so it shows only the modified side. */}
{translate(
'auto.components.editor.EditorContent.9640d1d3db',
'Previewing the modified version of this diff. Switch to source mode to inspect changes.'
)}
</div>
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={modifiedDiffContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${diffViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
{...markdownDocuments.previewProps}
/>
</div>
</div>
)
}
const diffReloadNonce = activeFile.diffContentReloadNonce ?? 0
const originalModelKey = `${diffViewStateKey}:original:${getDiffContentSignature(diffContent.originalContent)}`
const modifiedModelKey = `${diffViewStateKey}:modified:${getDiffContentSignature(diffContent.modifiedContent)}:${diffReloadNonce}`
const diffViewer = (
<DiffViewer
// Why: content refreshes via modifiedModelKey; keying off content too would remount Monaco and flash on every save.
key={`${viewStateScopeId}:${diffReloadNonce}`}
modelKey={diffViewStateKey}
originalModelKey={originalModelKey}
modifiedModelKey={modifiedModelKey}
originalContent={diffContent.originalContent}
modifiedContent={modifiedDiffContent}
largeDiffRenderLimit={diffContent.largeDiffRenderLimit}
largeDiffSaveContentAvailable={largeDiffSaveContentAvailable}
language={resolvedLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
sideBySide={sideBySide}
editable={isEditable}
worktreeId={activeFile.worktreeId}
onContentChange={isEditable ? onContentChange : undefined}
onSave={isEditable ? (isMarkdown ? markdownDocuments.mdSave : onSave) : undefined}
/>
)
if (activeFile.externalMutation !== 'changed') {
return diffViewer
}
return (
// Why: parent isn't a flex container, so flex-1 collapses to 0px — use h-full here and a flex column inside.
<div className="flex h-full min-h-0 flex-col">
{externalChangeBanner}
<div className="flex min-h-0 flex-1 flex-col">{diffViewer}</div>
</div>
)
}
@@ -0,0 +1,266 @@
import { translate } from '@/i18n/i18n'
import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor'
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { ChangesModeView } from './ChangesModeView'
import { ConflictBanner, ConflictPlaceholderView } from './ConflictComponents'
import {
CsvViewer,
ImageViewer,
IpynbViewer,
MermaidViewer,
MonacoEditor
} from './editor-lazy-views'
import type { EditorConflictNavigation } from './useEditorConflictNavigation'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import type { FileContent } from './editor-panel-content-types'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
import { EditorMarkdownFileSurface } from './EditorMarkdownFileSurface'
const noopEditorContentChange = (_content: string): void => {}
const noopEditorSave = async (_content: string): Promise<boolean> => false
type MarkdownDocumentsController = ReturnType<typeof useMarkdownDocuments>
export function EditorEditFileSurface({
activeFile,
viewStateScopeId,
editorViewStateKey,
diffViewStateKey,
pdfViewStateKey,
fileContent,
diffContent,
editBuffer,
activeConflictEntry,
monacoLanguage,
isMarkdown,
isMermaid,
isCsv,
isNotebook,
mdViewMode,
isChangesMode,
sideBySide,
showMarkdownTableOfContents,
showMarkdownFrontmatter,
onCloseMarkdownTableOfContents,
markdownAnnotationsEnabled,
pendingEditorReveal,
markdownDocuments,
getConflictNavigation,
getMarkdownSourceLineOffset,
handleContentChange,
handleDirtyStateHint,
handleSave,
reloadContent
}: {
activeFile: OpenFile
viewStateScopeId: string
editorViewStateKey: string
diffViewStateKey: string
pdfViewStateKey: string
fileContent: FileContent | undefined
diffContent: GitDiffResult | undefined
editBuffer: string | undefined
activeConflictEntry: GitStatusEntry | null
monacoLanguage: string
isMarkdown: boolean
isMermaid: boolean
isCsv: boolean
isNotebook: boolean
mdViewMode: MarkdownViewMode
isChangesMode: boolean
sideBySide: boolean
showMarkdownTableOfContents: boolean
showMarkdownFrontmatter: boolean
onCloseMarkdownTableOfContents: () => void
markdownAnnotationsEnabled: boolean
pendingEditorReveal: PendingEditorReveal | null
markdownDocuments: MarkdownDocumentsController
getConflictNavigation: (file: OpenFile, content: string) => EditorConflictNavigation | undefined
getMarkdownSourceLineOffset: (frontMatterRaw: string) => number
handleContentChange: (content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
handleSave: (content: string) => Promise<boolean>
reloadContent: (file: OpenFile) => void
}): React.JSX.Element {
if (activeFile.conflict?.kind === 'conflict-placeholder') {
return <ConflictPlaceholderView file={activeFile} />
}
if (!fileContent) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')}
</div>
)
}
if (fileContent.loadError) {
return (
<EditorFileLoadErrorView
message={fileContent.loadError}
onRetry={() => reloadContent(activeFile)}
/>
)
}
if (fileContent.isBinary) {
if (fileContent.isImage) {
return (
<ImageViewer
content={fileContent.content}
filePath={activeFile.filePath}
mimeType={fileContent.mimeType}
scrollCacheKey={pdfViewStateKey}
/>
)
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{translate(
'auto.components.editor.EditorContent.b9de81ba52',
'Binary file — cannot display'
)}
</div>
)
}
const currentContent = editBuffer ?? fileContent.content
const externalChangeBanner =
activeFile.externalMutation === 'changed' ? (
<ExternalFileChangeBanner
file={activeFile}
currentContent={currentContent}
reloadContent={reloadContent}
/>
) : null
if (isChangesMode) {
const changesView = (
<ChangesModeView
activeFile={activeFile}
dc={diffContent}
modifiedContent={currentContent}
activeConflictEntry={activeConflictEntry}
resolvedLanguage={monacoLanguage}
sideBySide={sideBySide}
viewStateScopeId={viewStateScopeId}
diffViewStateKey={diffViewStateKey}
onContentChange={handleContentChange}
onSave={isMarkdown ? markdownDocuments.mdSave : handleSave}
/>
)
if (!externalChangeBanner) {
return changesView
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{externalChangeBanner}
<div className="min-h-0 flex-1">{changesView}</div>
</div>
)
}
const monacoEditor = (
// Why: without a key React reuses the instance and skips cleanup (scroll snapshot); key forces a remount per pane+path.
<MonacoEditor
key={`${viewStateScopeId}\u0000${activeFile.filePath}`}
fileId={activeFile.id}
filePath={activeFile.filePath}
viewStateKey={editorViewStateKey}
viewStateId={viewStateScopeId}
relativePath={activeFile.relativePath}
content={currentContent}
language={monacoLanguage}
// Why: read-only tabs no-op the change/save callbacks so no draft, dirty state, or write can occur.
readOnly={activeFile.readOnly === true}
liveTail={activeFile.liveTail === true}
onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange}
onSave={
activeFile.readOnly === true
? noopEditorSave
: isMarkdown
? markdownDocuments.mdSave
: handleSave
}
worktreeId={activeFile.worktreeId}
markdownAnnotationsEnabled={markdownAnnotationsEnabled && isMarkdown}
conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'}
revealLine={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.matchLength
: undefined
}
markdownDocuments={isMarkdown ? markdownDocuments.markdownDocuments : undefined}
/>
)
const editorSurface = isMarkdown ? (
<EditorMarkdownFileSurface
activeFile={activeFile}
viewStateScopeId={viewStateScopeId}
editorViewStateKey={editorViewStateKey}
currentContent={currentContent}
mdViewMode={mdViewMode}
showMarkdownTableOfContents={showMarkdownTableOfContents}
showMarkdownFrontmatter={showMarkdownFrontmatter}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
markdownDocuments={markdownDocuments}
getMarkdownSourceLineOffset={getMarkdownSourceLineOffset}
handleContentChange={handleContentChange}
handleDirtyStateHint={handleDirtyStateHint}
monacoEditor={monacoEditor}
/>
) : isMermaid && mdViewMode === 'rich' ? (
<MermaidViewer key={activeFile.id} content={currentContent} filePath={activeFile.filePath} />
) : isCsv && mdViewMode === 'rich' ? (
<CsvViewer key={activeFile.id} content={currentContent} filePath={activeFile.filePath} />
) : isNotebook && mdViewMode === 'rich' ? (
<IpynbViewer
key={activeFile.id}
content={currentContent}
fileId={activeFile.id}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
scrollCacheKey={`${editorViewStateKey}:notebook`}
onContentChange={handleContentChange}
onDirtyStateHint={handleDirtyStateHint}
onSave={handleSave}
/>
) : (
monacoEditor
)
return (
<div className="flex flex-1 min-h-0 flex-col">
{externalChangeBanner}
{activeFile.conflict && (
<ConflictBanner
file={activeFile}
entry={activeConflictEntry}
conflictNavigation={getConflictNavigation(activeFile, currentContent)}
/>
)}
<div className="min-h-0 flex-1 relative">{editorSurface}</div>
</div>
)
}
function matchesPendingEditorReveal(
reveal: PendingEditorReveal | null,
file: Pick<OpenFile, 'id' | 'filePath'>
): reveal is PendingEditorReveal {
if (!reveal) {
return false
}
return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath
}
@@ -0,0 +1,29 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export function EditorFileLoadErrorView({
message,
onRetry
}: {
message: string
onRetry: () => void
}): React.JSX.Element {
return (
<div className="flex h-full items-center justify-center bg-editor-surface p-6 text-sm text-muted-foreground">
<div className="flex max-w-xl items-start gap-3 rounded-md border border-border bg-background p-4">
<AlertCircle className="mt-0.5 size-4 flex-shrink-0 text-destructive" />
<div className="min-w-0">
<div className="font-medium text-foreground">
{translate('auto.components.editor.EditorContent.39f018b052', 'Unable to load file')}
</div>
<div className="mt-1 break-words">{message}</div>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={onRetry}>
<RefreshCw className="size-3.5" />
{translate('auto.components.editor.EditorContent.2a512bb46a', 'Retry')}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,167 @@
import { translate } from '@/i18n/i18n'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
type MarkdownDocumentsController = ReturnType<typeof useMarkdownDocuments>
export function EditorMarkdownFileSurface({
activeFile,
viewStateScopeId,
editorViewStateKey,
currentContent,
mdViewMode,
showMarkdownTableOfContents,
showMarkdownFrontmatter,
onCloseMarkdownTableOfContents,
markdownAnnotationsEnabled,
markdownDocuments,
getMarkdownSourceLineOffset,
handleContentChange,
handleDirtyStateHint,
monacoEditor
}: {
activeFile: OpenFile
viewStateScopeId: string
editorViewStateKey: string
currentContent: string
mdViewMode: MarkdownViewMode
showMarkdownTableOfContents: boolean
showMarkdownFrontmatter: boolean
onCloseMarkdownTableOfContents: () => void
markdownAnnotationsEnabled: boolean
markdownDocuments: MarkdownDocumentsController
getMarkdownSourceLineOffset: (frontMatterRaw: string) => number
handleContentChange: (content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
monacoEditor: React.JSX.Element
}): React.JSX.Element {
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
const renderMode = getMarkdownRenderMode({
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(currentContent),
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
viewMode: mdViewMode
})
if (activeFile.conflict?.conflictStatus === 'unresolved') {
return <div className="h-full min-h-0">{monacoEditor}</div>
}
if (renderMode === 'source' && mdViewMode === 'rich') {
const richFallbackMessage =
richModeUnsupportedMessage ??
'File is too large for rich editing. Showing source mode instead.'
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border/60 bg-blue-500/10 px-3 py-2 text-xs text-blue-950 dark:text-blue-100">
{richFallbackMessage}
</div>
<div className="min-h-0 flex-1 h-full">{monacoEditor}</div>
</div>
)
}
if (renderMode === 'rich-editor') {
const frontMatter = extractFrontMatter(currentContent)
const editorContent = frontMatter ? frontMatter.body : currentContent
const onContentChange = frontMatter
? (body: string): void => handleContentChange(prependFrontMatter(frontMatter.raw, body))
: handleContentChange
const onSave = frontMatter
? (body: string): Promise<boolean> =>
markdownDocuments.mdSave(prependFrontMatter(frontMatter.raw, body))
: markdownDocuments.mdSave
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
{/* Why: keyed for remount like MonacoEditor; boundary contains a TipTap render crash (issue #826) to this pane. */}
<RichMarkdownErrorBoundary key={viewStateScopeId} fileId={activeFile.id}>
<RichMarkdownEditor
fileId={activeFile.id}
viewStateId={viewStateScopeId}
content={editorContent}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
externalSshTargetId={activeFile.externalSshTargetId}
runtimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:rich`}
onContentChange={onContentChange}
onDirtyStateHint={handleDirtyStateHint}
onSave={onSave}
onOpenDocLink={markdownDocuments.onOpenDocLink}
markdownDocuments={markdownDocuments.markdownDocuments}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
markdownAnnotationFilePath={activeFile.relativePath}
markdownSourceLineOffset={
frontMatter ? getMarkdownSourceLineOffset(frontMatter.raw) : 0
}
markdownReviewContent={currentContent}
// Why: banner goes below the toolbar (inside the editor shell) so formatting controls stay at the top of the pane.
headerSlot={
frontMatter && showMarkdownFrontmatter ? (
<FrontMatterBanner raw={frontMatter.raw} />
) : null
}
/>
</RichMarkdownErrorBoundary>
</div>
</div>
)
}
if (renderMode === 'preview') {
const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage
return (
<div className="flex h-full min-h-0 flex-col">
{shouldExplainRichFallback ? (
<div className="border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{richModeUnsupportedMessage}
</div>
) : null}
{/* Why: fall back to the stable preview renderer when Tiptap can't safely own the document. */}
<div className="min-h-0 flex-1">
<MarkdownPreview
key={viewStateScopeId}
content={currentContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
{...markdownDocuments.previewProps}
/>
</div>
</div>
)
}
return <div className="h-full min-h-0">{monacoEditor}</div>
}
function FrontMatterBanner({ raw }: { raw: string }): React.JSX.Element {
const inner = raw
.replace(/^(?:---|\+\+\+)\r?\n/, '')
.replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '')
.trim()
return (
<div className="border-b border-border/60 bg-muted/40 px-3 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{translate('auto.components.editor.EditorContent.e4b074749d', 'Front Matter')}
<span className="ml-2 font-normal normal-case tracking-normal opacity-70">
{translate('auto.components.editor.EditorContent.56dba34e1a', '(edit in source mode)')}
</span>
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground font-mono scrollbar-editor">
{inner}
</pre>
</div>
)
}
@@ -0,0 +1,148 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { useAppStore } from '@/store'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
import { getIpynbCodeCellEditorHeight, getIpynbCodeCellPreviewLines } from './ipynb-code-cell-lines'
import type { IpynbCell } from './ipynb-parse'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
export function IpynbMarkdownCell({ source }: { source: string }): React.JSX.Element {
return (
<div className="markdown-preview-body px-4 py-3 text-sm">
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{source || '\u00a0'}
</Markdown>
</div>
)
}
export function IpynbEditableTextCell({
source,
onChange
}: {
source: string
onChange: (source: string) => void
}): React.JSX.Element {
return (
<textarea
value={source}
onChange={(event) => onChange(event.target.value)}
className="block min-h-24 w-full resize-y border-0 bg-background px-4 py-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
)
}
function IpynbCodeCellEditor({
cell,
source,
active,
onActivate,
onDeactivate,
onChange,
onSaveRequest
}: {
cell: IpynbCell
source: string
active: boolean
onActivate: () => void
onDeactivate: () => void
onChange: (source: string) => void
onSaveRequest: () => Promise<void>
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const onDeactivateRef = useRef(onDeactivate)
const onSaveRequestRef = useRef(onSaveRequest)
useLayoutEffect(() => {
onDeactivateRef.current = onDeactivate
onSaveRequestRef.current = onSaveRequest
}, [onDeactivate, onSaveRequest])
const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
const editorHeight = getIpynbCodeCellEditorHeight(source, fontSize)
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
const lines = useMemo(() => getIpynbCodeCellPreviewLines(source), [source])
const handleMount: OnMount = useCallback((editorInstance, monacoInstance) => {
editorInstance.focus()
const cleanupSaveShortcut = installEditorSaveShortcut(
editorInstance.getContainerDomNode(),
() => {
void onSaveRequestRef.current()
}
)
const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance)
const blurSub = editorInstance.onDidBlurEditorWidget(() => {
onDeactivateRef.current()
})
editorInstance.onDidDispose(() => {
cleanupSaveShortcut()
cleanupFindShortcut()
blurSub.dispose()
})
editorInstance.addCommand(monacoInstance.KeyCode.Escape, () => {
onDeactivateRef.current()
})
}, [])
useEffect(() => {
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs')
}, [isDark])
if (!active) {
return (
<div
role="button"
tabIndex={0}
className="block w-full cursor-text bg-editor-surface text-left"
onClick={onActivate}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onActivate()
}
}}
>
<MonacoCodeExcerpt
lines={lines}
firstLineNumber={1}
highlightedStartLine={-1}
highlightedEndLine={-1}
language={cell.language}
/>
</div>
)
}
return (
<div className="bg-editor-surface focus-within:ring-1 focus-within:ring-ring">
<Editor
height={editorHeight}
defaultLanguage={cell.language}
language={cell.language}
theme={isDark ? 'vs-dark' : 'vs'}
value={source}
onMount={handleMount}
onChange={(value) => onChange(value ?? '')}
options={{
automaticLayout: true,
fontFamily: resolveEditorFontFamily(settings),
fontSize,
glyphMargin: false,
lineNumbersMinChars: 3,
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollBeyondLastLine: false,
wordWrap: 'off'
}}
/>
</div>
)
}
export const IpynbCodeCell = memo(IpynbCodeCellEditor)
@@ -0,0 +1,121 @@
import DOMPurify from 'dompurify'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { IpynbMarkdownCell } from './IpynbCellEditor'
import type { IpynbCell, IpynbOutputItem } from './ipynb-parse'
function valueToText(value: unknown): string {
if (Array.isArray(value)) {
return value.map((item) => String(item ?? '')).join('')
}
if (typeof value === 'string') {
return value
}
if (value === undefined || value === null) {
return ''
}
return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
}
function dataUriForImage(item: IpynbOutputItem): string | null {
const value = valueToText(item.value).replace(/\s/g, '')
if (!value) {
return null
}
if (item.mime === 'image/svg+xml') {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(valueToText(item.value))}`
}
return `data:${item.mime};base64,${value}`
}
function PreformattedOutput({
text,
error = false
}: {
text: string
error?: boolean
}): React.JSX.Element {
return (
<pre
className={cn(
'max-h-[420px] overflow-auto whitespace-pre-wrap px-3 py-2 font-mono text-xs leading-5 scrollbar-editor',
error ? 'text-destructive' : 'text-foreground'
)}
>
{text}
</pre>
)
}
function OutputItem({ item }: { item: IpynbOutputItem }): React.JSX.Element | null {
if (item.mime === 'text/html') {
const html = DOMPurify.sanitize(valueToText(item.value), {
USE_PROFILES: { html: true, svg: true, svgFilters: true }
})
return (
<iframe
title={translate('auto.components.editor.IpynbViewer.66a3f7d330', 'Notebook HTML output')}
sandbox=""
referrerPolicy="no-referrer"
loading="lazy"
className="block h-80 w-full border-0 bg-background"
srcDoc={html}
/>
)
}
if (item.mime.startsWith('image/')) {
const uri = dataUriForImage(item)
return uri ? (
<div className="flex max-w-full overflow-auto p-3 scrollbar-editor">
<img src={uri} alt={item.mime} className="max-h-[520px] max-w-full object-contain" />
</div>
) : null
}
if (item.mime === 'application/json' || item.mime.endsWith('+json')) {
const text =
typeof item.value === 'string' ? item.value : JSON.stringify(item.value ?? null, null, 2)
return <PreformattedOutput text={text} />
}
if (item.mime === 'text/markdown') {
return <IpynbMarkdownCell source={valueToText(item.value)} />
}
if (item.mime.startsWith('text/') || item.mime === 'application/javascript') {
return <PreformattedOutput text={valueToText(item.value)} />
}
return null
}
export function IpynbCellOutputs({ cell }: { cell: IpynbCell }): React.JSX.Element | null {
if (cell.outputs.length === 0) {
return null
}
return (
<div className="border-t border-border/50 bg-background">
{cell.outputs.map((output, index) => {
if (output.kind === 'stream') {
return <PreformattedOutput key={index} text={output.text} />
}
if (output.kind === 'error') {
return (
<div key={index} className="border-l-2 border-destructive">
<PreformattedOutput
error
text={[output.name, output.message, output.traceback].filter(Boolean).join('\n')}
/>
</div>
)
}
const renderedItems = output.items
.map((item, itemIndex) => <OutputItem key={`${item.mime}-${itemIndex}`} item={item} />)
.filter(Boolean)
return renderedItems.length > 0 ? (
<div key={index} className="border-b border-border/40 last:border-b-0">
{renderedItems}
</div>
) : null
})}
</div>
)
}
@@ -0,0 +1,176 @@
import type { ReactNode } from 'react'
import {
ArrowDownToLine,
ArrowUpToLine,
Braces,
FileCode2,
Loader2,
MoveDown,
MoveUp,
Play,
Trash2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import type { IpynbCell, IpynbCellKind } from './ipynb-parse'
export function IpynbToolbarButton({
label,
disabled = false,
shortcut,
onClick,
children
}: {
label: string
disabled?: boolean
shortcut?: ShortcutKeyComboDetails
onClick: () => void
children: ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<span className="flex items-center gap-2">
<span>{label}</span>
{shortcut && shortcut.keys.length > 0 ? (
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
) : null}
</span>
</TooltipContent>
</Tooltip>
)
}
export function IpynbCellToolbar({
cell,
index,
running,
canMoveUp,
canMoveDown,
onRun,
onKindChange,
onInsertAbove,
onInsertBelow,
onMoveUp,
onMoveDown,
onDelete
}: {
cell: IpynbCell
index: number
running: boolean
canMoveUp: boolean
canMoveDown: boolean
onRun: () => void
onKindChange: (kind: IpynbCellKind) => void
onInsertAbove: (kind: IpynbCellKind) => void
onInsertBelow: (kind: IpynbCellKind) => void
onMoveUp: () => void
onMoveDown: () => void
onDelete: () => void
}): React.JSX.Element {
const Icon = cell.kind === 'code' ? Play : cell.kind === 'markdown' ? FileCode2 : Braces
const executionLabel = cell.kind === 'code' ? `In [${cell.executionCount ?? ' '}]:` : cell.kind
return (
<div className="flex items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground">
<Icon className="size-3.5" />
<span className="font-mono">{executionLabel}</span>
<select
value={cell.kind}
onChange={(event) => onKindChange(event.target.value as IpynbCellKind)}
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground"
>
<option value="code">
{translate('auto.components.editor.IpynbViewer.7005960d73', 'Code')}
</option>
<option value="markdown">
{translate('auto.components.editor.IpynbViewer.1833dbbc43', 'Markdown')}
</option>
<option value="raw">
{translate('auto.components.editor.IpynbViewer.3e4cbf15ea', 'Raw')}
</option>
</select>
{cell.kind === 'code' ? (
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.859bf9fc21', 'Run cell')}
disabled={running}
onClick={onRun}
>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
</IpynbToolbarButton>
) : null}
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.fd8ac707bc', 'Move cell up')}
disabled={!canMoveUp}
onClick={onMoveUp}
>
<MoveUp className="size-3.5" />
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.27e064e2db', 'Move cell down')}
disabled={!canMoveDown}
onClick={onMoveDown}
>
<MoveDown className="size-3.5" />
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.53b839b8a0', 'Insert code cell above')}
onClick={() => onInsertAbove('code')}
>
<ArrowUpToLine className="size-3.5" />
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.b4208cad7e', 'Insert code cell below')}
onClick={() => onInsertBelow('code')}
>
<ArrowDownToLine className="size-3.5" />
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate(
'auto.components.editor.IpynbViewer.ffc1ac2699',
'Insert markdown cell above'
)}
onClick={() => onInsertAbove('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveUp className="absolute -right-0.5 -top-0.5 size-2.5" />
</span>
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate(
'auto.components.editor.IpynbViewer.b42f6a9547',
'Insert markdown cell below'
)}
onClick={() => onInsertBelow('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveDown className="absolute -bottom-0.5 -right-0.5 size-2.5" />
</span>
</IpynbToolbarButton>
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.781abd6926', 'Delete cell')}
onClick={onDelete}
>
<Trash2 className="size-3.5" />
</IpynbToolbarButton>
<span className="ml-auto font-mono">#{index + 1}</span>
</div>
)
}
@@ -1,47 +1,7 @@
/* eslint-disable max-lines -- Why: notebook editing, output rendering, and cell
controls share one parsed document/update path for this first notebook editor
slice; splitting before the model stabilizes would make save/run mutations
harder to audit. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: source drafts are reconciled against parsed notebook cells after editor flushes so stale drafts do not overwrite external notebook updates. */
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MutableRefObject
} from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import DOMPurify from 'dompurify'
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import {
AlertCircle,
ArrowDownToLine,
ArrowUpToLine,
Braces,
FileCode2,
Loader2,
MoveDown,
MoveUp,
Play,
Save,
Trash2
} from 'lucide-react'
import { monaco } from '@/lib/monaco-setup'
import {
computeEditorFontSize,
resolveEditorFontFamily,
resolveEditorFontFamilyOrInherit
} from '@/lib/editor-font-zoom'
import { getConnectionId } from '@/lib/connection-context'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { useCallback, useMemo, useState } from 'react'
import { AlertCircle, Save } from 'lucide-react'
import { computeEditorFontSize, resolveEditorFontFamilyOrInherit } from '@/lib/editor-font-zoom'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -51,27 +11,20 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { registerPendingEditorFlush } from './editor-pending-flush'
import {
editorShortcutMatches,
installEditorSaveShortcut,
installMonacoEditorFindShortcut
} from './editor-shortcuts'
import { getIpynbCodeCellEditorHeight, getIpynbCodeCellPreviewLines } from './ipynb-code-cell-lines'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
import {
deleteIpynbCell,
insertIpynbCell,
moveIpynbCell,
updateIpynbCellKind,
updateIpynbCellOutputs,
updateIpynbCellSources
} from './ipynb-cell-mutations'
import { parseIpynb, type IpynbCell, type IpynbCellKind, type IpynbOutputItem } from './ipynb-parse'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { editorShortcutMatches } from './editor-shortcuts'
import { IpynbCellToolbar, IpynbToolbarButton } from './IpynbCellToolbar'
import { IpynbCodeCell, IpynbEditableTextCell, IpynbMarkdownCell } from './IpynbCellEditor'
import { IpynbCellOutputs } from './IpynbCellOutputs'
import { parseIpynb } from './ipynb-parse'
import {
getIpynbCellKey,
hasIpynbSourceDraft,
useIpynbDocumentEditing
} from './useIpynbDocumentEditing'
import { useIpynbCellExecution } from './useIpynbCellExecution'
import { useIpynbScrollRestoration } from './useIpynbScrollRestoration'
type IpynbViewerProps = {
content: string
@@ -84,475 +37,6 @@ type IpynbViewerProps = {
onSave: (content: string) => Promise<boolean>
}
const NOTEBOOK_SOURCE_COMMIT_DELAY_MS = 400
function cancelIpynbStructuralContentFrames(frameIds: MutableRefObject<number[]>): void {
for (const frameId of frameIds.current) {
cancelAnimationFrame(frameId)
}
frameIds.current = []
}
function requestIpynbStructuralContentFrame(
frameIds: MutableRefObject<number[]>,
callback: FrameRequestCallback
): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
frameIds.current = frameIds.current.filter((pendingFrameId) => pendingFrameId !== frameId)
}
callback(timestamp)
})
if (!completed) {
frameIds.current.push(frameId)
}
}
type NotebookExecutionTrustState = {
filePath: string
trustedForFile: boolean
pendingRunCellIndex: number | null
}
function createNotebookExecutionTrustState(filePath: string): NotebookExecutionTrustState {
return {
filePath,
trustedForFile: false,
pendingRunCellIndex: null
}
}
function valueToText(value: unknown): string {
if (Array.isArray(value)) {
return value.map((item) => String(item ?? '')).join('')
}
if (typeof value === 'string') {
return value
}
if (value === undefined || value === null) {
return ''
}
return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
}
function dataUriForImage(item: IpynbOutputItem): string | null {
const value = valueToText(item.value).replace(/\s/g, '')
if (!value) {
return null
}
if (item.mime === 'image/svg+xml') {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(valueToText(item.value))}`
}
return `data:${item.mime};base64,${value}`
}
function NotebookCellHeader({
cell,
index,
running,
canMoveUp,
canMoveDown,
onRun,
onKindChange,
onInsertAbove,
onInsertBelow,
onMoveUp,
onMoveDown,
onDelete
}: {
cell: IpynbCell
index: number
running: boolean
canMoveUp: boolean
canMoveDown: boolean
onRun: () => void
onKindChange: (kind: IpynbCellKind) => void
onInsertAbove: (kind: IpynbCellKind) => void
onInsertBelow: (kind: IpynbCellKind) => void
onMoveUp: () => void
onMoveDown: () => void
onDelete: () => void
}): React.JSX.Element {
const Icon = cell.kind === 'code' ? Play : cell.kind === 'markdown' ? FileCode2 : Braces
const executionLabel = cell.kind === 'code' ? `In [${cell.executionCount ?? ' '}]:` : cell.kind
return (
<div className="flex items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground">
<Icon className="size-3.5" />
<span className="font-mono">{executionLabel}</span>
<select
value={cell.kind}
onChange={(event) => onKindChange(event.target.value as IpynbCellKind)}
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground"
>
<option value="code">
{translate('auto.components.editor.IpynbViewer.7005960d73', 'Code')}
</option>
<option value="markdown">
{translate('auto.components.editor.IpynbViewer.1833dbbc43', 'Markdown')}
</option>
<option value="raw">
{translate('auto.components.editor.IpynbViewer.3e4cbf15ea', 'Raw')}
</option>
</select>
{cell.kind === 'code' ? (
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.859bf9fc21', 'Run cell')}
disabled={running}
onClick={onRun}
>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
</NotebookHeaderButton>
) : null}
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.fd8ac707bc', 'Move cell up')}
disabled={!canMoveUp}
onClick={onMoveUp}
>
<MoveUp className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.27e064e2db', 'Move cell down')}
disabled={!canMoveDown}
onClick={onMoveDown}
>
<MoveDown className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.53b839b8a0', 'Insert code cell above')}
onClick={() => onInsertAbove('code')}
>
<ArrowUpToLine className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.b4208cad7e', 'Insert code cell below')}
onClick={() => onInsertBelow('code')}
>
<ArrowDownToLine className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate(
'auto.components.editor.IpynbViewer.ffc1ac2699',
'Insert markdown cell above'
)}
onClick={() => onInsertAbove('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveUp className="absolute -right-0.5 -top-0.5 size-2.5" />
</span>
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate(
'auto.components.editor.IpynbViewer.b42f6a9547',
'Insert markdown cell below'
)}
onClick={() => onInsertBelow('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveDown className="absolute -bottom-0.5 -right-0.5 size-2.5" />
</span>
</NotebookHeaderButton>
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.781abd6926', 'Delete cell')}
onClick={onDelete}
>
<Trash2 className="size-3.5" />
</NotebookHeaderButton>
<span className="ml-auto font-mono">#{index + 1}</span>
</div>
)
}
function NotebookHeaderButton({
label,
disabled = false,
shortcut,
onClick,
children
}: {
label: string
disabled?: boolean
shortcut?: ShortcutKeyComboDetails
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<span className="flex items-center gap-2">
<span>{label}</span>
{shortcut && shortcut.keys.length > 0 ? (
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
) : null}
</span>
</TooltipContent>
</Tooltip>
)
}
function MarkdownCell({ source }: { source: string }): React.JSX.Element {
return (
<div className="markdown-preview-body px-4 py-3 text-sm">
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{source || '\u00a0'}
</Markdown>
</div>
)
}
function CodeCell({
cell,
source,
active,
onActivate,
onDeactivate,
onChange,
onSaveRequest
}: {
cell: IpynbCell
source: string
active: boolean
onActivate: () => void
onDeactivate: () => void
onChange: (source: string) => void
onSaveRequest: () => Promise<void>
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const onDeactivateRef = useRef(onDeactivate)
const onSaveRequestRef = useRef(onSaveRequest)
// Why: Monaco commands/listeners are installed once on mount and need the
// latest callbacks without rebuilding the embedded editor.
onDeactivateRef.current = onDeactivate
onSaveRequestRef.current = onSaveRequest
const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
const editorHeight = getIpynbCodeCellEditorHeight(source, fontSize)
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
const lines = useMemo(() => getIpynbCodeCellPreviewLines(source), [source])
const handleMount: OnMount = useCallback((editorInstance, monacoInstance) => {
editorInstance.focus()
const cleanupSaveShortcut = installEditorSaveShortcut(
editorInstance.getContainerDomNode(),
() => {
void onSaveRequestRef.current()
}
)
const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance)
const blurSub = editorInstance.onDidBlurEditorWidget(() => {
onDeactivateRef.current()
})
editorInstance.onDidDispose(() => {
// Why: the inline source editor owns its shortcut bridges and blur
// subscription for the lifetime of this Monaco editor instance.
cleanupSaveShortcut()
cleanupFindShortcut()
blurSub.dispose()
})
editorInstance.addCommand(monacoInstance.KeyCode.Escape, () => {
onDeactivateRef.current()
})
}, [])
useEffect(() => {
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs')
}, [isDark])
if (!active) {
return (
<div
role="button"
tabIndex={0}
className="block w-full cursor-text bg-editor-surface text-left"
onClick={onActivate}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onActivate()
}
}}
>
<MonacoCodeExcerpt
lines={lines}
firstLineNumber={1}
highlightedStartLine={-1}
highlightedEndLine={-1}
language={cell.language}
/>
</div>
)
}
return (
<div className="bg-editor-surface focus-within:ring-1 focus-within:ring-ring">
<Editor
height={editorHeight}
defaultLanguage={cell.language}
language={cell.language}
theme={isDark ? 'vs-dark' : 'vs'}
value={source}
onMount={handleMount}
onChange={(value) => onChange(value ?? '')}
options={{
automaticLayout: true,
fontFamily: resolveEditorFontFamily(settings),
fontSize,
glyphMargin: false,
lineNumbersMinChars: 3,
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollBeyondLastLine: false,
wordWrap: 'off'
}}
/>
</div>
)
}
const MemoizedCodeCell = React.memo(CodeCell)
function getCellKey(cell: IpynbCell, index: number): string {
return cell.id ?? `${index}:${cell.kind}`
}
function hasOwnDraft(drafts: Record<string, string>, key: string): boolean {
return Object.hasOwn(drafts, key)
}
function EditableTextCell({
source,
onChange
}: {
source: string
onChange: (source: string) => void
}): React.JSX.Element {
return (
<textarea
value={source}
onChange={(event) => onChange(event.target.value)}
className="block min-h-24 w-full resize-y border-0 bg-background px-4 py-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
)
}
function PreformattedOutput({
text,
error = false
}: {
text: string
error?: boolean
}): React.JSX.Element {
return (
<pre
className={cn(
'max-h-[420px] overflow-auto whitespace-pre-wrap px-3 py-2 font-mono text-xs leading-5 scrollbar-editor',
error ? 'text-destructive' : 'text-foreground'
)}
>
{text}
</pre>
)
}
function OutputItem({ item }: { item: IpynbOutputItem }): React.JSX.Element | null {
if (item.mime === 'text/html') {
const html = DOMPurify.sanitize(valueToText(item.value), {
USE_PROFILES: { html: true, svg: true, svgFilters: true }
})
return (
<iframe
title={translate('auto.components.editor.IpynbViewer.66a3f7d330', 'Notebook HTML output')}
sandbox=""
referrerPolicy="no-referrer"
loading="lazy"
className="block h-80 w-full border-0 bg-background"
srcDoc={html}
/>
)
}
if (item.mime.startsWith('image/')) {
const uri = dataUriForImage(item)
if (!uri) {
return null
}
return (
<div className="flex max-w-full overflow-auto p-3 scrollbar-editor">
<img src={uri} alt={item.mime} className="max-h-[520px] max-w-full object-contain" />
</div>
)
}
if (item.mime === 'application/json' || item.mime.endsWith('+json')) {
const text =
typeof item.value === 'string' ? item.value : JSON.stringify(item.value ?? null, null, 2)
return <PreformattedOutput text={text} />
}
if (item.mime === 'text/markdown') {
return <MarkdownCell source={valueToText(item.value)} />
}
if (item.mime.startsWith('text/') || item.mime === 'application/javascript') {
return <PreformattedOutput text={valueToText(item.value)} />
}
return null
}
function CellOutputs({ cell }: { cell: IpynbCell }): React.JSX.Element | null {
if (cell.outputs.length === 0) {
return null
}
return (
<div className="border-t border-border/50 bg-background">
{cell.outputs.map((output, index) => {
if (output.kind === 'stream') {
return <PreformattedOutput key={index} text={output.text} />
}
if (output.kind === 'error') {
return (
<div key={index} className="border-l-2 border-destructive">
<PreformattedOutput
error
text={[output.name, output.message, output.traceback].filter(Boolean).join('\n')}
/>
</div>
)
}
const renderedItems = output.items
.map((item, itemIndex) => <OutputItem key={`${item.mime}-${itemIndex}`} item={item} />)
.filter(Boolean)
if (renderedItems.length === 0) {
return null
}
return (
<div key={index} className="border-b border-border/40 last:border-b-0">
{renderedItems}
</div>
)
})}
</div>
)
}
export default function IpynbViewer({
content,
fileId,
@@ -563,24 +47,9 @@ export default function IpynbViewer({
onDirtyStateHint,
onSave
}: IpynbViewerProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const [runningCellIndex, setRunningCellIndex] = useState<number | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const [editingCellKey, setEditingCellKey] = useState<string | null>(null)
const [executionTrustState, setExecutionTrustState] = useState(() =>
createNotebookExecutionTrustState(filePath)
)
const [sourceDrafts, setSourceDrafts] = useState<Record<string, string>>({})
const sourceDraftsRef = useRef(sourceDrafts)
const contentRef = useRef(content)
const notebookRef = useRef<ReturnType<typeof parseIpynb> | null>(null)
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const sourceCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const structuralContentFrameIdsRef = useRef<number[]>([])
const fontSize = computeEditorFontSize(13, editorFontZoomLevel)
const parsed = useMemo(() => {
try {
return { notebook: parseIpynb(content), error: null as string | null }
@@ -591,151 +60,41 @@ export default function IpynbViewer({
}
}
}, [content])
contentRef.current = content
notebookRef.current = parsed.notebook
onContentChangeRef.current = onContentChange
onDirtyStateHintRef.current = onDirtyStateHint
// Why: execution trust belongs to the currently rendered file; resetting
// during render avoids a paint with the previous file's trust prompt state.
if (executionTrustState.filePath !== filePath) {
setExecutionTrustState(createNotebookExecutionTrustState(filePath))
}
const executionTrustedForFile =
executionTrustState.filePath === filePath ? executionTrustState.trustedForFile : false
const pendingRunCellIndex =
executionTrustState.filePath === filePath ? executionTrustState.pendingRunCellIndex : null
const setPendingRunCellIndexForFile = (nextPendingRunCellIndex: number | null): void => {
setExecutionTrustState((current) => ({
filePath,
trustedForFile: current.filePath === filePath ? current.trustedForFile : false,
pendingRunCellIndex: nextPendingRunCellIndex
}))
}
const trustFileForExecution = (): void => {
setExecutionTrustState({
filePath,
trustedForFile: true,
pendingRunCellIndex: null
})
}
const materializeSourceDrafts = useCallback((): string => {
const notebook = notebookRef.current
const drafts = sourceDraftsRef.current
if (!notebook || Object.keys(drafts).length === 0) {
return contentRef.current
}
const updates = notebook.cells
.map((cell, index) => {
const key = getCellKey(cell, index)
return hasOwnDraft(drafts, key) ? { index, source: drafts[key] ?? '' } : null
})
.filter((update): update is { index: number; source: string } => update !== null)
return updateIpynbCellSources(contentRef.current, updates)
}, [])
const flushSourceDrafts = useCallback((): string => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
sourceCommitTimerRef.current = null
}
const nextContent = materializeSourceDrafts()
if (nextContent !== contentRef.current) {
contentRef.current = nextContent
onContentChangeRef.current(nextContent)
}
return nextContent
}, [materializeSourceDrafts])
const queueSourceDraftCommit = useCallback((): void => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
}
sourceCommitTimerRef.current = setTimeout(() => {
void flushSourceDrafts()
}, NOTEBOOK_SOURCE_COMMIT_DELAY_MS)
}, [flushSourceDrafts])
useEffect(() => {
return registerPendingEditorFlush(fileId, flushSourceDrafts)
}, [fileId, flushSourceDrafts])
const setRootRef = useCallback(
(node: HTMLDivElement | null): void => {
rootRef.current = node
if (node !== null) {
return
}
// Why: pending source edits and structural mutation frames belong to the
// notebook scroll root; clear them when that DOM owner detaches.
void flushSourceDrafts()
cancelIpynbStructuralContentFrames(structuralContentFrameIdsRef)
},
[flushSourceDrafts]
)
useEffect(() => {
if (!parsed.notebook || Object.keys(sourceDraftsRef.current).length === 0) {
return
}
const nextDrafts = { ...sourceDraftsRef.current }
let changed = false
parsed.notebook.cells.forEach((cell, index) => {
const key = getCellKey(cell, index)
if (hasOwnDraft(nextDrafts, key) && nextDrafts[key] === cell.source) {
delete nextDrafts[key]
changed = true
}
})
if (changed) {
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
}
}, [parsed.notebook])
useLayoutEffect(() => {
const container = rootRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
throttleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
throttleTimer = null
}, 150)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => {
if (container.scrollHeight > container.clientHeight || container.scrollTop > 0) {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
}
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
container.removeEventListener('scroll', onScroll)
}
}, [scrollCacheKey])
useLayoutEffect(() => {
const container = rootRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (!container || targetScrollTop === undefined) {
return
}
container.scrollTop = targetScrollTop
}, [scrollCacheKey, content])
const deactivateEditor = useCallback((): void => setEditingCellKey(null), [])
const {
rootRef,
setRootRef,
sourceDrafts,
flushSourceDrafts,
applyContent,
updateCellSource,
updateCellKind,
insertCell,
moveCell,
deleteCell
} = useIpynbDocumentEditing({
content,
fileId,
notebook: parsed.notebook,
onContentChange,
onDirtyStateHint,
onDeactivateEditor: deactivateEditor
})
const execution = useIpynbCellExecution({
filePath,
worktreeId,
flushSourceDrafts,
applyContent,
onSave
})
useIpynbScrollRestoration(rootRef, scrollCacheKey, content)
const saveShortcut = useShortcutKeyDetails('editor.save')
const fontSize = computeEditorFontSize(13, editorFontZoomLevel)
const saveNotebook = useCallback(async (): Promise<void> => {
const latestContent = flushSourceDrafts()
await onSave(latestContent)
}, [flushSourceDrafts, onSave])
const saveShortcut = useShortcutKeyDetails('editor.save')
const handleNotebookKeyDownCapture = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
@@ -755,10 +114,9 @@ export default function IpynbViewer({
return
}
const target = event.target instanceof Element ? event.target : null
if (target?.closest('.monaco-editor')) {
return
if (!target?.closest('.monaco-editor')) {
setEditingCellKey(null)
}
setEditingCellKey(null)
},
[editingCellKey]
)
@@ -783,97 +141,6 @@ export default function IpynbViewer({
}
const { notebook } = parsed
const applyContent = (nextContent: string): void => {
contentRef.current = nextContent
onContentChange(nextContent)
}
const updateCellSource = (index: number, source: string): void => {
const cell = notebook.cells[index]
if (!cell) {
return
}
const key = getCellKey(cell, index)
const nextDrafts = { ...sourceDraftsRef.current, [key]: source }
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
onDirtyStateHintRef.current(true)
queueSourceDraftCommit()
}
const applyStructuralContentChange = (
getNextContent: (latestContent: string) => string
): void => {
const latestContent = flushSourceDrafts()
// Why: Monaco can still have a render frame queued for the active cell.
// Exit edit mode first, then reorder/replace cells on the next frame so
// structural notebook actions do not dispose an editor mid-render.
setEditingCellKey(null)
requestIpynbStructuralContentFrame(structuralContentFrameIdsRef, () => {
applyContent(getNextContent(latestContent))
})
}
const updateCellKind = (index: number, kind: IpynbCellKind): void => {
applyStructuralContentChange((latestContent) =>
updateIpynbCellKind(latestContent, index, kind, notebook.language)
)
}
const insertCell = (index: number, kind: IpynbCellKind): void => {
applyStructuralContentChange((latestContent) =>
insertIpynbCell(latestContent, index, kind, notebook.language)
)
}
const moveCell = (index: number, direction: -1 | 1): void => {
applyStructuralContentChange((latestContent) => moveIpynbCell(latestContent, index, direction))
}
const deleteCell = (index: number): void => {
applyStructuralContentChange((latestContent) => deleteIpynbCell(latestContent, index))
}
const runCell = async (
index: number,
options: { skipTrustPrompt?: boolean } = {}
): Promise<void> => {
const latestContent = flushSourceDrafts()
const latestNotebook = parseIpynb(latestContent)
const cell = latestNotebook.cells[index]
if (!cell || cell.kind !== 'code' || runningCellIndex !== null) {
return
}
if (!executionTrustedForFile && !options.skipTrustPrompt) {
setPendingRunCellIndexForFile(index)
return
}
setRunError(null)
setRunningCellIndex(index)
try {
const didSave = await onSave(latestContent)
if (!didSave) {
return
}
const result = await window.api.notebook.runPythonCell({
filePath,
code: cell.source,
preamble: latestNotebook.cells
.slice(0, index)
.filter((previousCell) => previousCell.kind === 'code')
.map((previousCell) => previousCell.source)
.join('\n\n'),
connectionId: getConnectionId(worktreeId) ?? undefined
})
applyContent(updateIpynbCellOutputs(latestContent, index, result))
} catch (error) {
setRunError(error instanceof Error ? error.message : String(error))
} finally {
setRunningCellIndex(null)
}
}
const cancelPendingRun = (): void => setPendingRunCellIndexForFile(null)
const confirmPendingRun = (): void => {
const index = pendingRunCellIndex
trustFileForExecution()
if (index !== null) {
void runCell(index, { skipTrustPrompt: true })
}
}
return (
<div
ref={setRootRef}
@@ -890,15 +157,15 @@ export default function IpynbViewer({
</span>
<span>{notebook.language}</span>
{notebook.kernelName ? <span>{notebook.kernelName}</span> : null}
{runError ? <span className="text-destructive">{runError}</span> : null}
{execution.runError ? <span className="text-destructive">{execution.runError}</span> : null}
<div className="ml-auto flex items-center gap-2">
<NotebookHeaderButton
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.15ec40a735', 'Save notebook')}
shortcut={saveShortcut}
onClick={() => void saveNotebook()}
>
<Save className="size-3.5" />
</NotebookHeaderButton>
</IpynbToolbarButton>
<span className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-medium text-muted-foreground">
{translate('auto.components.editor.IpynbViewer.329764e9fc', 'BETA')}
</span>
@@ -915,8 +182,8 @@ export default function IpynbViewer({
</div>
) : (
notebook.cells.map((cell, index) => {
const cellKey = getCellKey(cell, index)
const source = hasOwnDraft(sourceDrafts, cellKey)
const cellKey = getIpynbCellKey(cell, index)
const source = hasIpynbSourceDraft(sourceDrafts, cellKey)
? (sourceDrafts[cellKey] ?? '')
: cell.source
return (
@@ -924,13 +191,13 @@ export default function IpynbViewer({
key={cellKey}
className="overflow-hidden rounded-md border border-border bg-background"
>
<NotebookCellHeader
<IpynbCellToolbar
cell={cell}
index={index}
running={runningCellIndex === index}
running={execution.runningCellIndex === index}
canMoveUp={index > 0}
canMoveDown={index < notebook.cells.length - 1}
onRun={() => void runCell(index)}
onRun={() => void execution.runCell(index)}
onKindChange={(kind) => updateCellKind(index, kind)}
onInsertAbove={(kind) => insertCell(index, kind)}
onInsertBelow={(kind) => insertCell(index + 1, kind)}
@@ -940,16 +207,16 @@ export default function IpynbViewer({
/>
{cell.kind === 'markdown' ? (
<div className="grid gap-0 lg:grid-cols-2">
<EditableTextCell
<IpynbEditableTextCell
source={source}
onChange={(nextSource) => updateCellSource(index, nextSource)}
/>
<div className="border-t border-border/50 lg:border-l lg:border-t-0">
<MarkdownCell source={source} />
<IpynbMarkdownCell source={source} />
</div>
</div>
) : cell.kind === 'code' ? (
<MemoizedCodeCell
<IpynbCodeCell
cell={cell}
source={source}
active={editingCellKey === cellKey}
@@ -961,22 +228,22 @@ export default function IpynbViewer({
onSaveRequest={saveNotebook}
/>
) : (
<EditableTextCell
<IpynbEditableTextCell
source={source}
onChange={(nextSource) => updateCellSource(index, nextSource)}
/>
)}
<CellOutputs cell={cell} />
<IpynbCellOutputs cell={cell} />
</section>
)
})
)}
</div>
<Dialog
open={pendingRunCellIndex !== null}
open={execution.pendingRunCellIndex !== null}
onOpenChange={(open) => {
if (!open) {
cancelPendingRun()
execution.cancelPendingRun()
}
}}
>
@@ -993,10 +260,10 @@ export default function IpynbViewer({
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelPendingRun}>
<Button type="button" variant="outline" size="sm" onClick={execution.cancelPendingRun}>
{translate('auto.components.editor.IpynbViewer.7f0d7077c6', 'Cancel')}
</Button>
<Button type="button" size="sm" autoFocus onClick={confirmPendingRun}>
<Button type="button" size="sm" autoFocus onClick={execution.confirmPendingRun}>
{translate('auto.components.editor.IpynbViewer.859bf9fc21', 'Run cell')}
</Button>
</DialogFooter>
@@ -0,0 +1,16 @@
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
// Why: one lazy() identity per viewer, shared by every editor surface — a second lazy() over the
// same module is a distinct component type, so it gets its own Suspense boundary and remount.
export const MonacoEditor = lazy(() => import('./MonacoEditor'))
export const DiffViewer = lazy(() => import('./DiffViewer'))
export const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
export const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), {
reloadKey: 'rich-markdown-editor'
})
export const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
export const ImageViewer = lazy(() => import('./ImageViewer'))
export const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
export const MermaidViewer = lazy(() => import('./MermaidViewer'))
export const CsvViewer = lazy(() => import('./CsvViewer'))
export const IpynbViewer = lazy(() => import('./IpynbViewer'))
@@ -0,0 +1,61 @@
import { useCallback, useState } from 'react'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { getNextConflictNavigationIndex } from './ConflictComponents'
import {
findGitConflictBlocks,
getGitConflictMarkerLineLength
} from './monaco-conflict-decorations'
export type EditorConflictNavigation = {
currentIndex: number | null
total: number
onJump: (direction: 'previous' | 'next') => void
}
export function useEditorConflictNavigation(): (
file: OpenFile,
content: string
) => EditorConflictNavigation | undefined {
const setPendingEditorReveal = useAppStore((state) => state.setPendingEditorReveal)
const [navigationIndexByFile, setNavigationIndexByFile] = useState<Record<string, number>>({})
return useCallback(
(file: OpenFile, content: string) => {
const blocks = findGitConflictBlocks(content)
if (blocks.length === 0) {
return undefined
}
const currentIndex = navigationIndexByFile[file.id] ?? null
return {
currentIndex,
total: blocks.length,
onJump: (direction: 'previous' | 'next') => {
const nextIndex = getNextConflictNavigationIndex({
currentIndex,
direction,
total: blocks.length
})
if (nextIndex === null) {
return
}
const line = blocks[nextIndex].startLine
const markerLineLength = getGitConflictMarkerLineLength(content, line)
setNavigationIndexByFile((previous) => ({ ...previous, [file.id]: nextIndex }))
// Why: clear first so a repeated same-location reveal still changes the prop and re-runs the editor's reveal effect.
setPendingEditorReveal(null)
queueMicrotask(() => {
setPendingEditorReveal({
filePath: file.filePath,
line,
column: 1,
matchLength: markerLineLength
})
})
}
}
},
[navigationIndexByFile, setPendingEditorReveal]
)
}
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseIpynb } from './ipynb-parse'
const { getConnectionIdMock } = vi.hoisted(() => ({
getConnectionIdMock: vi.fn(() => 'ssh-connection')
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: getConnectionIdMock
}))
import { useIpynbCellExecution } from './useIpynbCellExecution'
function notebookContent(): string {
return JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: {},
cells: [
{
id: 'setup',
cell_type: 'code',
metadata: {},
execution_count: null,
outputs: [],
source: ['x = 41']
},
{
id: 'run',
cell_type: 'code',
metadata: {},
execution_count: null,
outputs: [],
source: ['print(x + 1)']
}
]
})
}
describe('notebook cell execution lifecycle', () => {
const runPythonCell = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
runPythonCell.mockResolvedValue({ stdout: '42\n', stderr: '', exitCode: 0 })
Object.defineProperty(window, 'api', {
configurable: true,
value: { notebook: { runPythonCell } }
})
})
afterEach(() => {
Reflect.deleteProperty(window, 'api')
})
it('requires file trust, saves first, and routes execution through the connection', async () => {
const content = notebookContent()
const onSave = vi.fn().mockResolvedValue(true)
const applyContent = vi.fn()
const { result } = renderHook(() =>
useIpynbCellExecution({
filePath: '/repo/notebook.ipynb',
worktreeId: 'worktree-a',
flushSourceDrafts: () => content,
applyContent,
onSave
})
)
await act(() => result.current.runCell(1))
expect(result.current.pendingRunCellIndex).toBe(1)
expect(onSave).not.toHaveBeenCalled()
expect(runPythonCell).not.toHaveBeenCalled()
act(() => result.current.confirmPendingRun())
await waitFor(() => expect(runPythonCell).toHaveBeenCalledOnce())
expect(onSave).toHaveBeenCalledWith(content)
expect(runPythonCell).toHaveBeenCalledWith({
filePath: '/repo/notebook.ipynb',
code: 'print(x + 1)',
preamble: 'x = 41',
connectionId: 'ssh-connection'
})
await waitFor(() => expect(applyContent).toHaveBeenCalledOnce())
expect(
parseIpynb(applyContent.mock.calls[0]?.[0] as string).cells[1]?.outputs[0]
).toMatchObject({
kind: 'stream',
text: '42\n'
})
})
it('drops stale trust prompts across file moves and skips execution after a failed save', async () => {
const content = notebookContent()
const onSave = vi.fn().mockResolvedValue(false)
const hook = renderHook(
({ filePath }: { filePath: string }) =>
useIpynbCellExecution({
filePath,
worktreeId: 'worktree-a',
flushSourceDrafts: () => content,
applyContent: vi.fn(),
onSave
}),
{ initialProps: { filePath: '/repo/a.ipynb' } }
)
await act(() => hook.result.current.runCell(1))
expect(hook.result.current.pendingRunCellIndex).toBe(1)
hook.rerender({ filePath: '/repo/b.ipynb' })
expect(hook.result.current.pendingRunCellIndex).toBeNull()
hook.rerender({ filePath: '/repo/a.ipynb' })
expect(hook.result.current.pendingRunCellIndex).toBeNull()
await act(() => hook.result.current.runCell(1, { skipTrustPrompt: true }))
expect(onSave).toHaveBeenCalledWith(content)
expect(runPythonCell).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,105 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { updateIpynbCellOutputs } from './ipynb-cell-mutations'
import { parseIpynb } from './ipynb-parse'
type FileExecutionTrust = {
filePath: string
revision: number
trusted: boolean
}
type PendingCellRun = {
fileRevision: number
cellIndex: number
}
type UseIpynbCellExecutionArgs = {
filePath: string
worktreeId: string
flushSourceDrafts: () => string
applyContent: (content: string) => void
onSave: (content: string) => Promise<boolean>
}
export function useIpynbCellExecution({
filePath,
worktreeId,
flushSourceDrafts,
applyContent,
onSave
}: UseIpynbCellExecutionArgs) {
const trustRef = useRef<FileExecutionTrust>({ filePath, revision: 0, trusted: false })
const [pendingRun, setPendingRun] = useState<PendingCellRun | null>(null)
const [runningCellIndex, setRunningCellIndex] = useState<number | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const fileRevision =
trustRef.current.filePath === filePath
? trustRef.current.revision
: trustRef.current.revision + 1
useLayoutEffect(() => {
if (trustRef.current.filePath !== filePath) {
trustRef.current = { filePath, revision: fileRevision, trusted: false }
}
}, [filePath, fileRevision])
const pendingRunCellIndex =
pendingRun?.fileRevision === fileRevision ? pendingRun.cellIndex : null
const runCell = async (
index: number,
options: { skipTrustPrompt?: boolean } = {}
): Promise<void> => {
const latestContent = flushSourceDrafts()
const latestNotebook = parseIpynb(latestContent)
const cell = latestNotebook.cells[index]
if (!cell || cell.kind !== 'code' || runningCellIndex !== null) {
return
}
if (!trustRef.current.trusted && !options.skipTrustPrompt) {
setPendingRun({ fileRevision, cellIndex: index })
return
}
setRunError(null)
setRunningCellIndex(index)
try {
const didSave = await onSave(latestContent)
if (!didSave) {
return
}
const result = await window.api.notebook.runPythonCell({
filePath,
code: cell.source,
preamble: latestNotebook.cells
.slice(0, index)
.filter((previousCell) => previousCell.kind === 'code')
.map((previousCell) => previousCell.source)
.join('\n\n'),
connectionId: getConnectionId(worktreeId) ?? undefined
})
applyContent(updateIpynbCellOutputs(latestContent, index, result))
} catch (error) {
setRunError(error instanceof Error ? error.message : String(error))
} finally {
setRunningCellIndex(null)
}
}
const cancelPendingRun = (): void => setPendingRun(null)
const confirmPendingRun = (): void => {
const index = pendingRunCellIndex
trustRef.current.trusted = true
setPendingRun(null)
if (index !== null) {
void runCell(index, { skipTrustPrompt: true })
}
}
return {
runningCellIndex,
runError,
pendingRunCellIndex,
runCell,
cancelPendingRun,
confirmPendingRun
}
}
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPendingEditorChange } from './editor-pending-flush'
import { parseIpynb } from './ipynb-parse'
import { useIpynbDocumentEditing } from './useIpynbDocumentEditing'
function notebookContent(firstSource = 'a', secondSource = 'b'): string {
return JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: { language_info: { name: 'python' } },
cells: [
{
id: 'a',
cell_type: 'code',
metadata: {},
execution_count: null,
outputs: [],
source: [firstSource]
},
{
id: 'b',
cell_type: 'code',
metadata: {},
execution_count: null,
outputs: [],
source: [secondSource]
}
]
})
}
describe('notebook document editing lifecycle', () => {
const animationFrames = new Map<number, FrameRequestCallback>()
let nextFrameId = 1
beforeEach(() => {
vi.useFakeTimers()
animationFrames.clear()
nextFrameId = 1
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId
nextFrameId += 1
animationFrames.set(frameId, callback)
return frameId
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((frameId: number) => {
animationFrames.delete(frameId)
})
)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('debounces drafts, flushes the latest source, and releases acknowledged drafts', () => {
const onContentChange = vi.fn()
const onDirtyStateHint = vi.fn()
const onDeactivateEditor = vi.fn()
const initialContent = notebookContent()
const hook = renderHook(
({ content }: { content: string }) =>
useIpynbDocumentEditing({
content,
fileId: 'notebook-a',
notebook: parseIpynb(content),
onContentChange,
onDirtyStateHint,
onDeactivateEditor
}),
{ initialProps: { content: initialContent } }
)
act(() => {
hook.result.current.updateCellSource(0, 'first draft')
hook.result.current.updateCellSource(0, 'latest draft')
vi.advanceTimersByTime(399)
})
expect(onDirtyStateHint).toHaveBeenCalledTimes(2)
expect(onContentChange).not.toHaveBeenCalled()
act(() => flushPendingEditorChange('notebook-a'))
expect(onContentChange).toHaveBeenCalledTimes(1)
const committedContent = onContentChange.mock.calls[0]?.[0] as string
expect(parseIpynb(committedContent).cells[0]?.source).toBe('latest draft')
hook.rerender({ content: committedContent })
expect(Object.hasOwn(hook.result.current.sourceDrafts, 'a')).toBe(false)
const externalContent = notebookContent('external source')
hook.rerender({ content: externalContent })
expect(Object.hasOwn(hook.result.current.sourceDrafts, 'a')).toBe(false)
})
it('flushes drafts before structural work and cancels queued work on detach', () => {
const onContentChange = vi.fn()
const onDeactivateEditor = vi.fn()
const content = notebookContent()
const { result } = renderHook(() =>
useIpynbDocumentEditing({
content,
fileId: 'notebook-a',
notebook: parseIpynb(content),
onContentChange,
onDirtyStateHint: vi.fn(),
onDeactivateEditor
})
)
act(() => {
result.current.updateCellSource(0, 'edited before move')
result.current.moveCell(0, 1)
})
expect(onDeactivateEditor).toHaveBeenCalledOnce()
expect(onContentChange).toHaveBeenCalledTimes(1)
expect(parseIpynb(onContentChange.mock.calls[0]?.[0] as string).cells[0]?.source).toBe(
'edited before move'
)
expect(animationFrames.size).toBe(1)
const [[frameId, frameCallback]] = [...animationFrames.entries()]
animationFrames.delete(frameId)
act(() => frameCallback(0))
const movedNotebook = parseIpynb(onContentChange.mock.calls[1]?.[0] as string)
expect(movedNotebook.cells.map((cell) => cell.id)).toEqual(['b', 'a'])
expect(movedNotebook.cells[1]?.source).toBe('edited before move')
act(() => result.current.deleteCell(0))
expect(animationFrames.size).toBe(1)
act(() => result.current.setRootRef(null))
expect(cancelAnimationFrame).toHaveBeenCalled()
expect(animationFrames.size).toBe(0)
})
})
@@ -0,0 +1,221 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type MutableRefObject
} from 'react'
import { registerPendingEditorFlush } from './editor-pending-flush'
import {
deleteIpynbCell,
insertIpynbCell,
moveIpynbCell,
updateIpynbCellKind,
updateIpynbCellSources
} from './ipynb-cell-mutations'
import type { IpynbCell, IpynbCellKind, ParsedIpynb } from './ipynb-parse'
const NOTEBOOK_SOURCE_COMMIT_DELAY_MS = 400
export function getIpynbCellKey(cell: IpynbCell, index: number): string {
return cell.id ?? `${index}:${cell.kind}`
}
export function hasIpynbSourceDraft(drafts: Record<string, string>, key: string): boolean {
return Object.hasOwn(drafts, key)
}
function cancelStructuralFrames(frameIds: MutableRefObject<number[]>): void {
for (const frameId of frameIds.current) {
cancelAnimationFrame(frameId)
}
frameIds.current = []
}
function requestStructuralFrame(
frameIds: MutableRefObject<number[]>,
callback: FrameRequestCallback
): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
frameIds.current = frameIds.current.filter((pendingFrameId) => pendingFrameId !== frameId)
}
callback(timestamp)
})
if (!completed) {
frameIds.current.push(frameId)
}
}
type UseIpynbDocumentEditingArgs = {
content: string
fileId: string
notebook: ParsedIpynb | null
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onDeactivateEditor: () => void
}
export function useIpynbDocumentEditing({
content,
fileId,
notebook,
onContentChange,
onDirtyStateHint,
onDeactivateEditor
}: UseIpynbDocumentEditingArgs) {
const rootRef = useRef<HTMLDivElement>(null)
const [sourceDrafts, setSourceDrafts] = useState<Record<string, string>>({})
const sourceDraftsRef = useRef(sourceDrafts)
const contentRef = useRef(content)
const notebookRef = useRef(notebook)
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const sourceCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const structuralFrameIdsRef = useRef<number[]>([])
useLayoutEffect(() => {
contentRef.current = content
notebookRef.current = notebook
onContentChangeRef.current = onContentChange
onDirtyStateHintRef.current = onDirtyStateHint
}, [content, notebook, onContentChange, onDirtyStateHint])
const materializeSourceDrafts = useCallback((): string => {
const latestNotebook = notebookRef.current
const drafts = sourceDraftsRef.current
if (!latestNotebook || Object.keys(drafts).length === 0) {
return contentRef.current
}
const updates = latestNotebook.cells
.map((cell, index) => {
const key = getIpynbCellKey(cell, index)
return hasIpynbSourceDraft(drafts, key) ? { index, source: drafts[key] ?? '' } : null
})
.filter((update): update is { index: number; source: string } => update !== null)
return updateIpynbCellSources(contentRef.current, updates)
}, [])
const flushSourceDrafts = useCallback((): string => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
sourceCommitTimerRef.current = null
}
const nextContent = materializeSourceDrafts()
if (nextContent !== contentRef.current) {
contentRef.current = nextContent
onContentChangeRef.current(nextContent)
}
return nextContent
}, [materializeSourceDrafts])
const queueSourceDraftCommit = useCallback((): void => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
}
sourceCommitTimerRef.current = setTimeout(() => {
void flushSourceDrafts()
}, NOTEBOOK_SOURCE_COMMIT_DELAY_MS)
}, [flushSourceDrafts])
useEffect(
() => registerPendingEditorFlush(fileId, flushSourceDrafts),
[fileId, flushSourceDrafts]
)
useEffect(() => {
if (!notebook || Object.keys(sourceDraftsRef.current).length === 0) {
return
}
const nextDrafts = { ...sourceDraftsRef.current }
let changed = false
for (const [index, cell] of notebook.cells.entries()) {
const key = getIpynbCellKey(cell, index)
if (hasIpynbSourceDraft(nextDrafts, key) && nextDrafts[key] === cell.source) {
delete nextDrafts[key]
changed = true
}
}
if (changed) {
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
}
}, [notebook])
const setRootRef = useCallback(
(node: HTMLDivElement | null): void => {
rootRef.current = node
if (node !== null) {
return
}
void flushSourceDrafts()
cancelStructuralFrames(structuralFrameIdsRef)
},
[flushSourceDrafts]
)
const applyContent = useCallback((nextContent: string): void => {
contentRef.current = nextContent
onContentChangeRef.current(nextContent)
}, [])
const updateCellSource = useCallback(
(index: number, source: string): void => {
const cell = notebookRef.current?.cells[index]
if (!cell) {
return
}
const key = getIpynbCellKey(cell, index)
const nextDrafts = { ...sourceDraftsRef.current, [key]: source }
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
onDirtyStateHintRef.current(true)
queueSourceDraftCommit()
},
[queueSourceDraftCommit]
)
const applyStructuralChange = useCallback(
(getNextContent: (latestContent: string) => string): void => {
const latestContent = flushSourceDrafts()
onDeactivateEditor()
requestStructuralFrame(structuralFrameIdsRef, () => {
applyContent(getNextContent(latestContent))
})
},
[applyContent, flushSourceDrafts, onDeactivateEditor]
)
const updateCellKind = (index: number, kind: IpynbCellKind): void => {
const language = notebookRef.current?.language ?? 'python'
applyStructuralChange((latestContent) =>
updateIpynbCellKind(latestContent, index, kind, language)
)
}
const insertCell = (index: number, kind: IpynbCellKind): void => {
const language = notebookRef.current?.language ?? 'python'
applyStructuralChange((latestContent) => insertIpynbCell(latestContent, index, kind, language))
}
const moveCell = (index: number, direction: -1 | 1): void => {
applyStructuralChange((latestContent) => moveIpynbCell(latestContent, index, direction))
}
const deleteCell = (index: number): void => {
applyStructuralChange((latestContent) => deleteIpynbCell(latestContent, index))
}
return {
rootRef,
setRootRef,
sourceDrafts,
flushSourceDrafts,
applyContent,
updateCellSource,
updateCellKind,
insertCell,
moveCell,
deleteCell
}
}
@@ -0,0 +1,43 @@
import { useLayoutEffect, type RefObject } from 'react'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
export function useIpynbScrollRestoration(
rootRef: RefObject<HTMLDivElement | null>,
scrollCacheKey: string,
content: string
): void {
useLayoutEffect(() => {
const container = rootRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
throttleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
throttleTimer = null
}, 150)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => {
if (container.scrollHeight > container.clientHeight || container.scrollTop > 0) {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
}
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
container.removeEventListener('scroll', onScroll)
}
}, [rootRef, scrollCacheKey])
useLayoutEffect(() => {
const container = rootRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (container && targetScrollTop !== undefined) {
container.scrollTop = targetScrollTop
}
}, [rootRef, scrollCacheKey, content])
}
@@ -0,0 +1,286 @@
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { joinPath } from '@/lib/path'
import {
getOpenFilesForExternalFileChange,
notifyEditorExternalFileChange
} from '@/components/editor/editor-autosave'
import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark'
import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature'
import {
clearSelfWrite,
getRecentSelfWrite,
type RecentSelfWrite
} from '@/components/editor/editor-self-write-registry'
import { readRuntimeFileContent } from '@/runtime/runtime-file-client'
import type { EditorExternalWatchTarget } from './editor-external-watch-targets'
export type EditorExternalWatchNotification = {
worktreeId: string
worktreePath: string
relativePath: string
runtimeEnvironmentId: string | null
allowLocalWindowsWslAliases?: true
indexedOpenFiles?: {
matches: (openFiles: OpenFile[]) => OpenFile[]
}
}
// Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per owner+path.
const EXTERNAL_RELOAD_DEBOUNCE_MS = 75
const pendingExternalReloadTimers = new Map<string, ReturnType<typeof setTimeout>>()
export function scheduleDebouncedEditorExternalReload(
notification: EditorExternalWatchNotification
): void {
const key = `${notification.worktreeId}::${notification.runtimeEnvironmentId ?? 'client'}::${notification.relativePath}`
const existing = pendingExternalReloadTimers.get(key)
if (existing !== undefined) {
globalThis.clearTimeout(existing)
}
const handle = globalThis.setTimeout(() => {
pendingExternalReloadTimers.delete(key)
notifyEditorExternalFileChange(notification)
}, EXTERNAL_RELOAD_DEBOUNCE_MS)
pendingExternalReloadTimers.set(key, handle)
}
const inFlightEchoVerificationReads = new Map<string, ReturnType<typeof readRuntimeFileContent>>()
// Why: one save echo can arrive as a burst of payloads; share the in-flight full-file read so concurrent payloads for the same file don't stack duplicate reads.
function readFileForEchoVerification(args: {
runtimeEnvironmentId: string | null | undefined
filePath: string
relativePath: string
worktreeId: string | null | undefined
connectionId: string | undefined
expectedExternalSshTargetId?: string
}): ReturnType<typeof readRuntimeFileContent> {
const key = [
args.runtimeEnvironmentId ?? '',
args.connectionId ?? '',
args.expectedExternalSshTargetId ?? '',
args.filePath
].join('::')
let pending = inFlightEchoVerificationReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
settings: args.runtimeEnvironmentId
? { activeRuntimeEnvironmentId: args.runtimeEnvironmentId }
: null,
filePath: args.filePath,
relativePath: args.relativePath,
worktreeId: args.worktreeId ?? undefined,
connectionId: args.connectionId,
expectedExternalSshTargetId: args.expectedExternalSshTargetId
})
inFlightEchoVerificationReads.set(key, pending)
const release = (): void => {
if (inFlightEchoVerificationReads.get(key) === pending) {
inFlightEchoVerificationReads.delete(key)
}
}
pending.then(release, release)
}
return pending
}
function markTabsChangedOnDisk(fileIds: string[], connectionId: string | undefined): void {
const state = useAppStore.getState()
for (const fileId of fileIds) {
const file = state.openFiles.find((candidate) => candidate.id === fileId)
// Why: echo verification resolves async — the tab may have been closed since, so only mark files still open.
if (file) {
markFileChangedOnDisk(state, file, { connectionId, origin: 'live' })
}
}
}
export function scheduleEditorChangedOnDiskMark(
target: EditorExternalWatchTarget,
notification: EditorExternalWatchNotification,
fileIds: string[]
): void {
if (fileIds.length === 0) {
return
}
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
const recentSelfWrite = getRecentSelfWrite(absolutePath, target.runtimeEnvironmentId)
// Why: the fs event may be the echo of Orca's own save — verify disk really differs from our last write before showing a "changed on disk" banner.
if (!recentSelfWrite || recentSelfWrite.content === null) {
markTabsChangedOnDisk(fileIds, target.connectionId)
return
}
void readFileForEchoVerification({
runtimeEnvironmentId: target.runtimeEnvironmentId,
filePath: absolutePath,
relativePath: notification.relativePath,
worktreeId: notification.worktreeId,
connectionId: target.connectionId
})
.then((result) => {
if (result.isBinary || result.content !== recentSelfWrite.content) {
markTabsChangedOnDisk(fileIds, target.connectionId)
}
})
.catch(() => {
// Why: unreadable disk state can't disprove an external change — keep the conflict visible rather than risk a silent overwrite.
markTabsChangedOnDisk(fileIds, target.connectionId)
})
}
// Per-file generation so a newer echo-verify read supersedes an older one — overlapping reads can't clear each other's autosave gate or apply a stale verdict.
const liveMoveVerifyGeneration = new Map<string, number>()
let liveMoveVerifyCounter = 0
type LiveMoveVerifyCandidate = {
fileId: string
baseline: string | undefined
generation: number
/** The move that installed the provenance; a newer move re-homing the tab supersedes this verification, even across a rekey. */
operationId?: string
}
// Fails closed: anything but a proven baseline match surfaces the conflict, so a real external write is never swallowed.
function resolveLiveMoveVerification(
candidate: LiveMoveVerifyCandidate,
diskSignature: string | null,
connectionId: string | undefined,
consumeProvenance: boolean
): void {
const { fileId, baseline, generation, operationId } = candidate
if (liveMoveVerifyGeneration.get(fileId) !== generation) {
return
}
liveMoveVerifyGeneration.delete(fileId)
const state = useAppStore.getState()
state.setPendingLiveDiskVerification(fileId, false)
const file = state.openFiles.find((candidateFile) => candidateFile.id === fileId)
if (
!file ||
!file.isDirty ||
file.externalMutation === 'changed' ||
file.lastKnownDiskSignature !== baseline ||
(operationId !== undefined && file.pendingSelfMoveEcho?.operationId !== operationId)
) {
return
}
// Why: proactive post-commit verification leaves provenance for a later destination watcher event; a watcher-driven check consumes it.
if (consumeProvenance) {
state.clearSelfMoveEcho(fileId)
}
const isMoveEcho = baseline !== undefined && diskSignature === baseline
if (!isMoveEcho) {
markFileChangedOnDisk(state, file, { connectionId, origin: 'live' })
}
}
/** Verifies destination echoes latched before a completed editor path rekey. */
export function verifyLatchedEditorMoveDestinations(
worktreePath: string,
connectionId: string | undefined,
fileIds: readonly string[]
): void {
const state = useAppStore.getState()
const gated = fileIds.filter(
(id) => state.openFiles.find((file) => file.id === id)?.pendingSelfMoveEcho
)
if (gated.length === 0) {
return
}
scheduleEditorSelfMoveEchoVerification(
{ worktreeId: '', worktreePath, connectionId, runtimeEnvironmentId: null },
gated,
false
)
}
// Autosave is suspended synchronously first so a write landing mid-read can't be overwritten before verification settles.
export function scheduleEditorSelfMoveEchoVerification(
target: EditorExternalWatchTarget,
fileIds: string[],
consumeProvenance: boolean
): void {
if (fileIds.length === 0) {
return
}
const state = useAppStore.getState()
for (const fileId of fileIds) {
const file = state.openFiles.find((candidate) => candidate.id === fileId)
if (!file || !file.isDirty || file.externalMutation === 'changed') {
continue
}
const generation = ++liveMoveVerifyCounter
liveMoveVerifyGeneration.set(fileId, generation)
state.setPendingLiveDiskVerification(fileId, true)
const candidate: LiveMoveVerifyCandidate = {
fileId,
baseline: file.lastKnownDiskSignature,
generation,
operationId: file.pendingSelfMoveEcho?.operationId
}
// Why: cross-worktree tabs must read their own absolute path; joining their relative path to the initiating worktree can address the wrong host path.
void readFileForEchoVerification({
runtimeEnvironmentId: file.runtimeEnvironmentId?.trim() || target.runtimeEnvironmentId,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content)
resolveLiveMoveVerification(
candidate,
diskSignature,
target.connectionId,
consumeProvenance
)
})
.catch(() =>
resolveLiveMoveVerification(candidate, null, target.connectionId, consumeProvenance)
)
}
}
export function scheduleSelfWriteAwareEditorExternalReload(
target: EditorExternalWatchTarget,
notification: EditorExternalWatchNotification,
file: OpenFile,
recentSelfWrite: RecentSelfWrite
): void {
if (recentSelfWrite.content === null) {
scheduleDebouncedEditorExternalReload(notification)
return
}
const runtimeEnvironmentId = file.runtimeEnvironmentId ?? target.runtimeEnvironmentId
// Why: a self-write stamp only proves recent change; compare disk content so it suppresses only Orca's echo, not a newer agent write in the same TTL.
void readFileForEchoVerification({
runtimeEnvironmentId,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
})
.then((result) => {
if (
(result.isBinary || result.content !== recentSelfWrite.content) &&
hasCleanExternalReloadTarget(notification)
) {
clearSelfWrite(file.filePath, runtimeEnvironmentId)
scheduleDebouncedEditorExternalReload(notification)
}
})
.catch(() => {
if (hasCleanExternalReloadTarget(notification)) {
clearSelfWrite(file.filePath, runtimeEnvironmentId)
scheduleDebouncedEditorExternalReload(notification)
}
})
}
function hasCleanExternalReloadTarget(notification: EditorExternalWatchNotification): boolean {
const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification)
return matching.some((file) => !file.isDirty)
}
@@ -0,0 +1,300 @@
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { basename } from '@/lib/path'
import {
canAutoSaveOpenFile,
isExternalReloadableEditorTab
} from '@/components/editor/editor-autosave'
import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index'
import { getRecentSelfWrite } from '@/components/editor/editor-self-write-registry'
import {
hasActiveEditorPathMoves,
isActiveMoveSourcePath
} from '@/components/editor/editor-path-move-inflight'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import type { FsChangedPayload } from '../../../shared/filesystem-entry-types'
import {
ORCA_WORKTREE_FILE_CHANGE_EVENT,
type WorktreeFileChangeEventDetail
} from './worktree-file-change-event'
import {
getLocalWindowsWslAliasOption,
getOpenFileRuntimeOwner,
type EditorExternalWatchTarget
} from './editor-external-watch-targets'
import {
scheduleDebouncedEditorExternalReload,
scheduleEditorChangedOnDiskMark,
scheduleEditorSelfMoveEchoVerification,
scheduleSelfWriteAwareEditorExternalReload,
type EditorExternalWatchNotification
} from './editor-external-watch-disk-verification'
// Why: macOS atomic writes split delete→create across payloads; debounce deletion so a same-path create cancels the tombstone before it paints.
const EXTERNAL_MUTATION_DEBOUNCE_MS = 75
type PendingDeleteTimer = {
fileId: string
timer: ReturnType<typeof setTimeout>
}
export function buildEditorExternalWatchEventHandler(
findTarget: (
worktreePath: string,
runtimeEnvironmentId: string | null
) => EditorExternalWatchTarget | undefined
): {
handleFsChanged: (payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void
dispose: () => void
} {
const pendingDeletes = new Map<string, PendingDeleteTimer>()
const pendingKey = (
worktreeId: string,
runtimeEnvironmentId: string | null,
absolutePath: string
): string => `${worktreeId}::${runtimeEnvironmentId ?? 'client'}::${absolutePath}`
const handleFsChanged = (
payload: FsChangedPayload,
runtimeEnvironmentId: string | null = null
): void => {
const target = findTarget(payload.worktreePath, runtimeEnvironmentId)
if (!target) {
return
}
// Why: this app-level hook owns watcher subscriptions; other consumers listen here so they don't fight over watch/unwatch ownership.
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(
new CustomEvent<WorktreeFileChangeEventDetail>(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: { payload, runtimeEnvironmentId: target.runtimeEnvironmentId }
})
)
}
// Why: one batch index keeps local WSL alias normalization out of event×tab loops.
const openFilesAtStart = useAppStore.getState().openFiles
const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
runtimeEnvironmentId: target.runtimeEnvironmentId,
...getLocalWindowsWslAliasOption(target)
})
const createOrUpdatePaths = batchPaths.createOrUpdatePaths
for (const createdPath of createOrUpdatePaths.keys()) {
const key = pendingKey(target.worktreeId, target.runtimeEnvironmentId, createdPath)
const existing = pendingDeletes.get(key)
if (existing) {
clearTimeout(existing.timer)
pendingDeletes.delete(key)
}
}
const deletedOpenEditorsRaw = batchPaths.deletedOpenEditors
// Only pay the per-id lookup to suppress a move's own source-delete while a move is live; otherwise the batch stays O(deletes).
const deletedOpenEditors = hasActiveEditorPathMoves()
? deletedOpenEditorsRaw.filter(
({ file }) =>
!isActiveMoveSourcePath(target.worktreeId, target.runtimeEnvironmentId, file.filePath)
)
: deletedOpenEditorsRaw
const deletedOpenEditorIds = deletedOpenEditors.map(({ file }) => file.id)
const hasPairedCreate =
deletedOpenEditorIds.length > 0 &&
hasRenameCorrelatedCreate(payload, target.worktreeId, deletedOpenEditorIds, openFilesAtStart)
if (deletedOpenEditorIds.length > 0) {
if (hasPairedCreate) {
const setExternalMutation = useAppStore.getState().setExternalMutation
for (const fileId of deletedOpenEditorIds) {
setExternalMutation(fileId, 'renamed')
}
} else {
for (const { file, normalizedDeletePath } of deletedOpenEditors) {
const key = pendingKey(
target.worktreeId,
target.runtimeEnvironmentId,
normalizedDeletePath
)
const existing = pendingDeletes.get(key)
if (existing) {
clearTimeout(existing.timer)
pendingDeletes.delete(key)
}
const timer = setTimeout(() => {
pendingDeletes.delete(key)
// Why: the debounce window lets the tab close or leave edit mode, so re-check before writing to avoid tombstoning a stale tab.
const state = useAppStore.getState()
const stillEditing = state.openFiles.some(
(candidate) => candidate.id === file.id && candidate.mode === 'edit'
)
if (stillEditing) {
state.setExternalMutation(file.id, 'deleted')
}
}, EXTERNAL_MUTATION_DEBOUNCE_MS)
pendingDeletes.set(key, { fileId: file.id, timer })
}
}
}
// Why: a reappearing file clears deleted/renamed tombstones, but a changed mark resolves only through reload/save.
if (createOrUpdatePaths.size > 0) {
const state = useAppStore.getState()
for (const file of state.openFiles) {
if (
file.worktreeId === target.worktreeId &&
getOpenFileRuntimeOwner(file) === target.runtimeEnvironmentId &&
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
(file.externalMutation === 'deleted' || file.externalMutation === 'renamed') &&
batchPaths.matchesCreateOrUpdate(file)
) {
state.setExternalMutation(file.id, null)
}
}
}
if (payload.events.some((event) => event.kind === 'overflow')) {
// Why: overflow omits paths, so reload clean tabs and clear tombstones that may have been resurrected during the overrun.
for (const notification of collectOverflowEditorExternalReloadTargets(target)) {
scheduleDebouncedEditorExternalReload(notification)
}
return
}
if (batchPaths.changes.length === 0) {
return
}
for (const change of batchPaths.changes) {
const matching = batchPaths.matchingOpenFiles(change)
const notification: EditorExternalWatchNotification = {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
relativePath: change.relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId,
...getLocalWindowsWslAliasOption(target)
}
Object.defineProperty(notification, 'indexedOpenFiles', {
value: {
matches: (openFiles: OpenFile[]) => batchPaths.matchingOpenFiles(change, openFiles)
}
})
if (matching.length === 0) {
if (batchPaths.hasCombinedDiffConsumer) {
scheduleDebouncedEditorExternalReload(notification)
}
continue
}
const dirtyMatches = matching.filter((file) => file.isDirty)
if (dirtyMatches.length > 0) {
const dirtyIds = dirtyMatches
.filter((file) => canAutoSaveOpenFile(file))
.map((file) => file.id)
let isSelfMoveEcho = false
if (dirtyMatches.some((file) => file.pendingSelfMoveEcho)) {
const normalizedAbsolutePath = normalizeRuntimePathForComparison(change.absolutePath)
isSelfMoveEcho = dirtyMatches.some(
(file) =>
file.pendingSelfMoveEcho &&
normalizeRuntimePathForComparison(file.pendingSelfMoveEcho.targetPath) ===
normalizedAbsolutePath
)
}
if (isSelfMoveEcho) {
scheduleEditorSelfMoveEchoVerification(target, dirtyIds, true)
} else {
scheduleEditorChangedOnDiskMark(target, notification, dirtyIds)
}
if (dirtyMatches.length === matching.length) {
if (batchPaths.hasCombinedDiffConsumer) {
scheduleDebouncedEditorExternalReload(notification)
}
continue
}
}
const recentSelfWrite = getRecentSelfWrite(change.absolutePath, target.runtimeEnvironmentId)
if (recentSelfWrite) {
scheduleSelfWriteAwareEditorExternalReload(
target,
notification,
matching[0],
recentSelfWrite
)
continue
}
scheduleDebouncedEditorExternalReload(notification)
}
}
const dispose = (): void => {
for (const pending of pendingDeletes.values()) {
clearTimeout(pending.timer)
}
pendingDeletes.clear()
}
return { handleFsChanged, dispose }
}
export function collectOverflowEditorExternalReloadTargets(
target: Pick<EditorExternalWatchTarget, 'worktreeId' | 'worktreePath'> &
Partial<
Pick<
EditorExternalWatchTarget,
'connectionId' | 'runtimeEnvironmentId' | 'allowLocalWindowsWslAliases'
>
>
): EditorExternalWatchNotification[] {
const state = useAppStore.getState()
const notifications: EditorExternalWatchNotification[] = []
for (const file of state.openFiles) {
if (
file.worktreeId !== target.worktreeId ||
getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) ||
!isExternalReloadableEditorTab(file) ||
file.isDirty
) {
continue
}
if (file.externalMutation) {
state.setExternalMutation(file.id, null)
}
notifications.push({
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
relativePath: file.relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId ?? null,
...getLocalWindowsWslAliasOption(target)
})
}
return notifications
}
function hasRenameCorrelatedCreate(
payload: FsChangedPayload,
worktreeId: string,
deletedOpenEditorIds: string[],
openFiles: OpenFile[]
): boolean {
if (deletedOpenEditorIds.length === 0) {
return false
}
const deletedIdSet = new Set(deletedOpenEditorIds)
const deletedBasenames = new Set<string>()
for (const file of openFiles) {
if (
file.worktreeId !== worktreeId ||
(file.mode !== 'edit' && file.mode !== 'markdown-preview') ||
!deletedIdSet.has(file.id)
) {
continue
}
deletedBasenames.add(basename(file.filePath))
}
if (deletedBasenames.size === 0) {
return false
}
return payload.events.some(
(event) =>
event.kind === 'create' &&
event.isDirectory !== true &&
deletedBasenames.has(basename(event.absolutePath))
)
}
@@ -0,0 +1,260 @@
import type { AppState } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
export type EditorExternalWatchTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
runtimeEnvironmentId: string | null
allowLocalWindowsWslAliases?: true
}
export type EditorExternalWatchTargetState = Pick<
AppState,
| 'openFiles'
| 'worktreesByRepo'
| 'repos'
| 'activeWorktreeId'
| 'settings'
| 'rightSidebarOpen'
| 'rightSidebarTab'
| 'rightSidebarExplorerView'
| 'gitStatusHugeByWorktree'
| 'sshConnectionStates'
| 'folderWorkspaces'
| 'projectGroups'
>
type WatchedTargetsSnapshot = {
targets: EditorExternalWatchTarget[]
targetsKey: string
}
let cachedOpenFiles: AppState['openFiles'] | null = null
let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null
let cachedRepos: AppState['repos'] | null = null
let cachedActiveWorktreeId: string | null = null
let cachedRuntimeEnvironmentId: string | undefined
let cachedRightSidebarOpen: boolean | null = null
let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null
let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null
let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null
let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null
let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null
let cachedProjectGroups: AppState['projectGroups'] | null = null
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' }
export function getEditorExternalWatchTargetKey(target: EditorExternalWatchTarget): string {
// Why: include connectionId so a local placeholder watch is replaced by the real SSH watch once an SSH worktree's provider metadata hydrates.
return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}::${target.allowLocalWindowsWslAliases === true ? 'wsl-aliases' : 'literal'}`
}
export function getOpenFileRuntimeOwner(
file: Pick<OpenFile, 'runtimeEnvironmentId'>
): string | null {
return file.runtimeEnvironmentId?.trim() || null
}
export function getLocalWindowsWslAliasOption(
target: Pick<EditorExternalWatchTarget, 'allowLocalWindowsWslAliases'>
): Pick<EditorExternalWatchTarget, 'allowLocalWindowsWslAliases'> {
return isLocalWindowsDesktopClient() && target.allowLocalWindowsWslAliases === true
? { allowLocalWindowsWslAliases: true }
: {}
}
function isLocalHostStamp(value: string | null | undefined): boolean {
return parseExecutionHostId(value)?.kind === 'local'
}
function canWatchLocalWindowsWslAliases(args: {
worktreePath: string
runtimeEnvironmentId: string | null
connectionId: string | null | undefined
worktree: AppState['worktreesByRepo'][string][number] | undefined
repo: AppState['repos'][number] | undefined
folderWorkspace: AppState['folderWorkspaces'][number] | undefined
projectGroup: AppState['projectGroups'][number] | undefined
}): boolean {
if (
args.runtimeEnvironmentId !== null ||
args.connectionId !== null ||
!isWindowsAbsolutePathLike(args.worktreePath)
) {
return false
}
if (args.worktree) {
return (
!!args.repo &&
!args.worktree.runtimeOwnerEnvironmentId?.trim() &&
isLocalHostStamp(args.worktree.hostId) &&
isLocalHostStamp(args.repo.executionHostId)
)
}
return (
!!args.folderWorkspace &&
isLocalHostStamp(args.folderWorkspace.executionHostId) &&
isLocalHostStamp(args.projectGroup?.executionHostId)
)
}
export function selectEditorExternalWatchTargets(
state: EditorExternalWatchTargetState
): WatchedTargetsSnapshot {
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined
if (
cachedOpenFiles === state.openFiles &&
cachedWorktreesByRepo === state.worktreesByRepo &&
cachedRepos === state.repos &&
cachedActiveWorktreeId === state.activeWorktreeId &&
cachedRuntimeEnvironmentId === runtimeEnvironmentId &&
cachedRightSidebarOpen === state.rightSidebarOpen &&
cachedRightSidebarTab === state.rightSidebarTab &&
cachedRightSidebarExplorerView === state.rightSidebarExplorerView &&
cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree &&
cachedSshConnectionStates === state.sshConnectionStates &&
cachedFolderWorkspaces === state.folderWorkspaces &&
cachedProjectGroups === state.projectGroups
) {
return cachedWatchedTargetsSnapshot
}
const targetOwnersByWorktreeId = new Map<string, Set<string | null>>()
// Why: watcher ownership is scoped by worktree + runtime owner — the same path can be open locally and in a runtime workspace, and reads/saves already route per owner.
for (const file of state.openFiles) {
let owners = targetOwnersByWorktreeId.get(file.worktreeId)
if (!owners) {
owners = new Set()
targetOwnersByWorktreeId.set(file.worktreeId, owners)
}
// Why: persisted/restored tabs may have runtimeEnvironmentId undefined; new openFile calls resolve inheritance before storing, so an ownerless tab stays local.
owners.add(getOpenFileRuntimeOwner(file))
}
const activeWorktreeId = state.activeWorktreeId
const activeWorktree = activeWorktreeId
? findWorktreeById(state.worktreesByRepo, activeWorktreeId)
: undefined
const activeWorktreeHost = parseExecutionHostId(activeWorktree?.hostId)
const activeRepo = activeWorktree
? activeWorktreeHost?.kind === 'local'
? (findRepoForHost(state.repos, activeWorktree.repoId, {
hostId: activeWorktreeHost.id
}) ?? undefined)
: state.repos.find((repo) => repo.id === activeWorktree.repoId)
: undefined
const sourceControlCanConsumeWatch =
!!activeWorktreeId &&
!!activeRepo &&
isGitRepoKind(activeRepo) &&
!state.gitStatusHugeByWorktree[activeWorktreeId] &&
(!activeRepo.connectionId ||
state.sshConnectionStates.get(activeRepo.connectionId)?.status === 'connected')
const activeWorktreeNeedsSidebarWatch =
activeWorktreeId !== null &&
state.rightSidebarOpen &&
((state.rightSidebarTab === 'explorer' && state.rightSidebarExplorerView === 'files') ||
(state.rightSidebarTab === 'source-control' && sourceControlCanConsumeWatch))
if (activeWorktreeNeedsSidebarWatch) {
// Why: this app-level watcher owns Explorer/Source-Control subscriptions so downstream consumers don't fight over watch/unwatch IPC.
let owners = targetOwnersByWorktreeId.get(activeWorktreeId)
if (!owners) {
owners = new Set()
targetOwnersByWorktreeId.set(activeWorktreeId, owners)
}
// Why: sidebar watcher must follow the selected worktree's host owner, not the host currently focused in the UI.
owners.add(getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId))
}
const nextTargets: EditorExternalWatchTarget[] = []
const parts: string[] = []
const sortedWorktreeIds = Array.from(targetOwnersByWorktreeId.keys()).sort()
for (const id of sortedWorktreeIds) {
const worktree = findWorktreeById(state.worktreesByRepo, id)
const workspaceScope = parseWorkspaceKey(id)
const folderWorkspace =
workspaceScope?.type === 'folder'
? state.folderWorkspaces.find(
(workspace) => workspace.id === workspaceScope.folderWorkspaceId
)
: undefined
if (!worktree && !folderWorkspace) {
continue
}
const worktreeHost = parseExecutionHostId(worktree?.hostId)
const repo = worktree
? worktreeHost?.kind === 'local'
? (findRepoForHost(state.repos, worktree.repoId, { hostId: worktreeHost.id }) ?? undefined)
: state.repos.find((candidate) => candidate.id === worktree.repoId)
: undefined
const folderHostId = parseExecutionHostId(folderWorkspace?.executionHostId)?.id
const projectGroup = folderWorkspace
? state.projectGroups.find(
(group) =>
group.id === folderWorkspace.projectGroupId &&
parseExecutionHostId(group.executionHostId)?.id === folderHostId
)
: undefined
const connectionId = folderWorkspace
? getFolderWorkspaceConnectionId(state, folderWorkspace.id)
: repo
? (repo.connectionId ?? null)
: undefined
if (connectionId === undefined && folderWorkspace) {
continue
}
const owners = Array.from(targetOwnersByWorktreeId.get(id) ?? []).sort((left, right) =>
(left ?? '').localeCompare(right ?? '')
)
for (const owner of owners) {
const target = {
worktreeId: id,
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
connectionId: connectionId ?? undefined,
runtimeEnvironmentId: owner,
...(canWatchLocalWindowsWslAliases({
worktreePath: worktree?.path ?? folderWorkspace!.folderPath,
runtimeEnvironmentId: owner,
connectionId,
worktree,
repo,
folderWorkspace,
projectGroup
})
? { allowLocalWindowsWslAliases: true as const }
: {})
}
nextTargets.push(target)
parts.push(getEditorExternalWatchTargetKey(target))
}
}
const targetsKey = parts.join('|')
cachedOpenFiles = state.openFiles
cachedWorktreesByRepo = state.worktreesByRepo
cachedRepos = state.repos
cachedActiveWorktreeId = state.activeWorktreeId
cachedRuntimeEnvironmentId = runtimeEnvironmentId
cachedRightSidebarOpen = state.rightSidebarOpen
cachedRightSidebarTab = state.rightSidebarTab
cachedRightSidebarExplorerView = state.rightSidebarExplorerView
cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree
cachedSshConnectionStates = state.sshConnectionStates
cachedFolderWorkspaces = state.folderWorkspaces
cachedProjectGroups = state.projectGroups
if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) {
return cachedWatchedTargetsSnapshot
}
cachedWatchedTargetsSnapshot = { targets: nextTargets, targetsKey }
return cachedWatchedTargetsSnapshot
}
@@ -0,0 +1,187 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
type TestWatchTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
runtimeEnvironmentId: string | null
allowLocalWindowsWslAliases?: true
}
const subscriptionState = vi.hoisted(() => ({
snapshot: { targets: [] as TestWatchTarget[], targetsKey: '' },
subscribeRuntimeFileChanges: vi.fn(),
disposeEventHandler: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector({})
}))
vi.mock('@/runtime/runtime-file-client', () => ({
subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges
}))
vi.mock('./editor-external-watch-targets', () => ({
selectEditorExternalWatchTargets: () => subscriptionState.snapshot,
getEditorExternalWatchTargetKey: (target: TestWatchTarget) =>
[
target.worktreeId,
target.worktreePath,
target.connectionId ?? 'local',
target.runtimeEnvironmentId ?? 'client',
target.allowLocalWindowsWslAliases ? 'wsl-aliases' : 'literal'
].join('::')
}))
vi.mock('./editor-external-watch-event-reconciliation', () => ({
buildEditorExternalWatchEventHandler: vi.fn(() => ({
handleFsChanged: vi.fn(),
dispose: subscriptionState.disposeEventHandler
})),
collectOverflowEditorExternalReloadTargets: vi.fn()
}))
vi.mock('./editor-external-watch-disk-verification', () => ({
verifyLatchedEditorMoveDestinations: vi.fn()
}))
import { useEditorExternalWatch } from './useEditorExternalWatch'
function WatchProbe(): null {
useEditorExternalWatch()
return null
}
function runtimeTarget(): TestWatchTarget {
return {
worktreeId: 'wt-runtime',
worktreePath: '/runtime/repo',
connectionId: 'nested-ssh',
runtimeEnvironmentId: 'runtime-1'
}
}
function deferredRuntimeSubscription(): {
promise: Promise<() => void>
resolve: (unsubscribe: () => void) => void
} {
let resolve!: (unsubscribe: () => void) => void
const promise = new Promise<() => void>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('useEditorExternalWatch subscriptions', () => {
let previousApi: unknown
let container: HTMLDivElement
let root: Root
let watchWorktree: ReturnType<typeof vi.fn>
let unwatchWorktree: ReturnType<typeof vi.fn>
let unsubscribeFsEvents: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
subscriptionState.snapshot = { targets: [], targetsKey: '' }
watchWorktree = vi.fn().mockResolvedValue(undefined)
unwatchWorktree = vi.fn().mockResolvedValue(undefined)
unsubscribeFsEvents = vi.fn()
previousApi = (window as unknown as { api?: unknown }).api
;(window as unknown as { api: unknown }).api = {
fs: {
watchWorktree,
unwatchWorktree,
onFsChanged: vi.fn(() => unsubscribeFsEvents)
}
}
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
;(window as unknown as { api?: unknown }).api = previousApi
})
it('unsubscribes an SSH watch and the shared event listener exactly once on unmount', async () => {
subscriptionState.snapshot = {
targets: [
{
worktreeId: 'wt-ssh',
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
runtimeEnvironmentId: null
}
],
targetsKey: 'ssh-watch'
}
await act(async () => root.render(createElement(WatchProbe)))
expect(watchWorktree).toHaveBeenCalledWith({
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
await act(async () => root.unmount())
expect(unwatchWorktree).toHaveBeenCalledTimes(1)
expect(unwatchWorktree).toHaveBeenCalledWith({
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(unsubscribeFsEvents).toHaveBeenCalledTimes(1)
expect(subscriptionState.disposeEventHandler).toHaveBeenCalledTimes(1)
})
it('disposes a runtime subscription that resolves after unmount', async () => {
const pending = deferredRuntimeSubscription()
const unsubscribeRuntime = vi.fn()
subscriptionState.subscribeRuntimeFileChanges.mockReturnValueOnce(pending.promise)
subscriptionState.snapshot = {
targets: [runtimeTarget()],
targetsKey: 'runtime-watch'
}
await act(async () => root.render(createElement(WatchProbe)))
await act(async () => root.unmount())
pending.resolve(unsubscribeRuntime)
await act(async () => pending.promise)
expect(unsubscribeRuntime).toHaveBeenCalledTimes(1)
expect(unwatchWorktree).not.toHaveBeenCalled()
})
it('cannot let an old runtime subscribe resolution replace a re-added watch', async () => {
const stalePending = deferredRuntimeSubscription()
const currentPending = deferredRuntimeSubscription()
const unsubscribeStale = vi.fn()
const unsubscribeCurrent = vi.fn()
subscriptionState.subscribeRuntimeFileChanges
.mockReturnValueOnce(stalePending.promise)
.mockReturnValueOnce(currentPending.promise)
subscriptionState.snapshot = {
targets: [runtimeTarget()],
targetsKey: 'runtime-watch-1'
}
await act(async () => root.render(createElement(WatchProbe)))
subscriptionState.snapshot = { targets: [], targetsKey: 'no-runtime-watch' }
await act(async () => root.render(createElement(WatchProbe)))
subscriptionState.snapshot = {
targets: [runtimeTarget()],
targetsKey: 'runtime-watch-2'
}
await act(async () => root.render(createElement(WatchProbe)))
currentPending.resolve(unsubscribeCurrent)
await act(async () => currentPending.promise)
stalePending.resolve(unsubscribeStale)
await act(async () => stalePending.promise)
expect(unsubscribeStale).toHaveBeenCalledTimes(1)
expect(unsubscribeCurrent).not.toHaveBeenCalled()
await act(async () => root.unmount())
expect(unsubscribeCurrent).toHaveBeenCalledTimes(1)
})
})
File diff suppressed because it is too large Load Diff