From e0bc4e1126783fc4e5302e18521f92125956dec2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:30:47 -0700 Subject: [PATCH] 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. --- config/max-lines-baseline.txt | 3 - .../editor/EditorConflictReviewSurface.tsx | 286 +++++ .../EditorContent.monaco-lifecycle.test.tsx | 72 ++ .../src/components/editor/EditorContent.tsx | 890 ++------------- .../editor/EditorDiffFileSurface.tsx | 172 +++ .../editor/EditorEditFileSurface.tsx | 266 +++++ .../editor/EditorFileLoadErrorView.tsx | 29 + .../editor/EditorMarkdownFileSurface.tsx | 167 +++ .../src/components/editor/IpynbCellEditor.tsx | 148 +++ .../components/editor/IpynbCellOutputs.tsx | 121 ++ .../components/editor/IpynbCellToolbar.tsx | 176 +++ .../src/components/editor/IpynbViewer.tsx | 863 ++------------ .../components/editor/editor-lazy-views.ts | 16 + .../editor/useEditorConflictNavigation.ts | 61 + .../editor/useIpynbCellExecution.test.tsx | 121 ++ .../editor/useIpynbCellExecution.ts | 105 ++ .../editor/useIpynbDocumentEditing.test.tsx | 142 +++ .../editor/useIpynbDocumentEditing.ts | 221 ++++ .../editor/useIpynbScrollRestoration.ts | 43 + ...editor-external-watch-disk-verification.ts | 286 +++++ ...tor-external-watch-event-reconciliation.ts | 300 +++++ .../hooks/editor-external-watch-targets.ts | 260 +++++ ...EditorExternalWatch-subscriptions.test.tsx | 187 +++ .../src/hooks/useEditorExternalWatch.ts | 1000 ++--------------- 24 files changed, 3422 insertions(+), 2513 deletions(-) create mode 100644 src/renderer/src/components/editor/EditorConflictReviewSurface.tsx create mode 100644 src/renderer/src/components/editor/EditorDiffFileSurface.tsx create mode 100644 src/renderer/src/components/editor/EditorEditFileSurface.tsx create mode 100644 src/renderer/src/components/editor/EditorFileLoadErrorView.tsx create mode 100644 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx create mode 100644 src/renderer/src/components/editor/IpynbCellEditor.tsx create mode 100644 src/renderer/src/components/editor/IpynbCellOutputs.tsx create mode 100644 src/renderer/src/components/editor/IpynbCellToolbar.tsx create mode 100644 src/renderer/src/components/editor/editor-lazy-views.ts create mode 100644 src/renderer/src/components/editor/useEditorConflictNavigation.ts create mode 100644 src/renderer/src/components/editor/useIpynbCellExecution.test.tsx create mode 100644 src/renderer/src/components/editor/useIpynbCellExecution.ts create mode 100644 src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx create mode 100644 src/renderer/src/components/editor/useIpynbDocumentEditing.ts create mode 100644 src/renderer/src/components/editor/useIpynbScrollRestoration.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-disk-verification.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-targets.ts create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e1bebf1b4b3..565b410a7c3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx new file mode 100644 index 00000000000..85c53cfb41f --- /dev/null +++ b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx @@ -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 + editBuffers: Record + 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 + 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 ( +
+ +
+ ) + } + + const fileContent = fileContents[contentFile.id] + if (!fileContent) { + return ( +
+
+ {translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')} +
+
+ ) + } + if (fileContent.loadError) { + return ( +
+ reloadContent(contentFile)} + /> +
+ ) + } + if (fileContent.isBinary) { + if (fileContent.isImage) { + return ( +
+ +
+ ) + } + return ( +
+
+ {translate( + 'auto.components.editor.EditorContent.b9de81ba52', + 'Binary file — cannot display' + )} +
+
+ ) + } + + 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 ( +
+ {contentFile.conflict && ( + + )} +
+ {} : (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 + } + /> +
+
+ ) + } + + 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 ( +
+ {unresolvedEntries.map(renderInlineFile)} +
+ ) + } + + return ( + 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 +): reveal is PendingEditorReveal { + if (!reveal) { + return false + } + return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath +} diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index d0cc1cc78d9..0590a1a7b32 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -7,6 +7,19 @@ const lifecycle = vi.hoisted(() => ({ events: [] as string[], diffModelKeys: [] as string[], models: new Map(), + notebookProps: [] as { + fileId: string + filePath: string + worktreeId: string + scrollCacheKey: string + onContentChange: (content: string) => void + onSave: (content: string) => Promise + }[], + 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( + + ) + + 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( + + ) + + expect(lifecycle.richMarkdownProps).toHaveLength(1) + expect(lifecycle.richMarkdownProps[0]).toMatchObject({ + externalSshTargetId: 'ssh-target', + runtimeEnvironmentId: 'runtime-environment', + worktreeId: markdown.worktreeId + }) + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 01be65d3465..ca12823e846 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 => 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 -): 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 ( -
-
- -
-
- {translate('auto.components.editor.EditorContent.39f018b052', 'Unable to load file')} -
-
{message}
- -
-
-
- ) -} - 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 - >({}) - 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. - - ) - - 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
{renderMonacoEditor(fc)}
- } - - // 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 ( -
-
- {richFallbackMessage} -
-
{renderMonacoEditor(fc)}
-
- ) - } - - 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 => md.mdSave(prependFrontMatter(fm.raw, body)) - : md.mdSave - - return ( -
-
- {/* Why: keyed for remount like MonacoEditor; boundary contains a TipTap render crash (issue #826) to this pane. */} - - : null - } - /> - -
-
- ) - } - - if (renderMode === 'preview') { - const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage - return ( -
- {shouldExplainRichFallback ? ( -
- {richModeUnsupportedMessage} -
- ) : null} - {/* Why: fall back to the stable preview renderer when Tiptap can't safely own the document. */} -
- -
-
- ) - } - - // Why: Monaco with height="100%" sizes to its immediate parent, so the wrapper needs an explicit height or it collapses. - return
{renderMonacoEditor(fc)}
- } - - 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 ( -
- -
- ) - } - - const fc = fileContents[contentFile.id] - if (!fc) { - return ( -
-
- {translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')} -
-
- ) - } - if (fc.loadError) { - return ( -
- reloadContent(contentFile)} /> -
- ) - } - if (fc.isBinary) { - if (fc.isImage) { - return ( -
- -
- ) - } - return ( -
-
- {translate( - 'auto.components.editor.EditorContent.b9de81ba52', - 'Binary file — cannot display' - )} -
-
- ) - } - - 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 ( -
- {contentFile.conflict && ( - - )} -
- {} : (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 - } - /> -
-
- ) - } - - 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 ( -
- {unresolvedEntries.map(renderConflictReviewInlineFile)} -
- ) - } - 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 ( { void reloadOpenCheckRunDetailsTab(activeFile.id) @@ -646,31 +146,18 @@ export function EditorContent({ if (activeFile.mode === 'conflict-review') { return ( - 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')} + ) } @@ -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 (
{translate('auto.components.editor.EditorContent.37a0e81fa6', 'Loading preview...')}
) } - if (fc.loadError) { - return reloadContent(activeFile)} /> + if (fileContent.loadError) { + return ( + reloadContent(activeFile)} + /> + ) } - if (fc.isBinary) { + if (fileContent.isBinary) { return (
{translate( @@ -708,12 +200,11 @@ export function EditorContent({ ) } const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath - const previewContent = editBuffers[previewSourceFileId] ?? fc.content return (
) } if (activeFile.mode === 'edit') { - if (activeFile.conflict?.kind === 'conflict-placeholder') { - return - } - const fc = fileContents[activeFile.id] - if (!fc) { - return ( -
- {translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')} -
- ) - } - if (fc.loadError) { - return reloadContent(activeFile)} /> - } - if (fc.isBinary) { - if (fc.isImage) { - return ( - - ) - } - return ( -
- {translate( - 'auto.components.editor.EditorContent.b9de81ba52', - 'Binary file — cannot display' - )} -
- ) - } - const externalChangeBanner = - activeFile.externalMutation === 'changed' ? ( - - ) : null - if (isChangesMode) { - const changesView = ( - - ) - if (!externalChangeBanner) { - return changesView - } - return ( -
- {externalChangeBanner} -
{changesView}
-
- ) - } return ( -
- {externalChangeBanner} - {activeFile.conflict && ( - - )} -
- {isMarkdown ? ( - renderMarkdownContent(fc) - ) : isMermaid && mdViewMode === 'rich' ? ( - - ) : isCsv && mdViewMode === 'rich' ? ( - - ) : isNotebook && mdViewMode === 'rich' ? ( - - ) : ( - renderMonacoEditor(fc) - )} -
-
- ) - } - - // Diff mode - const dc = diffContents[activeFile.id] - if (!dc) { - return ( -
- {translate('auto.components.editor.EditorContent.c88c73a0d3', 'Loading diff...')} -
- ) - } - const isEditable = activeFile.diffSource === 'unstaged' - if (dc.kind === 'binary') { - if (dc.isImage) { - return ( - - ) - } - return ( -
-
-
- {translate('auto.components.editor.EditorContent.78541e254e', 'Binary file changed')} -
-
- {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.' - )} -
-
-
- ) - } - 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' ? ( - - ) : null - if (isMarkdown && mdViewMode === 'preview' && dc.largeDiffRenderLimit?.limited !== true) { - return ( -
- {diffExternalChangeBanner} -
- {/* 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.' - )} -
-
- -
-
) } - // 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 = ( - ) - // 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. -
- {diffExternalChangeBanner} -
{diffViewer}
-
- ) -} - -// 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 ( -
-
- {translate('auto.components.editor.EditorContent.e4b074749d', 'Front Matter')} - - {translate('auto.components.editor.EditorContent.56dba34e1a', '(edit in source mode)')} - -
-
-        {inner}
-      
-
- ) } diff --git a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx new file mode 100644 index 00000000000..1597e3cef27 --- /dev/null +++ b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx @@ -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 + +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 + reloadContent: (file: OpenFile) => void +}): React.JSX.Element { + if (!diffContent) { + return ( +
+ {translate('auto.components.editor.EditorContent.c88c73a0d3', 'Loading diff...')} +
+ ) + } + + const isEditable = activeFile.diffSource === 'unstaged' + if (diffContent.kind === 'binary') { + if (diffContent.isImage) { + return ( + + ) + } + return ( +
+
+
+ {translate('auto.components.editor.EditorContent.78541e254e', 'Binary file changed')} +
+
+ {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.' + )} +
+
+
+ ) + } + + const modifiedDiffContent = editBuffer ?? diffContent.modifiedContent + const largeDiffSaveContentAvailable = !( + diffContent.largeDiffRenderLimit?.limited === true && + editBuffer === undefined && + diffContent.modifiedContent.length === 0 + ) + const externalChangeBanner = + activeFile.externalMutation === 'changed' ? ( + + ) : null + + if ( + isMarkdown && + mdViewMode === 'preview' && + diffContent.largeDiffRenderLimit?.limited !== true + ) { + return ( +
+ {externalChangeBanner} +
+ {/* 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.' + )} +
+
+ +
+
+ ) + } + + const diffReloadNonce = activeFile.diffContentReloadNonce ?? 0 + const originalModelKey = `${diffViewStateKey}:original:${getDiffContentSignature(diffContent.originalContent)}` + const modifiedModelKey = `${diffViewStateKey}:modified:${getDiffContentSignature(diffContent.modifiedContent)}:${diffReloadNonce}` + const diffViewer = ( + + ) + 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. +
+ {externalChangeBanner} +
{diffViewer}
+
+ ) +} diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx new file mode 100644 index 00000000000..d0b83539ee6 --- /dev/null +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -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 => false + +type MarkdownDocumentsController = ReturnType + +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 + reloadContent: (file: OpenFile) => void +}): React.JSX.Element { + if (activeFile.conflict?.kind === 'conflict-placeholder') { + return + } + if (!fileContent) { + return ( +
+ {translate('auto.components.editor.EditorContent.b2735221f5', 'Loading...')} +
+ ) + } + if (fileContent.loadError) { + return ( + reloadContent(activeFile)} + /> + ) + } + if (fileContent.isBinary) { + if (fileContent.isImage) { + return ( + + ) + } + return ( +
+ {translate( + 'auto.components.editor.EditorContent.b9de81ba52', + 'Binary file — cannot display' + )} +
+ ) + } + + const currentContent = editBuffer ?? fileContent.content + const externalChangeBanner = + activeFile.externalMutation === 'changed' ? ( + + ) : null + + if (isChangesMode) { + const changesView = ( + + ) + if (!externalChangeBanner) { + return changesView + } + return ( +
+ {externalChangeBanner} +
{changesView}
+
+ ) + } + + const monacoEditor = ( + // Why: without a key React reuses the instance and skips cleanup (scroll snapshot); key forces a remount per pane+path. + + ) + + const editorSurface = isMarkdown ? ( + + ) : isMermaid && mdViewMode === 'rich' ? ( + + ) : isCsv && mdViewMode === 'rich' ? ( + + ) : isNotebook && mdViewMode === 'rich' ? ( + + ) : ( + monacoEditor + ) + + return ( +
+ {externalChangeBanner} + {activeFile.conflict && ( + + )} +
{editorSurface}
+
+ ) +} + +function matchesPendingEditorReveal( + reveal: PendingEditorReveal | null, + file: Pick +): reveal is PendingEditorReveal { + if (!reveal) { + return false + } + return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath +} diff --git a/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx b/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx new file mode 100644 index 00000000000..41964fbd444 --- /dev/null +++ b/src/renderer/src/components/editor/EditorFileLoadErrorView.tsx @@ -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 ( +
+
+ +
+
+ {translate('auto.components.editor.EditorContent.39f018b052', 'Unable to load file')} +
+
{message}
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx new file mode 100644 index 00000000000..1d0500f58cb --- /dev/null +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -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 + +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
{monacoEditor}
+ } + if (renderMode === 'source' && mdViewMode === 'rich') { + const richFallbackMessage = + richModeUnsupportedMessage ?? + 'File is too large for rich editing. Showing source mode instead.' + return ( +
+
+ {richFallbackMessage} +
+
{monacoEditor}
+
+ ) + } + 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 => + markdownDocuments.mdSave(prependFrontMatter(frontMatter.raw, body)) + : markdownDocuments.mdSave + + return ( +
+
+ {/* Why: keyed for remount like MonacoEditor; boundary contains a TipTap render crash (issue #826) to this pane. */} + + + ) : null + } + /> + +
+
+ ) + } + if (renderMode === 'preview') { + const shouldExplainRichFallback = mdViewMode === 'rich' && richModeUnsupportedMessage + return ( +
+ {shouldExplainRichFallback ? ( +
+ {richModeUnsupportedMessage} +
+ ) : null} + {/* Why: fall back to the stable preview renderer when Tiptap can't safely own the document. */} +
+ +
+
+ ) + } + return
{monacoEditor}
+} + +function FrontMatterBanner({ raw }: { raw: string }): React.JSX.Element { + const inner = raw + .replace(/^(?:---|\+\+\+)\r?\n/, '') + .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') + .trim() + + return ( +
+
+ {translate('auto.components.editor.EditorContent.e4b074749d', 'Front Matter')} + + {translate('auto.components.editor.EditorContent.56dba34e1a', '(edit in source mode)')} + +
+
+        {inner}
+      
+
+ ) +} diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx new file mode 100644 index 00000000000..77f319f034c --- /dev/null +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -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 ( +
+ + {source || '\u00a0'} + +
+ ) +} + +export function IpynbEditableTextCell({ + source, + onChange +}: { + source: string + onChange: (source: string) => void +}): React.JSX.Element { + return ( +