mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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
Reference in New Issue
Block a user