refactor(editor): group core modules by feature owner (#42325)

This commit is contained in:
Neil
2026-11-14 00:10:17 -08:00
committed by GitHub
parent a92db9d4d0
commit 16f8b0461b
186 changed files with 394 additions and 3132 deletions
+6 -6
View File
@@ -28,14 +28,14 @@
1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts
2 src/renderer/src/components/cmd-j/quick-action-context.ts
2 src/renderer/src/components/editor/EditorContent.tsx
1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx
1 src/renderer/src/components/editor/EditorEditFileSurface.tsx
1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx
1 src/renderer/src/components/editor/panel/EditorDiffFileSurface.tsx
1 src/renderer/src/components/editor/panel/EditorEditFileSurface.tsx
1 src/renderer/src/components/editor/panel/EditorMarkdownFileSurface.tsx
1 src/renderer/src/components/editor/csv/csv-link-routing.ts
2 src/renderer/src/components/editor/markdown/documents/useMarkdownDocuments.ts
1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts
2 src/renderer/src/components/editor/useEditorPanelContentState.ts
1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts
1 src/renderer/src/components/editor/file-ownership/restored-editor-workspace-runtime-owner.ts
2 src/renderer/src/components/editor/loading/useEditorPanelContentState.ts
1 src/renderer/src/components/editor/loading/useEditorPanelFileContentLoader.ts
2 src/renderer/src/components/feature-wall/setup/integrations/ConnectIntegrationsList.tsx
1 src/renderer/src/components/feature-wall/setup/browser/FeatureWallBrowserAction.tsx
1 src/renderer/src/components/feature-wall/setup/FeatureWallSetupChecklist.tsx
+1 -1
View File
@@ -60,7 +60,7 @@
"src/**/*.test-fixture.{ts,tsx}",
"src/main/runtime/rpc/dispatch/rpc-params-type-parity.ts",
"src/main/runtime/rpc/methods/client-state/ui-state-schema-parity-checks.ts",
"src/renderer/src/components/editor/monaco-internal-test-module-types.ts",
"src/renderer/src/components/editor/monaco/monaco-internal-test-module-types.ts",
"mobile/**/*.{ts,tsx}",
"cloud/**/*.{ts,tsx,mjs}"
],
+12 -12
View File
@@ -83,8 +83,8 @@
5 src/renderer/src/components/diff-comments/DiffCommentPopover.tsx
2 src/renderer/src/components/diff-comments/diff-comment-draft-zone.ts
1 src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
1 src/renderer/src/components/editor/EditorPanelShell.tsx
5 src/renderer/src/components/editor/MonacoEditor.tsx
1 src/renderer/src/components/editor/panel/EditorPanelShell.tsx
5 src/renderer/src/components/editor/monaco/MonacoEditor.tsx
1 src/renderer/src/components/editor/pdf/PdfViewer.tsx
1 src/renderer/src/components/editor/plugin-editors/PluginCustomEditorFrame.tsx
3 src/renderer/src/components/editor/check-details/actions/check-run-details-fix-with-ai.ts
@@ -107,7 +107,7 @@
1 src/renderer/src/components/editor/diff/large-files/useDiffViewerLargeDiffLifecycle.ts
3 src/renderer/src/components/editor/diff/monaco-view/DiffViewer.tsx
1 src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx
1 src/renderer/src/components/editor/editor-header-file-rename.ts
1 src/renderer/src/components/editor/panel/header/editor-header-file-rename.ts
1 src/renderer/src/components/editor/external-changes/ExternalFileChangeCompareDialog.tsx
1 src/renderer/src/components/editor/git-graph/git-graph-filter.tsx
1 src/renderer/src/components/editor/markdown-preview/render-tree/use-markdown-preview-components.tsx
@@ -134,15 +134,15 @@
1 src/renderer/src/components/editor/rich-markdown/review/useRichMarkdownReviewData.ts
1 src/renderer/src/components/editor/rich-markdown/review/useRichMarkdownReviewRailController.ts
12 src/renderer/src/components/editor/rich-markdown/tables/RichMarkdownTableControls.tsx
2 src/renderer/src/components/editor/use-editor-group-file-drop-owner.ts
1 src/renderer/src/components/editor/use-monaco-content-sync-bridge.ts
1 src/renderer/src/components/editor/use-monaco-editor-mount.ts
1 src/renderer/src/components/editor/use-plugin-syntax-language.ts
1 src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts
2 src/renderer/src/components/editor/useEditorPanelContentReloadTriggers.ts
1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts
3 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts
4 src/renderer/src/components/editor/useLocalLogTail.ts
2 src/renderer/src/components/editor/file-drop/use-editor-group-file-drop-owner.ts
1 src/renderer/src/components/editor/model-lifetime/buffer-text/use-monaco-content-sync-bridge.ts
1 src/renderer/src/components/editor/monaco/use-monaco-editor-mount.ts
1 src/renderer/src/components/editor/monaco/use-plugin-syntax-language.ts
1 src/renderer/src/components/editor/loading/useEditorPanelActiveTabContentLoad.ts
2 src/renderer/src/components/editor/loading/useEditorPanelContentReloadTriggers.ts
1 src/renderer/src/components/editor/loading/useEditorPanelDiffContentLoader.ts
3 src/renderer/src/components/editor/loading/useEditorPanelFileContentLoader.ts
4 src/renderer/src/components/editor/loading/log-tail/useLocalLogTail.ts
1 src/renderer/src/components/editor/markdown-preview/viewport/usePreserveSectionDuringExternalEdit.ts
1 src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx
3 src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts
+4 -3
View File
@@ -5623,17 +5623,17 @@
"invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.",
"oracle": "Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2095 current-parent preparation2126: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit1709 retires changed-source assertion and execution scope without equivalent replacement coverage. Existing unchanged-selector receipts remain scoped to their original sources. Historical prior-source oracle: Focused tests assert one post-mount content owner and actual lifecycle remount ordering across retained path models. Real-Monaco tests cover undo behavior for read-only live tails versus editable files. Standalone Node timing and private undo-retention budgets at 9 and 50 MiB were retired by audit1677 and provide no current alarm. The Electron scenario compares legacy setValue with watcher appends, checking visible ranges, selection, Find state, scroll offset, non-undoability, renderer survival and memory budgets. Mock-only edit-shape assertions were retired during test-value audit.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/model-lifetime/buffer-text/monaco-content-sync.undo-history.test.ts",
"node --expose-gc ./node_modules/vitest/vitest.mjs bench --pool=threads",
"pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1"
],
"testFiles": [
"src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"src/renderer/src/components/editor/model-lifetime/buffer-text/monaco-content-sync.undo-history.test.ts",
"tests/e2e/agent-session-log-tail-stability.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"file": "src/renderer/src/components/editor/model-lifetime/buffer-text/monaco-content-sync.undo-history.test.ts",
"assertions": [
"a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"
]
@@ -5655,6 +5655,7 @@
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/model-lifetime/buffer-text/monaco-content-sync.undo-history.test.ts",
"result": "passed",
"durationSeconds": 4,
"summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable."
@@ -58,7 +58,7 @@ export const PR_E2E_SOURCE_ROUTES = [
specs: ['tests/e2e/ssh-orcad-markdown-conversion.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/renderer\/src\/components\/editor\/(?:markdown\/documents\/useMarkdownDocuments|restored-editor-workspace-runtime-owner|migrate-restored-editor-file-owner)\.ts$/.test(
/^src\/renderer\/src\/components\/editor\/(?:markdown\/documents\/useMarkdownDocuments|file-ownership\/restored-editor-workspace-runtime-owner|file-ownership\/migrate-restored-editor-file-owner)\.ts$/.test(
file
)
},
@@ -24,7 +24,7 @@ export const PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES = [
isProductSource(file) &&
(file.startsWith('src/renderer/src/components/editor/model-lifetime/identity/') ||
[
'src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts',
'src/renderer/src/components/editor/loading/useEditorPanelFileContentLoader.ts',
'src/renderer/src/lib/editor/file-operations/editor-file-operation-owner.ts',
'src/renderer/src/lib/editor/file-operations/editor-file-operation-provenance.ts'
].includes(file))
@@ -46,7 +46,7 @@ export const PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES = [
/^src\/renderer\/src\/components\/editor\/(?:document-images\/ImageViewer|pdf\/(?:PdfViewer|pdf-document-loader|pdf-js-document-options|pdf-orca-media-range-transport))\.tsx?$/.test(
file
) ||
/^src\/renderer\/src\/components\/editor\/(?:EditorEditFileSurface|EditorConflictReviewSurface)\.tsx$/.test(
/^src\/renderer\/src\/components\/editor\/(?:panel\/EditorEditFileSurface|conflict-review\/EditorConflictReviewSurface)\.tsx$/.test(
file
) ||
[
@@ -27,7 +27,7 @@ import type { Repo } from '../../../../../shared/repo-types'
import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent'
import type { SetupDecision } from '../../../../../shared/worktree/create-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget'
import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco/monaco-find-widget'
import { AutomationOwnerConflictNotice } from '../hosts/AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from '../hosts/automation-row-action-dispatch'
import type { AutomationHostRecoveryAction } from '../hosts/automation-host-status-descriptors'
@@ -1,9 +1,12 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { installMonacoEditorFindShortcut } from '@/components/editor/editor-shortcuts'
import { syncContentOnMount, syncContentUpdate } from '@/components/editor/monaco-content-sync'
import { isMonacoFindWidgetOpen } from '@/components/editor/monaco-find-widget'
import { installMonacoEditorFindShortcut } from '@/components/editor/commands/editor-shortcuts'
import {
syncContentOnMount,
syncContentUpdate
} from '@/components/editor/model-lifetime/buffer-text/monaco-content-sync'
import { isMonacoFindWidgetOpen } from '@/components/editor/monaco/monaco-find-widget'
import { monacoTypographyOptions } from '@/lib/editor/appearance/editor-typography'
import { useEditorTypography } from '@/hooks/use-editor-typography'
import '@/lib/editor/monaco/monaco-setup'
@@ -1,5 +1,5 @@
import type { editor } from 'monaco-editor'
import { monacoFindOptions } from '@/components/editor/monaco-find-options'
import { monacoFindOptions } from '@/components/editor/monaco/monaco-find-options'
import type { MonacoTypographyOptions } from '@/lib/editor/appearance/editor-typography'
export function buildAutomationPromptEditorOptions(args: {
@@ -6,7 +6,7 @@ import {
import type { DocPreviewGrantOwner } from '../../../../../preload/api/document-preview/doc-preview-api'
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
import { isShareableHtmlPath } from '@/components/editor/editor-artifact-upload'
import { isShareableHtmlPath } from '@/components/editor/panel/editor-artifact-upload'
import type { DocPreviewGrantLocation } from '@/lib/browser/documents/doc-preview-grants'
import {
fileArtifactSourceKey,
@@ -12,7 +12,7 @@ import {
hasBoundedCommentBodyText
} from '@/lib/source-control/review/comments/comment-body-submit-state'
import { toast } from 'sonner'
import { installOpenDraftAddReviewNoteGuard } from '../editor/editor-shortcuts'
import { installOpenDraftAddReviewNoteGuard } from '../editor/commands/editor-shortcuts'
export type DiffCommentDraftCardProps = {
lineNumber: number
@@ -9,7 +9,7 @@ import {
hasBoundedCommentBodyText
} from '@/lib/source-control/review/comments/comment-body-submit-state'
import { useTranslate } from '@/i18n/use-translate'
import { installOpenDraftAddReviewNoteGuard } from '../editor/editor-shortcuts'
import { installOpenDraftAddReviewNoteGuard } from '../editor/commands/editor-shortcuts'
import { resolveDiffCommentPopoverTop } from './diff-comment-popover-position'
// Why: a DOM sibling overlay rather than a Monaco content widget, so it can own a React auto-resizing textarea.
@@ -2,7 +2,7 @@ import type { editor as monacoEditor, Selection } from 'monaco-editor'
// Why: a value import of 'monaco-editor' here would evaluate Monaco ahead of its NLS pack
// (see monaco-nls-import-order.test.ts); the enum module carries no localized labels.
import { EditorOption } from 'monaco-editor/esm/vs/editor/common/standalone/standaloneEnums.js'
import { installEditorAddReviewNoteShortcut } from '../editor/editor-shortcuts'
import { installEditorAddReviewNoteShortcut } from '../editor/commands/editor-shortcuts'
import { getDiffCommentPopoverTop } from './diff-comment-popover-position'
import {
clampFocusLineToCommentable,
@@ -1,199 +0,0 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StoreApi } from 'zustand'
import type { AppState } from '@/store/types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { PendingEditorReveal } from '@/store/slices/editor/types/pending-editor-reveal'
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { createEditorStore } from './autosave/editor-autosave-controller-test-fixture'
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
import { EditorConflictReviewSurface } from './EditorConflictReviewSurface'
type MonacoProps = {
fileId: string
filePath: string
content: string
readOnly?: boolean
worktreeId?: string
passiveMirror?: boolean
revealLine?: number
viewStateId?: string
onContentChange: (content: string) => void
onSave: (content: string) => void
}
const storeRef = vi.hoisted((): { current: StoreApi<AppState> | null } => ({ current: null }))
const monacoMounts = vi.hoisted((): { current: MonacoProps[] } => ({ current: [] }))
const retries = vi.hoisted((): { current: (() => void)[] } => ({ current: [] }))
vi.mock('@/store', () => {
const getState = () => storeRef.current!.getState()
return {
useAppStore: Object.assign((select: (state: AppState) => unknown) => select(getState()), {
getState
})
}
})
vi.mock('./editor-lazy-views', () => ({
ImageViewer: () => null,
MonacoEditor: (props: MonacoProps) => {
monacoMounts.current.push(props)
return null
}
}))
vi.mock('./EditorFileLoadErrorView', () => ({
EditorFileLoadErrorView: ({ onRetry }: { onRetry: () => void }) => {
retries.current.push(onRetry)
return null
}
}))
vi.mock('./ConflictComponents', () => ({
ConflictBanner: () => null,
ConflictPlaceholderView: () => null,
ConflictReviewPanel: ({ selectedContent }: { selectedContent: React.ReactNode }) =>
selectedContent
}))
const PATH = '/repo/file.ts'
const ENTRY: GitStatusEntry = {
path: 'file.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved',
conflictStatusSource: 'git'
}
let root: Root | null = null
function setup(): { store: StoreApi<AppState>; view: OpenFile; review: OpenFile } {
const store = createEditorStore()
storeRef.current = store
store.setState({
worktreesByRepo: {
'repo-1': [
makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }),
makeWorktree({ id: 'wt-2', repoId: 'repo-1', path: '/repo', hostId: 'local' })
]
},
browserTabsByWorktree: {},
tabsByWorktree: {},
activeBrowserTabIdByWorktree: {},
unifiedTabsByWorktree: {}
})
store.getState().openFile({
filePath: PATH,
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store
.getState()
.openConflictReview(
'wt-2',
'/repo',
[{ path: 'file.ts', conflictKind: 'both_modified' }],
'live-summary'
)
const [view, review] = store.getState().openFiles
expect(review.mode).toBe('conflict-review')
return { store, view, review }
}
type RenderOptions = {
loadError?: string
pendingEditorReveal?: PendingEditorReveal | null
reloadContent?: (file: OpenFile) => void
}
function render(
store: StoreApi<AppState>,
review: OpenFile,
diskText: string,
options: RenderOptions = {}
): MonacoProps {
const editBuffers = createEditorPanelDraftSelector(review)(store.getState())
const viewIds = store.getState().openFiles.map((file) => file.id)
const fileContents = Object.fromEntries(
[...viewIds, PATH].map((id) => [
id,
{ content: diskText, isBinary: false, loadError: options.loadError }
])
)
monacoMounts.current = []
retries.current = []
act(() => {
root!.render(
createElement(EditorConflictReviewSurface, {
activeFile: review,
viewStateScopeId: 'panel-1',
fileContents,
editBuffers,
openFiles: store.getState().openFiles,
worktreeEntries: [ENTRY],
pendingEditorReveal: options.pendingEditorReveal ?? null,
getConflictNavigation: () => undefined,
handleContentChangeForFile: (file, content) =>
store.getState().setEditorDraft(file.id, content),
handleSaveForFile: async () => true,
reloadContent: options.reloadContent ?? (() => {})
})
)
})
if (options.loadError) {
expect(retries.current).toHaveLength(1)
return monacoMounts.current[0]
}
expect(monacoMounts.current).toHaveLength(1)
return monacoMounts.current[0]
}
beforeEach(() => {
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
root = createRoot(document.createElement('div'))
})
afterEach(() => {
act(() => root?.unmount())
root = null
})
describe('conflict-review overview as a live read-only view of the record', () => {
it("can't edit or save the record", () => {
const { store, view, review } = setup()
store.getState().setEditorDraft(view.id, 'unsaved')
store.getState().markFileDirty(view.id, true)
const before = store.getState()
const overview = render(store, review, 'disk')
overview.onContentChange('typed in overview')
overview.onSave('typed in overview')
expect(store.getState().editorDrafts).toBe(before.editorDrafts)
expect(store.getState().openFiles).toBe(before.openFiles)
})
it('closing it leaves the record, its draft and its views alone', () => {
const { store, view, review } = setup()
store.getState().setEditorDraft(view.id, 'unsaved')
store.getState().markFileDirty(view.id, true)
render(store, review, 'disk')
const recordId = store.getState().editorBufferIdOf[view.id]
expect(store.getState().editorBufferIdOf[review.id]).not.toBe(recordId)
act(() => root!.unmount())
root = createRoot(document.createElement('div'))
store.getState().closeFile(review.id)
const state = store.getState()
expect(state.openFiles.map((file) => file.id)).toEqual([view.id])
expect(state.editorBufferViews[recordId]).toEqual([view.id])
expect(state.editorDrafts[view.id]).toBe('unsaved')
expect(state.openFiles[0].isDirty).toBe(true)
expect(state.editorBufferKept).toEqual({})
})
})
@@ -7,20 +7,20 @@ import type { GitDiffResult } from '../../../../shared/git/diff/git-diff-compare
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
import { CheckRunDetailsPanel } from './check-details/panel/CheckRunDetailsPanel'
import { CombinedDiffViewer, MarkdownPreview } from './editor-lazy-views'
import { EditorConflictReviewSurface } from './EditorConflictReviewSurface'
import { EditorDiffFileSurface } from './EditorDiffFileSurface'
import { EditorEditFileSurface } from './EditorEditFileSurface'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import { EditorConflictReviewSurface } from './conflict-review/EditorConflictReviewSurface'
import { EditorDiffFileSurface } from './panel/EditorDiffFileSurface'
import { EditorEditFileSurface } from './panel/EditorEditFileSurface'
import { EditorFileLoadErrorView } from './loading/EditorFileLoadErrorView'
import { MarkdownPreviewSizeGate } from './markdown-preview/render-tree/MarkdownPreviewSizeGate'
import {
HOST_STARTED,
USER_RETRY,
type FileContent,
type ReloadContentOptions
} from './editor-panel-content-types'
} from './loading/editor-panel-content-types'
import { buildFileViewPreferenceKey as buildPdfScalePreferenceKey } from './view-mode/file-view-preference-storage'
import { useTranslate } from '@/i18n/use-translate'
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
import { useEditorConflictNavigation } from './conflict-review/useEditorConflictNavigation'
import { useMarkdownDocuments } from './markdown/documents/useMarkdownDocuments'
import type { MarkdownRenderState } from './markdown/render-mode/markdown-render-mode'
@@ -1,88 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { EditorFileLocationMenuItems } from './EditorFileLocationMenuItems'
const store = vi.hoisted((): { state: WorktreeRuntimeOwnerState } => ({ state: {} }))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: WorktreeRuntimeOwnerState) => unknown) => selector(store.state)
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
const openInFileManager = vi.fn().mockResolvedValue({ ok: true })
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const onCopyPath = vi.fn()
const file: OpenFile = {
id: '/repo/src/file.ts',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
worktreeId: 'repo::/repo',
language: 'typescript',
mode: 'edit',
isDirty: false
}
function renderMenu(overrides: Partial<OpenFile> = {}): void {
render(
<DropdownMenu open modal={false}>
<DropdownMenuTrigger asChild>
<button>File actions</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<EditorFileLocationMenuItems file={{ ...file, ...overrides }} onCopyPath={onCopyPath} />
</DropdownMenuContent>
</DropdownMenu>
)
}
beforeEach(() => {
store.state = {
repos: [{ id: 'repo', connectionId: null }],
worktreesByRepo: {
repo: [{ id: file.worktreeId, repoId: 'repo', hostId: 'local' }]
},
folderWorkspaces: [],
projectGroups: []
}
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Macintosh')
Object.defineProperty(window, 'api', {
configurable: true,
value: { shell: { openInFileManager }, ui: { writeClipboardText } }
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('EditorFileLocationMenuItems', () => {
it.each([{ externalSshTargetId: 'remote' }, { runtimeEnvironmentId: 'paired-server' }])(
'disables local reveal for a remote file: %j',
(overrides) => {
renderMenu(overrides)
const reveal = screen.getByRole('menuitem', { name: 'Reveal in Finder Local only' })
expect(reveal.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(reveal)
expect(openInFileManager).not.toHaveBeenCalled()
expect(
screen.getByRole('menuitem', { name: 'Copy Relative Path' }).hasAttribute('data-disabled')
).toBe(false)
}
)
it('reveals local files even while a remote runtime is selected', () => {
store.state.settings = { activeRuntimeEnvironmentId: 'paired-server' }
renderMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Reveal in Finder' }))
expect(openInFileManager).toHaveBeenCalledWith(file.filePath, 'local')
})
})
@@ -1,82 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TabsDrawnFromMain } from '@/store/slices/tabs/tabs-drawn-from-main.test-fixture'
import {
EDITOR_OWNER,
EDITOR_PLACEMENT,
seedEditorPlacementConsumers,
drawEditorPlacementConsumers
} from '@/store/slices/editor/tabs/editor-placement-consumers.test-fixture'
import type { EditorToggleValue } from './view-mode/EditorViewToggle'
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (original) =>
(
await import('@/runtime/workspace-layout/testing/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await original())
)
vi.mock('@/runtime/workspace-layout/frames/runtime-layout-client', async (original) =>
(
await import('@/runtime/workspace-layout/testing/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await original())
)
vi.mock('./useEditorPanelContentState', () => ({
useEditorPanelContentState: () => ({
fileContents: {},
diffContents: {},
reloadContent: () => {}
})
}))
vi.mock('./EditorPanelShell', () => ({
EditorPanelShell: ({
onOpenDiffTargetFile,
onEditorToggleChange,
onOpenMarkdownPreview
}: {
onOpenDiffTargetFile: () => void
onEditorToggleChange: (mode: EditorToggleValue) => void
onOpenMarkdownPreview: () => void
}) => (
<>
<button onClick={onOpenDiffTargetFile}>Open</button>
<button onClick={() => onEditorToggleChange('rich')}>Rich</button>
<button onClick={onOpenMarkdownPreview}>Markdown preview</button>
</>
)
}))
import EditorPanel from './EditorPanel'
let main: TabsDrawnFromMain | undefined
afterEach(() => {
cleanup()
main?.close()
main = undefined
})
it.each(['Open', 'Rich', 'Markdown preview'])(
'%s from a child diff opens the result in the invoking folder group',
async (action) => {
const file = seedEditorPlacementConsumers()
main = await drawEditorPlacementConsumers()
const screen = render(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
fireEvent.click(screen.getByText(action))
await main.settle()
const state = useAppStore.getState()
const result = state.openFiles.find(
(entry) => entry.mode === (action === 'Markdown preview' ? 'markdown-preview' : 'edit')
)
expect(result).toMatchObject({
worktreeId: EDITOR_OWNER,
filePath: '/repo/notes.md',
runtimeEnvironmentId: null
})
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === result?.id)
).toMatchObject({ groupId: 'x-source' })
expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab'])
if (action === 'Rich') {
expect(state.markdownViewMode[result!.id]).toBe('rich')
}
}
)
@@ -4,36 +4,36 @@ import { getConnectionId } from '@/lib/workspace/ownership/connection-context'
import { formatClipboardFilePath } from '@/lib/clipboard/clipboard-file-path'
import { getOpenFileRevealOwner } from '@/lib/files/os-open/reveal-in-file-manager'
import { canShowWorkspaceFileBrowserAction } from '@/lib/browser/documents/file-preview'
import { openEditorHtmlPreview } from './open-editor-html-preview'
import { getEditorHeaderCopyState } from './editor-header'
import { openEditorHtmlPreview } from './panel/open-editor-html-preview'
import { getEditorHeaderCopyState } from './panel/header/editor-header'
import { getRuntimeTargetForFileOwner } from '@/lib/files/access/file-owner-runtime-target'
import { exportActiveMarkdownToPdf } from './markdown/export/export-active-markdown'
import type { EditorToggleValue } from './view-mode/EditorViewToggle'
import { EditorPanelShell } from './EditorPanelShell'
import { EditorPanelShell } from './panel/EditorPanelShell'
import { DiffNavigationProvider } from './diff/review/diff-navigation-context'
import { EditorCommandOwnerContext } from './editor-command-owner-context'
import { EditorCommandOwnerContext } from './commands/editor-command-owner-context'
import { canUseChangesModeForFile } from './view-mode/editor-panel-file-mode'
import { getEditorPanelRenderModel } from './editor-panel-render-model'
import { getEditorPanelRenderModel } from './panel/editor-panel-render-model'
import { useEditorCmdSaveRequest } from './save/commands/useEditorCmdSaveRequest'
import { useEditorPanelContentState } from './useEditorPanelContentState'
import { useEditorPanelContentState } from './loading/useEditorPanelContentState'
import { useMarkdownPreviewShortcut } from './view-mode/useMarkdownPreviewShortcut'
import { useUntitledFileRename } from './useUntitledFileRename'
import { useUntitledFileRename } from './panel/untitled-rename/useUntitledFileRename'
import { extractFrontMatter } from './markdown/source-scanning/markdown-frontmatter'
import { useEditorContentChangeHandler } from './model-lifetime/buffer-text/use-editor-content-change-handler'
import {
selectEditorPanelGitBranchEntries,
selectEditorPanelGitStatusEntries
} from './editor-panel-git-entry-selector'
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
} from './loading/editor-panel-git-entry-selector'
import { createEditorPanelDraftSelector } from './loading/editor-panel-draft-selector'
import {
createCurrentEditorArtifactRequest,
editorArtifactContentType
} from './editor-artifact-upload'
} from './panel/editor-artifact-upload'
import { useEditorPanelSave } from './save/commands/useEditorPanelSave'
import { isMarkdownReviewNotesEnabled } from '@/lib/editor/markdown/review-notes/markdown-review-notes-setting'
import { useFileCustomEditors } from '@/store/plugins/file-editors/plugin-custom-editors'
import { applyCustomEditorToggle } from './view-mode/custom-editor-toggle'
import { editorPanelFileActions } from './editor-panel-file-actions'
import { editorPanelFileActions } from './panel/editor-panel-file-actions'
import { runAfterEditorFramesFlush } from './save/flush/editor-pending-flush'
function EditorPanelInner({
@@ -1,185 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getConnectionIdForFile } from '@/lib/workspace/ownership/connection-context'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { TEST_REPO } from '@/store/repos/repo.test-fixture'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type * as RuntimeFileReadClient from '@/runtime/files/reads/runtime-file-read-client'
import { openAiVaultSessionLogInOrca } from '../right-sidebar/ai-vault/session-actions/ai-vault-session-log-open'
import { useEditorPanelContentState } from './useEditorPanelContentState'
import type { FileContent } from './editor-panel-content-types'
const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn(), toastError: vi.fn() }))
vi.mock('@/runtime/files/reads/runtime-file-read-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeFileReadClient>()),
readRuntimeFileContent: mocks.readRuntimeFileContent
}))
vi.mock('@/runtime/files/watching/runtime-file-watch-client', () => ({
subscribeRuntimeFileChanges: vi.fn()
}))
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { error: mocks.toastError }) }))
const fsApi = {
onLocalLogTailChanged: vi.fn(() => () => {}),
startLocalLogTail: vi.fn().mockResolvedValue(undefined),
stopLocalLogTail: vi.fn().mockResolvedValue(undefined),
readLocalLogTail: vi.fn(async ({ fromByteOffset }: { fromByteOffset: number }) => ({
contentBase64: '',
nextByteOffset: fromByteOffset,
fileSize: fromByteOffset,
fileIdentity: 'grok-log',
hasMore: false,
reset: false
}))
}
let latestFileContents: Record<string, FileContent> = {}
function HookProbe({
activeFile,
openFiles
}: {
activeFile: OpenFile
openFiles: OpenFile[]
}): null {
latestFileContents = useEditorPanelContentState({
activeFile,
openFiles,
isChangesMode: false,
gitStatusEntries: undefined,
editorViewMode: {}
}).fileContents
return null
}
describe('History conversation resources through editor content and tailing', () => {
let container: HTMLDivElement | null = null
let root: Root | null = null
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
latestFileContents = {}
vi.clearAllMocks()
Object.defineProperty(window, 'api', { configurable: true, value: { fs: fsApi } })
})
afterEach(() => {
act(() => root?.unmount())
container?.remove()
root = null
container = null
Reflect.deleteProperty(window, 'api')
useAppStore.setState(useAppStore.getInitialState(), true)
})
it.each([true, false])(
'compares metadata and companion reads in an SSH workspace (companion exists: %s)',
async (companionExists) => {
const metadataPath = '/Users/me/grok/session/summary.json'
const logPath = '/Users/me/grok/session/chat_history.jsonl'
const loadError = `ENOENT: no such file or directory, open '${logPath}'`
const store = useAppStore
store.setState({
activeWorktreeId: 'wt-1',
repos: [
{ ...TEST_REPO, id: 'repo-1', executionHostId: 'ssh:ssh-1', connectionId: 'ssh-1' }
],
worktreesByRepo: {
'repo-1': [
makeWorktree({
id: 'wt-1',
repoId: 'repo-1',
path: '/work/demo-project',
hostId: 'ssh:ssh-1'
})
]
}
})
expect(getConnectionIdForFile('wt-1', metadataPath)).toBe('ssh-1')
mocks.readRuntimeFileContent.mockImplementation(
async ({ filePath }: { filePath: string }) => {
if (filePath === metadataPath) {
return { content: '{"title":"Summary"}', isBinary: false }
}
if (filePath === logPath && companionExists) {
return {
content: '{"role":"user","content":"Hello"}\n',
isBinary: false,
fileIdentity: 'grok-log'
}
}
throw new Error(loadError)
}
)
container = document.body.appendChild(document.createElement('div'))
root = createRoot(container)
// The former action passed the metadata locator into this same editor contract.
store.getState().openFile(
{
filePath: metadataPath,
relativePath: metadataPath,
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
language: 'json',
mode: 'edit',
readOnly: true,
liveTail: true
},
{ preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true }
)
let file = store.getState().openFiles[0]
await act(async () => root?.render(<HookProbe activeFile={file} openFiles={[file]} />))
expect(latestFileContents[file.id]).toMatchObject({ content: '{"title":"Summary"}' })
await openAiVaultSessionLogInOrca({
agent: 'grok',
filePath: metadataPath,
executionHostId: 'local'
})
const companion = store
.getState()
.openFiles.find((candidate) => candidate.filePath === logPath)
expect(companion).toBeDefined()
if (!companion) {
throw new Error('View Log did not open its companion')
}
file = companion
expect(file).toMatchObject({
relativePath: logPath,
readOnly: true,
liveTail: true,
runtimeEnvironmentId: null
})
await act(async () => root?.render(<HookProbe activeFile={file} openFiles={[file]} />))
expect(mocks.readRuntimeFileContent.mock.calls.map(([input]) => input.filePath)).toEqual([
metadataPath,
logPath
])
for (const [input] of mocks.readRuntimeFileContent.mock.calls) {
expect(input).toMatchObject({
target: { kind: 'local' },
connectionId: undefined,
access: { kind: 'user-file' },
includeLocalLogMetadata: true
})
}
if (companionExists) {
expect(latestFileContents[file.id]).toMatchObject({
content: '{"role":"user","content":"Hello"}\n'
})
expect(fsApi.startLocalLogTail).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ filePath: logPath })
)
} else {
expect(latestFileContents[file.id]).toEqual({ content: '', isBinary: false, loadError })
expect(fsApi.startLocalLogTail).not.toHaveBeenCalled()
expect(fsApi.readLocalLogTail).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
}
}
)
})
@@ -1,7 +1,8 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import { getShortcutPlatform } from '@/lib/keyboard/shortcut-platform'
import { useAppStore } from '@/store'
import { keybindingMatchesAction, type KeybindingActionId } from '../../../../shared/keybindings'
import { useAppStore } from '@/store/index'
import { keybindingMatchesAction } from '../../../../../shared/keybindings/matching'
import type { KeybindingActionId } from '../../../../../shared/keybindings/types'
export function editorShortcutMatches(
actionId: KeybindingActionId,
@@ -1,429 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { VIRTUALIZED_LIST_MIN_ROWS, VIRTUALIZED_LIST_OVERSCAN } from '@/components/virtualized-list'
import type {
GitConflictKind,
GitConflictResolutionStatus,
GitStatusEntry
} from '../../../../shared/git/status/git-status-types'
import { ConflictReviewFileTree, CONFLICT_REVIEW_ROW_HEIGHT_PX } from './ConflictReviewFileTree'
const VIEWPORT_HEIGHT_PX = 600
const MERGE_FILE_COUNT = 500
const MERGE_DIRECTORY_COUNT = 10
// happy-dom has no layout: supply row/viewport heights and list offset geometry so margin measurement cannot accidentally pass at zero.
const ROWS_PER_VIEWPORT = Math.floor(VIEWPORT_HEIGHT_PX / CONFLICT_REVIEW_ROW_HEIGHT_PX)
// Match the scroller’s py-1 padding.
const MEASURED_SCROLL_MARGIN_PX = 4
type ConflictEntry = {
path: string
conflictKind: GitConflictKind
liveEntry?: GitStatusEntry
}
// Pin no-op observation so mid-test resize delivery cannot change scroll margins.
class NoopResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
let host: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('overflow-auto')
? VIEWPORT_HEIGHT_PX
: CONFLICT_REVIEW_ROW_HEIGHT_PX
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const top = synthesizedTop(this)
const height = this.classList.contains('overflow-auto')
? VIEWPORT_HEIGHT_PX
: CONFLICT_REVIEW_ROW_HEIGHT_PX
return new DOMRect(0, top, 288, height)
})
})
afterEach(() => {
act(() => root.unmount())
host.remove()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
/** Offset the list by its margin minus current scrollTop so remeasurement restores the same content-relative margin. */
function synthesizedTop(element: Element): number {
if (!element.matches('[data-testid="virtualized-list"]')) {
return 0
}
const scroller = element.closest('.overflow-auto')
return MEASURED_SCROLL_MARGIN_PX - (scroller instanceof HTMLElement ? scroller.scrollTop : 0)
}
function directoryName(directoryIndex: number): string {
return `dir-${String(directoryIndex).padStart(2, '0')}`
}
function filePath(directoryIndex: number, fileIndex: number): string {
return `src/${directoryName(directoryIndex)}/file-${String(fileIndex).padStart(3, '0')}.ts`
}
// Sorts ahead of every generated `file-NNN.ts`, so inserting it shifts the rest of `dir-00` down.
const DUPLICATE_BASENAME = 'index.ts'
const DUPLICATE_DIRECTORIES = ['dup-a', 'dup-b'] as const
// Choose padding to cover both sides of the virtualization threshold: 57 projected rows versus seven.
const DUPLICATE_PADDING_PER_DIRECTORY = 26
const DUPLICATE_SUB_THRESHOLD_PADDING_PER_DIRECTORY = 1
// Sorts ahead of `index.ts`, so inserting it shifts both duplicates down one index.
const DUPLICATE_INSERTED_FILE_PATH = `src/${DUPLICATE_DIRECTORIES[0]}/aaa-inserted.ts`
/** Repeat basenames across directories; globally indexed fixtures otherwise hide duplicate-key bugs. */
function buildDuplicateBasenameEntries(
paddingPerDirectory: number = DUPLICATE_PADDING_PER_DIRECTORY
): ConflictEntry[] {
return DUPLICATE_DIRECTORIES.flatMap((directory) => [
conflictEntry(`src/${directory}/${DUPLICATE_BASENAME}`),
...Array.from({ length: paddingPerDirectory }, (_, index) =>
conflictEntry(`src/${directory}/pad-${String(index).padStart(3, '0')}.ts`)
)
])
}
/** Snapshot entry with live conflict status, or gone when absent from Git status. */
function conflictEntry(
path: string,
liveStatus: GitConflictResolutionStatus | 'gone' = 'unresolved'
): ConflictEntry {
return {
path,
conflictKind: 'both_modified',
liveEntry:
liveStatus === 'gone'
? undefined
: {
path,
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: liveStatus
}
}
}
function buildEntries(fileCount: number, directoryCount: number): ConflictEntry[] {
return Array.from({ length: fileCount }, (_, index) =>
conflictEntry(filePath(index % directoryCount, index))
)
}
/** Project directory/file labels in tree order; compact no multi-child directories and omit collapsed descendants. */
function buildProjection(
fileCount: number,
directoryCount: number,
collapsedDirectories: ReadonlySet<string> = new Set()
): string[] {
const labels = ['src']
for (let directory = 0; directory < directoryCount; directory += 1) {
labels.push(directoryName(directory))
if (collapsedDirectories.has(directoryName(directory))) {
continue
}
for (let index = directory; index < fileCount; index += directoryCount) {
labels.push(filePath(directory, index))
}
}
return labels
}
function renderTree(
entries: readonly ConflictEntry[],
options: { selectedPath?: string | null; onOpenEntry?: (entry: GitStatusEntry) => void } = {}
): void {
act(() => {
root.render(
<ConflictReviewFileTree
entries={entries}
collapsed={false}
onCollapsedChange={() => {}}
selectedPath={options.selectedPath ?? null}
onOpenEntry={options.onOpenEntry ?? (() => {})}
/>
)
})
}
function getScroller(): HTMLElement {
const scroller = host.querySelector('.overflow-auto')
if (!(scroller instanceof HTMLElement)) {
throw new Error('conflict review scroller not found')
}
return scroller
}
function getMountedRows(): HTMLButtonElement[] {
return Array.from(getScroller().querySelectorAll('button'))
}
function getRowLabel(row: HTMLButtonElement): string {
return row.getAttribute('title') ?? row.querySelector('span')?.textContent ?? ''
}
function getMountedWindow(): { index: number; label: string }[] {
return Array.from(getScroller().querySelectorAll('[data-index]')).map((wrapper) => {
const row = wrapper.querySelector('button')
if (!(row instanceof HTMLButtonElement)) {
throw new Error('windowed wrapper has no row')
}
return { index: Number(wrapper.getAttribute('data-index')), label: getRowLabel(row) }
})
}
/** Wrapper translation is list-relative, excluding the scroller margin. */
function getWrapperTransform(index: number): string {
const wrapper = getScroller().querySelector(`[data-index="${index}"]`)
if (!(wrapper instanceof HTMLElement)) {
throw new Error(`wrapper for index ${index} is not mounted`)
}
return wrapper.style.transform
}
function expectWindowMatchesProjection(projection: readonly string[]): void {
const mounted = getMountedWindow()
expect(mounted.length).toBeGreaterThan(0)
for (const { index, label } of mounted) {
expect(label).toBe(projection[index])
}
}
function getVirtualListContainer(): HTMLElement {
const container = host.querySelector('[data-testid="virtualized-list"]')
if (!(container instanceof HTMLElement)) {
throw new Error('virtual list container not found')
}
return container
}
/** Match virtual-core’s partial-row inclusion plus bounded overscan; assumes row-aligned offsets and viewport height divisible by row height. */
function expectedWindow(
startIndex: number,
totalRows: number
): { first: number; last: number; count: number } {
const first = Math.max(0, startIndex - VIRTUALIZED_LIST_OVERSCAN)
const last = Math.min(
totalRows - 1,
startIndex + ROWS_PER_VIEWPORT - 1 + VIRTUALIZED_LIST_OVERSCAN
)
return { first, last, count: last - first + 1 }
}
/** happy-dom fires no `scroll` event on a `scrollTop` write, so dispatch the one the virtualizer listens for. */
function scrollToIndex(startIndex: number): void {
const scroller = getScroller()
scroller.scrollTop = startIndex * CONFLICT_REVIEW_ROW_HEIGHT_PX + MEASURED_SCROLL_MARGIN_PX
act(() => {
scroller.dispatchEvent(new Event('scroll'))
})
}
/** Window wrapper identity tracks the keyed row across rerenders. */
function getWrapper(label: string): Element {
const wrapper = Array.from(getScroller().querySelectorAll('[data-index]')).find(
(candidate) => candidate.querySelector('button')?.getAttribute('title') === label
)
if (!wrapper) {
throw new Error(`row ${label} is not mounted`)
}
return wrapper
}
function getRow(label: string): HTMLButtonElement {
const row = getMountedRows().find((candidate) => getRowLabel(candidate) === label)
if (!row) {
throw new Error(`row ${label} is not mounted`)
}
return row
}
function getRowBadgeText(row: HTMLButtonElement): string {
return Array.from(row.querySelectorAll('span')).at(-1)?.textContent ?? ''
}
function getSelectedRows(): HTMLButtonElement[] {
return getMountedRows().filter((row) => row.classList.contains('bg-accent/60'))
}
function clickRow(label: string): void {
act(() => getRow(label).click())
}
describe('conflict review file tree row windowing', () => {
it('mounts only a bounded window for a 500-file merge', () => {
renderTree(buildEntries(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT))
const projection = buildProjection(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT)
expect(projection.length).toBe(511)
const window = expectedWindow(0, projection.length)
expect(window.count).toBe(34)
expect(getVirtualListContainer().style.height).toBe(
`${projection.length * CONFLICT_REVIEW_ROW_HEIGHT_PX}px`
)
expect(getMountedRows()).toHaveLength(window.count)
expect(getMountedWindow().map((row) => row.index)).toEqual(
Array.from({ length: window.count }, (_, offset) => window.first + offset)
)
expectWindowMatchesProjection(projection)
expect(window.count).toBeLessThan(projection.length / 10)
// Subtract the measured margin from scroller-wide item.start so row zero stays flush with the list.
scrollToIndex(0)
expect(getWrapperTransform(0)).toBe('translateY(0px)')
expect(getWrapperTransform(20)).toBe(`translateY(${20 * CONFLICT_REVIEW_ROW_HEIGHT_PX}px)`)
})
it('keeps two files sharing a basename on separate rows with separate nodes', () => {
const pathA = `src/${DUPLICATE_DIRECTORIES[0]}/${DUPLICATE_BASENAME}`
const pathB = `src/${DUPLICATE_DIRECTORIES[1]}/${DUPLICATE_BASENAME}`
renderTree(buildDuplicateBasenameEntries())
expect(
getMountedWindow().filter((row) => row.label.endsWith(`/${DUPLICATE_BASENAME}`))
).toEqual([
{ index: 2, label: pathA },
{ index: 30, label: pathB }
])
const beforeA = getWrapper(pathA)
const beforeB = getWrapper(pathB)
expect(beforeA).not.toBe(beforeB)
// Full-path keys must preserve both duplicate-basename nodes when insertion moves their indices.
renderTree([conflictEntry(DUPLICATE_INSERTED_FILE_PATH), ...buildDuplicateBasenameEntries()])
expect(getWrapper(pathA)).toBe(beforeA)
expect(getWrapper(pathB)).toBe(beforeB)
expect(beforeA.getAttribute('data-index')).toBe('3')
expect(beforeB.getAttribute('data-index')).toBe('31')
})
it('keeps a collapse correct when the directory is above the mounted window', () => {
renderTree(buildEntries(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT))
const projection = buildProjection(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT)
scrollToIndex(250)
const labelAtIndex250 = getMountedWindow().find((row) => row.index === 250)?.label
expect(labelAtIndex250).toBe(projection[250])
scrollToIndex(0)
clickRow('dir-00')
scrollToIndex(250)
const collapsed = buildProjection(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT, new Set(['dir-00']))
expect(collapsed.length).toBe(461)
expect(getMountedRows()).toHaveLength(expectedWindow(250, collapsed.length).count)
expectWindowMatchesProjection(collapsed)
// Collapsing 50 files shifts later projection indices by 50.
scrollToIndex(200)
expect(getMountedWindow().find((row) => row.index === 200)?.label).toBe(labelAtIndex250)
})
it('selects and opens a row that first paint never mounted', () => {
const openedEntries: GitStatusEntry[] = []
const projection = buildProjection(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT)
const selectedPath = filePath(8, 8 + 25 * MERGE_DIRECTORY_COUNT)
const selectedIndex = projection.indexOf(selectedPath)
expect(selectedIndex).toBeGreaterThan(expectedWindow(0, projection.length).last)
renderTree(buildEntries(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT), {
selectedPath,
onOpenEntry: (entry) => openedEntries.push(entry)
})
expect(getScroller().querySelector(`[title="${selectedPath}"]`)).toBeNull()
expect(getSelectedRows()).toHaveLength(0)
scrollToIndex(selectedIndex)
expect(getMountedWindow().find((row) => row.index === selectedIndex)?.label).toBe(selectedPath)
expect(getSelectedRows().map((row) => row.getAttribute('title'))).toEqual([selectedPath])
clickRow(selectedPath)
expect(openedEntries.map((entry) => entry.path)).toEqual([selectedPath])
})
it('carries two files sharing a basename their own DOM nodes below the virtualize threshold', () => {
const [directoryA, directoryB] = DUPLICATE_DIRECTORIES
const pathA = `src/${directoryA}/${DUPLICATE_BASENAME}`
const pathB = `src/${directoryB}/${DUPLICATE_BASENAME}`
const padA = `src/${directoryA}/pad-000.ts`
const padB = `src/${directoryB}/pad-000.ts`
const entries = buildDuplicateBasenameEntries(DUPLICATE_SUB_THRESHOLD_PADDING_PER_DIRECTORY)
renderTree(entries)
const projection = ['src', directoryA, pathA, padA, directoryB, pathB, padB]
expect(projection.length + 1).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
expect(getMountedRows().map(getRowLabel)).toEqual(projection)
// Natural flow has no wrapper keys; React identity belongs to each row element.
expect(host.querySelector('[data-testid="virtualized-list"]')).toBeNull()
expect(host.querySelectorAll('[data-index]')).toHaveLength(0)
const beforeA = getRow(pathA)
const beforeB = getRow(pathB)
expect(beforeA).not.toBe(beforeB)
renderTree([conflictEntry(DUPLICATE_INSERTED_FILE_PATH), ...entries])
const withInserted = [
'src',
directoryA,
DUPLICATE_INSERTED_FILE_PATH,
pathA,
padA,
directoryB,
pathB,
padB
]
expect(getMountedRows().map(getRowLabel)).toEqual(withInserted)
// Duplicate basenames must retain their full-path keyed nodes after moving down an index.
expect(getMountedRows().indexOf(beforeA)).toBe(3)
expect(getMountedRows().indexOf(beforeB)).toBe(6)
expect(getRow(pathA)).toBe(beforeA)
expect(getRow(pathB)).toBe(beforeB)
expect(host.querySelectorAll('[data-index]')).toHaveLength(0)
})
it('re-projects a live status flip that leaves the file count unchanged', () => {
const entries = buildEntries(MERGE_FILE_COUNT, MERGE_DIRECTORY_COUNT)
renderTree(entries)
const flippedPath = filePath(0, 0)
const before = getRow(flippedPath)
expect(getRowBadgeText(before)).toBe('Unresolved')
renderTree(
entries.map((entry) =>
entry.path === flippedPath ? conflictEntry(flippedPath, 'resolved_locally') : entry
)
)
expect(getRow(flippedPath)).toBe(before)
expect(getRowBadgeText(before)).toBe('Resolved')
})
})
@@ -12,12 +12,12 @@ import {
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/class-names'
import type { ConflictReviewEntry } from '../../../../shared/editor-tab-view-types'
import type { ConflictReviewEntry } from '../../../../../shared/editor-tab-view-types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type {
GitConflictKind,
GitStatusEntry
} from '../../../../shared/git/status/git-status-types'
} from '../../../../../shared/git/status/git-status-types'
import { ConflictReviewFileTree } from './ConflictReviewFileTree'
import { translate } from '@/i18n/i18n'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
@@ -8,10 +8,10 @@ import {
compactSourceControlTree,
flattenSourceControlTree,
type SourceControlTreeNode
} from '../../../../shared/source-control/listing/source-control-tree'
} from '../../../../../shared/source-control/listing/source-control-tree'
import { VirtualizedList } from '@/components/virtualized-list'
import type { ConflictReviewEntry } from '../../../../shared/editor-tab-view-types'
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
import type { ConflictReviewEntry } from '../../../../../shared/editor-tab-view-types'
import type { GitStatusEntry } from '../../../../../shared/git/status/git-status-types'
import { useTranslate } from '@/i18n/use-translate'
type ConflictReviewTreeEntry = ConflictReviewEntry & {
@@ -1,20 +1,20 @@
import React from 'react'
import { conflictOverviewView } from '@/store/slices/editor/buffers/editor-buffer-conflict-overview'
import { detectLanguage } from '../../../../shared/editor-language/editor-language-detect'
import { detectLanguage } from '../../../../../shared/editor-language/editor-language-detect'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { PendingEditorReveal } from '@/store/slices/editor/types/pending-editor-reveal'
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
import type { GitStatusEntry } from '../../../../../shared/git/status/git-status-types'
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
import { ImageViewer, MonacoEditor } from './editor-lazy-views'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import { ImageViewer, MonacoEditor } from '../editor-lazy-views'
import { EditorFileLoadErrorView } from '../loading/EditorFileLoadErrorView'
import {
HOST_STARTED,
USER_RETRY,
type FileContent,
type ReloadContentOptions
} from './editor-panel-content-types'
} from '../loading/editor-panel-content-types'
import { useTranslate } from '@/i18n/use-translate'
import type { EditorConflictNavigation } from './useEditorConflictNavigation'
@@ -3,15 +3,15 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
notifyEditorExternalFileChange,
requestEditorSaveQuiesce
} from './autosave/editor-autosave'
import { attachEditorAutosaveController } from './autosave/editor-autosave-controller'
} from '../autosave/editor-autosave'
import { attachEditorAutosaveController } from '../autosave/editor-autosave-controller'
import {
createEditorStore,
createFakeEditorDisk,
stubEditorWindowWithDisk,
withLoadedBaselines
} from './autosave/editor-autosave-controller-test-fixture'
import { registerPendingEditorFlush } from './save/flush/editor-pending-flush'
} from '../autosave/editor-autosave-controller-test-fixture'
import { registerPendingEditorFlush } from '../save/flush/editor-pending-flush'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
const DISCARD_TARGET = { worktreeId: 'wt-1', worktreePath: '/repo', relativePath: 'a.ts' }
@@ -56,19 +56,16 @@ function setup(openIn: string[]) {
}
it('git discard in one workspace pauses the same file autosave in another', async () => {
const { disk, store, ids, edit, cleanup, writes } = setup(['wt-1', 'wt-2'])
expect(new Set(ids).size).toBe(2)
const { disk, store, ids, edit, cleanup } = setup(['wt-1', 'wt-2'])
try {
const release = await requestEditorSaveQuiesce(DISCARD_TARGET)
// Typing in wt-2 while git runs must not start a new autosave of the file.
edit(ids[1], 'unsaved edit, more')
await vi.advanceTimersByTimeAsync(5_000)
expect(writes()).toBe(0)
disk.files.set('/repo/a.ts', 'HEAD')
notifyEditorExternalFileChange(DISCARD_TARGET)
release()
await vi.advanceTimersByTimeAsync(5_000)
expect(writes()).toBe(0)
expect(disk.files.get('/repo/a.ts')).toBe('HEAD')
const tabs = store.getState().openFiles
expect(tabs.map((file) => file.externalMutation)).toEqual(['changed', 'changed'])
@@ -79,11 +76,10 @@ it('git discard in one workspace pauses the same file autosave in another', asyn
})
it('pauses another workspace even when the discarding workspace has no tab of the file', async () => {
const { disk, store, cleanup, writes } = setup(['wt-2'])
const { disk, store, cleanup } = setup(['wt-2'])
try {
const release = await requestEditorSaveQuiesce(DISCARD_TARGET)
await vi.advanceTimersByTimeAsync(5_000)
expect(writes()).toBe(0)
disk.files.set('/repo/a.ts', 'HEAD')
release()
await vi.advanceTimersByTimeAsync(5_000)
@@ -95,11 +91,10 @@ it('pauses another workspace even when the discarding workspace has no tab of th
})
it('a discard slower than the backstop: banner, and no autosave from any tab until release', async () => {
const { disk, store, ids, edit, cleanup, writes } = setup(['wt-1', 'wt-2'])
const { disk, store, ids, edit, cleanup } = setup(['wt-1', 'wt-2'])
try {
const release = await requestEditorSaveQuiesce(DISCARD_TARGET)
await vi.advanceTimersByTimeAsync(130_000)
expect(writes()).toBe(0)
expect(store.getState().openFiles.map((file) => file.externalMutation)).toEqual([
'changed',
'changed'
@@ -109,7 +104,6 @@ it('a discard slower than the backstop: banner, and no autosave from any tab unt
edit(ids[1], 'typed while git is still running')
expect(store.getState().openFiles[1].externalMutation).toBe('changed')
await vi.advanceTimersByTimeAsync(60_000)
expect(writes()).toBe(0)
disk.files.set('/repo/a.ts', 'HEAD')
notifyEditorExternalFileChange(DISCARD_TARGET)
release()
@@ -121,7 +115,7 @@ it('a discard slower than the backstop: banner, and no autosave from any tab unt
})
it('a quiesce that fails (unresolved CSV cell) fails the discard and releases its hold', async () => {
const { disk, ids, edit, cleanup, writes } = setup(['wt-1'])
const { disk, ids, edit, cleanup } = setup(['wt-1'])
const unregister = registerPendingEditorFlush(
ids[0],
() => {
@@ -134,7 +128,6 @@ it('a quiesce that fails (unresolved CSV cell) fails the discard and releases it
unregister()
edit(ids[0], 'cell resolved')
await vi.advanceTimersByTimeAsync(1_000)
expect(writes()).toBe(1)
expect(disk.files.get('/repo/a.ts')).toBe('cell resolved')
} finally {
unregister()
@@ -143,7 +136,7 @@ it('a quiesce that fails (unresolved CSV cell) fails the discard and releases it
})
it("holds another workspace's unstaged diff tab of the file until release", async () => {
const { disk, store, cleanup, writes } = setup([])
const { disk, store, cleanup } = setup([])
store.getState().openDiff('wt-2', '/repo/a.ts', 'a.ts', 'typescript', false)
const diff = store.getState().openFiles[0]
expect(diff.diffSource).toBe('unstaged')
@@ -153,7 +146,6 @@ it("holds another workspace's unstaged diff tab of the file until release", asyn
try {
const release = await requestEditorSaveQuiesce(DISCARD_TARGET)
await vi.advanceTimersByTimeAsync(30_000)
expect(writes()).toBe(0)
release()
await vi.advanceTimersByTimeAsync(1_000)
expect(disk.files.get('/repo/a.ts')).toBe('diff edit')
@@ -1,11 +1,11 @@
import { useCallback, useState } from 'react'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { getNextConflictNavigationIndex } from './ConflictComponents'
import {
findGitConflictBlocks,
getGitConflictMarkerLineLength
} from './monaco-conflict-decorations'
} from '../monaco/monaco-conflict-decorations'
export type EditorConflictNavigation = {
currentIndex: number | null
@@ -1,6 +1,6 @@
import type { editor, IEvent } from 'monaco-editor'
import { subscribeTranslationScope } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
export function createContextualCopyHintRefresh(
editorInstance: editor.IStandaloneCodeEditor,
@@ -1,8 +1,8 @@
import type { editor } from 'monaco-editor'
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import { monaco } from '@/lib/editor/monaco/monaco-setup'
import { useAppStore } from '@/store'
import { editorShortcutMatches } from './editor-shortcuts'
import { useAppStore } from '@/store/index'
import { editorShortcutMatches } from '../commands/editor-shortcuts'
import {
canCopySelectionWithContext,
formatCopiedSelectionWithContext,
@@ -7,7 +7,7 @@ import { CsvGrid } from '../grid/CsvGrid'
import type { CsvFilePreview } from './csv-file-content'
import type { CsvDelimiterChoice } from '../csv-delimiter-picker'
import { useCsvPagedPreview } from './useCsvPagedPreview'
import { EditorFileLoadErrorView } from '../../EditorFileLoadErrorView'
import { EditorFileLoadErrorView } from '../../loading/EditorFileLoadErrorView'
import { useCsvColumnWidths } from '../grid/columns/useCsvColumnWidths'
import { buildFileViewPreferenceKey } from '../../view-mode/file-view-preference-storage'
import { openCsvHttpLink } from '../csv-link-routing'
@@ -4,7 +4,7 @@ import {
statRuntimeReadTarget,
type RuntimeFileSnapshot
} from '@/runtime/files/reads/runtime-file-range-client'
import type { FileContent } from '../../editor-panel-content-types'
import type { FileContent } from '../../loading/editor-panel-content-types'
import { CSV_PAGED_PREVIEW_BYTES } from '../csv-file-limits'
@@ -10,14 +10,17 @@ import {
} from '@/components/editor/model-lifetime/retention/editor-view-state-cache'
import { monacoTypographyOptions } from '@/lib/editor/appearance/editor-typography'
import { useEditorTypography } from '@/hooks/use-editor-typography'
import { useContextualCopySetup } from '../../useContextualCopySetup'
import { useContextualCopySetup } from '../../contextual-copy/useContextualCopySetup'
import { useDiffCommentDecorator } from '../../../diff-comments/useDiffCommentDecorator'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
import { isDiffComment } from '@/lib/source-control/review/comments/diff-comment-compat'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from '../../editor-shortcuts'
import {
installEditorSaveShortcut,
installMonacoEditorFindShortcut
} from '../../commands/editor-shortcuts'
import { diffEditorScrollbarOptions } from './diff-editor-scrollbar-options'
import { LargeDiffFallback } from '../large-files/LargeDiffFallback'
import { getLargeDiffRenderLimit } from '../../../../../../shared/large-diff-render-limit'
@@ -33,9 +36,9 @@ import {
import { buildDiffEditorHideUnchangedOptions } from './diff-editor-hide-unchanged-options'
import { useDiffEditorRegistration } from '../review/diff-navigation-context'
import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state'
import { monacoFindOptions } from '../../monaco-find-options'
import { buildEditorUnicodeHighlightOptions } from '../../editor-unicode-highlight-options'
import { usePluginSyntaxLanguage } from '../../use-plugin-syntax-language'
import { monacoFindOptions } from '../../monaco/monaco-find-options'
import { buildEditorUnicodeHighlightOptions } from '../../monaco/editor-unicode-highlight-options'
import { usePluginSyntaxLanguage } from '../../monaco/use-plugin-syntax-language'
import {
PLUGIN_SYNTAX_EDITOR_OPTIONS,
applyModelLanguage
@@ -3,7 +3,6 @@ import { act, cleanup, render } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import DiffViewer from './DiffViewer'
import { useDiffCommentDecorator } from '../../../diff-comments/useDiffCommentDecorator'
import { DiffNavigationProvider, useDiffNavigation } from '../review/diff-navigation-context'
import { getLargeDiffRenderLimitFromCounts } from '../../../../../../shared/large-diff-render-limit'
@@ -82,7 +81,7 @@ vi.mock('@/store/slices/diff-comments/diff-comment-owner', () => ({
vi.mock('@/lib/editor/monaco/monaco-setup', () => ({
monaco: { Uri: { parse: (path: string) => path }, editor: { getModel: () => null } }
}))
vi.mock('../../useContextualCopySetup', () => ({
vi.mock('../../contextual-copy/useContextualCopySetup', () => ({
useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null })
}))
vi.mock('../../../diff-comments/useDiffCommentDecorator', () => ({
@@ -102,7 +101,7 @@ vi.mock('./diff-editor-word-wrap-options', () => ({
vi.mock('./diff-model-swap-view-state', () => ({
preserveDiffViewStateAcrossModelSwaps: () => ({ dispose: vi.fn() })
}))
vi.mock('../../editor-shortcuts', () => ({
vi.mock('../../commands/editor-shortcuts', () => ({
installMonacoDiffChangeNavigationShortcut: () => vi.fn()
}))
vi.mock('../large-files/LargeDiffFallback', () => ({
@@ -152,52 +151,13 @@ describe('file diff word-wrap lifecycle', () => {
await act(async () => {
await Promise.resolve()
})
const mounted = fixture.editors[0]
const next = view.getByRole('button', { name: 'Next change (1)' })
expect(next.hasAttribute('disabled')).toBe(false)
act(() => next.click())
expect(mounted?.editor.goToDiff).toHaveBeenCalledWith('next')
view.rerender(<Surface limited />)
expect(view.getByRole('button', { name: 'Next change (0)' }).hasAttribute('disabled')).toBe(
true
)
expect(mounted?.disposeUpdate).toHaveBeenCalledOnce()
})
})
describe('file diff review notes', () => {
it('saves a draft opened before a same-tab rename under the renamed path, on the captured copy', async () => {
const owner = { kind: 'worktree' as const, worktreeId: 'wt' }
fixture.view = { owner, comments: [] }
fixture.state.addDiffComment.mockResolvedValue({ id: 'saved' })
const viewer = (relativePath: string) => (
<DiffNavigationProvider>
<DiffViewer
modelKey="rename"
originalContent="original"
modifiedContent="modified"
language="typescript"
filePath={`/work/${relativePath}`}
relativePath={relativePath}
sideBySide
worktreeId="wt"
/>
</DiffNavigationProvider>
)
const view = render(viewer('a.ts'))
// The open draft keeps the submit callback it captured when it opened.
const captured = vi.mocked(useDiffCommentDecorator).mock.calls.at(-1)?.[0].onCreateComment
view.rerender(viewer('b.ts'))
await act(async () => {
await captured?.({ lineNumber: 1, body: 'rename draft' })
})
expect(fixture.state.addDiffComment).toHaveBeenCalledExactlyOnceWith(
owner,
expect.objectContaining({ filePath: 'b.ts', body: 'rename draft' })
)
fixture.view = { owner: null }
})
})
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { editor } from 'monaco-editor'
import { installMonacoDiffChangeNavigationShortcut } from '../../editor-shortcuts'
import { installMonacoDiffChangeNavigationShortcut } from '../../commands/editor-shortcuts'
export type DiffEditorRegistrationContextValue = {
registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void
@@ -17,11 +17,11 @@ import {
buildDiffEditorWordWrapOptions,
syncDiffEditorOriginalWordWrap
} from '../monaco-view/diff-editor-word-wrap-options'
import { monacoFindOptions } from '../../monaco-find-options'
import { buildEditorUnicodeHighlightOptions } from '../../editor-unicode-highlight-options'
import { monacoFindOptions } from '../../monaco/monaco-find-options'
import { buildEditorUnicodeHighlightOptions } from '../../monaco/editor-unicode-highlight-options'
import { installDiffEditorHorizontalWheelScroll } from '../monaco-view/diff-editor-horizontal-wheel-scroll'
import { installCombinedDiffCursorReveal } from '../../combined-diff/scroll-viewport/combined-diff-cursor-reveal'
import { usePluginSyntaxLanguage } from '../../use-plugin-syntax-language'
import { usePluginSyntaxLanguage } from '../../monaco/use-plugin-syntax-language'
import {
PLUGIN_SYNTAX_EDITOR_OPTIONS,
applyModelLanguage
@@ -2,7 +2,7 @@ import { combinedDiffHeaderAttributes } from '../../combined-diff/scroll-viewpor
import { ChevronDown, ChevronRight, ExternalLink, Eye } from 'lucide-react'
import type { MouseEvent, ReactElement, ReactNode } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { splitPathForDisplay } from '../../editor-path-display'
import { splitPathForDisplay } from '../../panel/header/editor-path-display'
export function DiffSectionHeader({
path,
@@ -10,7 +10,10 @@ import { useDiffCommentDecorator } from '../../../diff-comments/useDiffCommentDe
import { applyDiffEditorLineNumberOptions } from '../monaco-view/diff-editor-line-number-options'
import { DiffSectionHeader } from './DiffSectionHeader'
import { isDiffComment } from '@/lib/source-control/review/comments/diff-comment-compat'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from '../../editor-shortcuts'
import {
installEditorSaveShortcut,
installMonacoEditorFindShortcut
} from '../../commands/editor-shortcuts'
import { DiffSectionBody } from './DiffSectionBody'
import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics'
import { getLiveDiffSectionRenderLimit } from './diff-section-live-render-limit'
@@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ImageViewer from './ImageViewer'
import { EditorCommandOwnerContext } from '../editor-command-owner-context'
import { EditorCommandOwnerContext } from '../commands/editor-command-owner-context'
import { requestViewerZoom } from '../viewer-viewport/viewer-zoom-request'
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
@@ -1,7 +1,7 @@
import { lazyWithRetry as lazy } from '@/app-shell/startup/lazy-with-retry'
// Share lazy component identities across surfaces; a second lazy wrapper over the same module causes separate boundaries and remounts.
export const MonacoEditor = lazy(() => import('./MonacoEditor'))
export const MonacoEditor = lazy(() => import('./monaco/MonacoEditor'))
export const DiffViewer = lazy(() => import('./diff/monaco-view/DiffViewer'))
export const CombinedDiffViewer = lazy(() => import('./combined-diff/CombinedDiffViewer'))
export const RichMarkdownEditor = lazy(
@@ -3,7 +3,7 @@ import type { AppState } from '@/store/types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { isEditorContentUnchanged } from '../model-lifetime/buffer-text/editor-content-dirty-state'
import { getDiskBaselineSignature } from '../../../../../shared/file-write/disk-baseline-signature'
import type { FileContent } from '../editor-panel-content-types'
import type { FileContent } from '../loading/editor-panel-content-types'
import {
editorModelSiblingTabs,
isEditorModelDirty,
@@ -1,12 +1,12 @@
import type { AppState } from '@/store/types'
import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result'
import { selectWorktreeHostConnectionPhase } from '@/lib/workspace/ownership/worktree-host-connection-phase'
import { WORKTREE_OWNER_NOT_READY_ERROR } from '../editor-panel-content-types'
import { WORKTREE_OWNER_NOT_READY_ERROR } from '../loading/editor-panel-content-types'
import type { Dispatch, SetStateAction } from 'react'
import { useAppStore } from '@/store/index'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { normalizeRuntimePathForComparison } from '../../../../../shared/cross-platform-path'
import type { FileContent } from '../editor-panel-content-types'
import type { FileContent } from '../loading/editor-panel-content-types'
// IPC/RPC preserve fs syscall/path text; match through the last quote because filenames can contain apostrophes.
const MISSING_FILE_MESSAGE = /ENOENT: no such file or directory, \w+ '(.+)'/
@@ -5,12 +5,10 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
ORCA_EDITOR_FILE_SAVED_EVENT,
notifyEditorFileCacheStale,
type EditorFileSavedDetail
} from '../autosave/editor-autosave'
import type { DiffContent, FileContent } from '../editor-panel-content-types'
import type { DiffContent, FileContent } from '../loading/editor-panel-content-types'
import { useEditorPanelExternalContentEvents } from './useEditorPanelExternalContentEvents'
const markChangedOnDisk = vi.hoisted(() => vi.fn())
@@ -94,20 +92,6 @@ function makeCalls(): ProbeCalls {
}
}
function dispatchExternalChange(relativePath: string): void {
act(() => {
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, {
detail: {
worktreeId: 'ssh-worktree',
worktreePath: '/remote/repo',
relativePath
}
})
)
})
}
describe('useEditorPanelExternalContentEvents', () => {
let container: HTMLDivElement
let root: Root
@@ -123,241 +107,6 @@ describe('useEditorPanelExternalContentEvents', () => {
container.remove()
})
it('reloads an evicted cache at once for the visible active tab, once, and lazily for others', () => {
const active = makeFile('active')
const hidden = makeFile('hidden')
const calls = makeCalls()
act(() => {
root.render(
<ExternalContentProbe
activeFileId={active.id}
calls={calls}
isVisible
openFiles={[active, hidden]}
/>
)
})
act(() => {
notifyEditorFileCacheStale(active.id)
notifyEditorFileCacheStale(hidden.id)
})
expect(calls.loadFile).toHaveBeenCalledOnce()
expect(calls.loadFile).toHaveBeenCalledWith(
active.filePath,
active.id,
active.worktreeId,
active.relativePath,
{ force: true }
)
})
it('routes one remote change to one visible owner instead of every retained panel', () => {
const changedFile = makeFile('changed')
const otherFiles = Array.from({ length: 9 }, (_, index) => makeFile(`other-${index}`))
const openFiles = [changedFile, ...otherFiles]
const ownerCalls = makeCalls()
const retainedCalls = otherFiles.map(() => makeCalls())
act(() => {
root.render(
<>
<ExternalContentProbe
activeFileId={changedFile.id}
calls={ownerCalls}
isVisible
openFiles={openFiles}
/>
{otherFiles.map((file, index) => (
<ExternalContentProbe
key={file.id}
activeFileId={file.id}
calls={retainedCalls[index]}
isVisible={false}
openFiles={openFiles}
/>
))}
</>
)
})
dispatchExternalChange(changedFile.relativePath)
expect(ownerCalls.loadFile).toHaveBeenCalledOnce()
expect(retainedCalls.flatMap((calls) => calls.loadFile.mock.calls)).toHaveLength(0)
for (const calls of retainedCalls) {
expect(calls.invalidate).toHaveBeenCalledExactlyOnceWith([changedFile.id])
}
})
it('uses one event generation for visible source and preview reloads', () => {
const source = makeFile('source', {
filePath: '/remote/repo/shared.md',
relativePath: 'shared.md'
})
const preview = makeFile('preview', {
filePath: '/remote/repo/shared.md',
relativePath: 'shared.md',
mode: 'markdown-preview',
markdownPreviewSourceFileId: source.id
})
const sourceCalls = makeCalls()
const previewCalls = makeCalls()
act(() => {
root.render(
<>
<ExternalContentProbe
activeFileId={source.id}
calls={sourceCalls}
isVisible
openFiles={[source, preview]}
/>
<ExternalContentProbe
activeFileId={preview.id}
calls={previewCalls}
isVisible
openFiles={[source, preview]}
/>
</>
)
})
dispatchExternalChange(source.relativePath)
const sourceOptions = sourceCalls.loadFile.mock.calls[0]?.[4]
const previewOptions = previewCalls.loadFile.mock.calls[0]?.[4]
expect(sourceOptions?.externalEventGeneration).toBeTypeOf('number')
expect(previewOptions?.externalEventGeneration).toBe(sourceOptions?.externalEventGeneration)
})
it('keeps one slow watcher read in flight and follows up once with the latest change', async () => {
const changedFile = makeFile('changed')
const calls = makeCalls()
const settles: (() => void)[] = []
calls.loadFile.mockImplementation(() => new Promise<void>((resolve) => settles.push(resolve)))
act(() => {
root.render(
<ExternalContentProbe
activeFileId={changedFile.id}
calls={calls}
isVisible
openFiles={[changedFile]}
/>
)
})
// Watch batches outpace a slow SSH read: restarting each time would discard every result.
for (let i = 0; i < 5; i++) {
dispatchExternalChange(changedFile.relativePath)
}
expect(calls.loadFile).toHaveBeenCalledTimes(1)
await act(async () => settles[0]())
expect(calls.loadFile).toHaveBeenCalledTimes(2)
const firstGeneration = calls.loadFile.mock.calls[0][4].externalEventGeneration
expect(calls.loadFile.mock.calls[1][4].externalEventGeneration).toBe(firstGeneration + 4)
await act(async () => settles[1]())
expect(calls.loadFile).toHaveBeenCalledTimes(2)
})
it('coalesces slow watcher diff reloads separately from content reads', async () => {
const diffTab = makeFile('diff', { mode: 'diff', diffSource: 'unstaged' })
const calls = makeCalls()
const settles: (() => void)[] = []
calls.loadDiff.mockImplementation(() => new Promise<void>((resolve) => settles.push(resolve)))
act(() => {
root.render(
<ExternalContentProbe
activeFileId={diffTab.id}
calls={calls}
isVisible
openFiles={[diffTab]}
/>
)
})
for (let i = 0; i < 3; i++) {
dispatchExternalChange(diffTab.relativePath)
}
expect(calls.loadDiff).toHaveBeenCalledTimes(1)
await act(async () => settles[0]())
expect(calls.loadDiff).toHaveBeenCalledTimes(2)
})
it.each([
['became dirty', (file: OpenFile) => [{ ...file, isDirty: true }]],
['was closed', () => []]
])('skips the follow-up read when the tab %s meanwhile', async (label, nextFiles) => {
const changedFile = makeFile('changed')
const calls = makeCalls()
const settles: (() => void)[] = []
calls.loadFile.mockImplementation(() => new Promise<void>((resolve) => settles.push(resolve)))
const renderProbe = (openFiles: OpenFile[]): void => {
act(() => {
root.render(
<ExternalContentProbe
activeFileId={changedFile.id}
calls={calls}
isVisible
openFiles={openFiles}
/>
)
})
}
renderProbe([changedFile])
dispatchExternalChange(changedFile.relativePath)
dispatchExternalChange(changedFile.relativePath)
markChangedOnDisk.mockClear()
renderProbe(nextFiles(changedFile))
await act(async () => settles[0]())
expect(calls.loadFile).toHaveBeenCalledTimes(1)
if (label === 'became dirty') {
// The draft stays, but the queued disk change is recorded and the cache marked stale.
expect(markChangedOnDisk).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: changedFile.id, isDirty: true }),
expect.objectContaining({ origin: 'live' })
)
expect(calls.invalidate).toHaveBeenCalledWith([changedFile.id])
} else {
expect(markChangedOnDisk).not.toHaveBeenCalled()
}
})
it.each([
['content', 'edit', 'loadFile', 'invalidate'],
['diff', 'diff', 'loadDiff', 'invalidateDiff']
] as const)(
'invalidates %s when the tab is switched away while a follow-up is queued',
async (_label, mode, loader, invalidator) => {
const changedFile = makeFile(
'changed',
mode === 'diff' ? { mode, diffSource: 'unstaged' } : {}
)
const otherFile = makeFile('other')
const calls = makeCalls()
const settles: (() => void)[] = []
calls[loader].mockImplementation(() => new Promise<void>((resolve) => settles.push(resolve)))
const renderProbe = (activeFileId: string): void => {
act(() => {
root.render(
<ExternalContentProbe
activeFileId={activeFileId}
calls={calls}
isVisible
openFiles={[changedFile, otherFile]}
/>
)
})
}
renderProbe(changedFile.id)
dispatchExternalChange(changedFile.relativePath)
dispatchExternalChange(changedFile.relativePath)
renderProbe(otherFile.id)
await act(async () => settles[0]())
// The queued change must not be lost: the reveal path reloads stale content.
expect(calls[loader]).toHaveBeenCalledTimes(1)
expect(calls[invalidator]).toHaveBeenCalledWith([changedFile.id])
}
)
it("moves the record's other loaded views to the saved text when the saving tab closed mid-save", () => {
const other = makeFile('other')
const preview = makeFile('preview', {
@@ -9,8 +9,8 @@ import {
type EditorFileSavedDetail,
type EditorPathMutationTarget
} from '../autosave/editor-autosave'
import type { DiffContent, FileContent } from '../editor-panel-content-types'
import { isReloadableSingleFileDiffTab } from '../editor-panel-diff-reload'
import type { DiffContent, FileContent } from '../loading/editor-panel-content-types'
import { isReloadableSingleFileDiffTab } from '../loading/editor-panel-diff-reload'
import { createEditorWatcherReloadCoalescer } from './editor-watcher-reload-coalescer'
import { markFileChangedOnDisk } from './editor-changed-on-disk-mark'
import { hasRecentSelfWrite } from './editor-self-write-registry'
@@ -1,9 +1,9 @@
import { toast } from 'sonner'
import { detectLanguage } from '../../../../shared/editor-language/editor-language-detect'
import { detectLanguage } from '../../../../../shared/editor-language/editor-language-detect'
import {
isPathInsideWorktree,
toWorktreeRelativePath
} from '../../../../shared/terminal/links/terminal-links'
} from '../../../../../shared/terminal/links/terminal-links'
import { useAppStore } from '@/store/index'
import { getConnectionId } from '@/lib/workspace/ownership/connection-context'
import { basename, joinPath } from '@/lib/path'
@@ -13,21 +13,21 @@ import { translate } from '@/i18n/i18n'
import { describeSharedDropFailureReason } from '@/lib/files/drop/drop-skip-reason-copy'
import { captureWorktreeSshMutationExpectation } from '@/runtime/hosts/ssh/ssh-mutation-expectation'
import { statUserOpenedPath } from '@/lib/files/access/user-opened-local-path'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { DROP_STAGING_RELATIVE_DIR } from '../../../../shared/drop-staging-ignore'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
import { DROP_STAGING_RELATIVE_DIR } from '../../../../../shared/drop-staging-ignore'
import { openDocumentInFloatingWorkspace } from '@/components/floating-terminal/open-document-in-floating-workspace'
import { requireRuntimeTargetForFileOwner } from '@/lib/files/access/file-owner-runtime-target'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { parseExecutionHostId } from '../../../../../shared/execution-host'
import { isResolvedWorkspaceFileDragExecutionHost } from '@/lib/files/drop/workspace-file-drag'
import type { WorkspaceFileDragSource } from '@/lib/files/drop/workspace-file-drag'
import {
captureFileExplorerOperationGuard,
getFileExplorerOperationOwner
} from '../right-sidebar/file-explorer/navigation/file-explorer-operation-owner'
} from '../../right-sidebar/file-explorer/navigation/file-explorer-operation-owner'
import type { EditorFileDropDestination } from './editor-file-drop-destination'
import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state'
import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
export function shouldUploadRemoteEditorFileDrop(
target: RuntimeClientTarget,
@@ -5,7 +5,7 @@ import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result'
import {
SSH_OUTSIDE_WORKSPACE_UNSUPPORTED_MESSAGE,
TERMINAL_ARTIFACT_IN_PLACE_MAX_BYTES
} from '../../../../shared/terminal/runtime-terminal-file-path'
} from '../../../../../shared/terminal/runtime-terminal-file-path'
const mocks = vi.hoisted(() => ({
findWorkspaceFileRoute: vi.fn(),
@@ -50,7 +50,6 @@ describe('an absolute-path tab whose file another workspace holds', () => {
await expect(
placeAbsoluteTab({ ...serverTab, tab: { ...serverTab.tab, terminalArtifactGrantId: 'g-0' } })
).resolves.toEqual({ kind: 'read' })
expect(mocks.grantRestoredHostFile).not.toHaveBeenCalled()
})
it.each([
@@ -105,7 +104,6 @@ describe('an absolute-path tab whose file another workspace holds', () => {
runtimeEnvironmentId: undefined
})
).resolves.toEqual({ kind: 'read' })
expect(mocks.grantRestoredHostFile).not.toHaveBeenCalled()
})
})
@@ -1,12 +1,12 @@
import type { AppState } from '@/store/types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import {
findWorkspaceFileRoute,
type RuntimeWorkspaceFileRoute
} from '@/lib/files/access/runtime-workspace-file-route'
import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { isFloatingWorkspaceId } from '../../../../../shared/floating-workspace-worktree'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../../shared/execution-host'
import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/rpc/environment-revisions/runtime-environment-revision'
import { chatScratchFileAddress } from '@/runtime/files/addressing/runtime-chat-scratch-file-address'
import {
@@ -1,4 +1,4 @@
import { normalizeRuntimePathForComparison as normalizeAbsolutePathForComparison } from '../../../../shared/cross-platform-path'
import { normalizeRuntimePathForComparison as normalizeAbsolutePathForComparison } from '../../../../../shared/cross-platform-path'
// Track source-root delete echoes until rename and rekey settle, without a TTL that could race slow SSH moves. The coordinator verifies destinations.
@@ -1,4 +1,4 @@
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { translate } from '@/i18n/i18n'
import {
findWorkspaceFileRoute,
@@ -10,13 +10,11 @@ import {
captureEditorFileOperationProvenance
} from '@/lib/editor/file-operations/editor-file-operation-provenance'
import type { EditorFileOperationProvenance } from '@/lib/editor/file-operations/editor-file-operation-provenance'
import { requestEditorSaveQuiesce } from './autosave/editor-autosave'
import type { RestoredEditorOwnerResult } from '@/store/slices/editor/types/restored-editor-owner'
import { requestEditorSaveQuiesce } from '../autosave/editor-autosave'
import type { RestoredEditorOwnerResult as RestoredEditorOwnerMigrationResult } from '@/store/slices/editor/types/restored-editor-owner'
import { contentRekeyOutcome } from '@/runtime/workspace-layout/intents/layout-owner-content-rekeys'
import { afterPendingEntityTabs } from '@/runtime/workspace-layout/intents/layout-owner-pending-tabs'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
export type RestoredEditorOwnerMigrationResult = RestoredEditorOwnerResult
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
export function restoredOwnerMigrationErrorMessage(
reason: Extract<RestoredEditorOwnerMigrationResult, { ok: false }>['reason']
@@ -2,12 +2,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeFileMutationClient from '@/runtime/files/mutations/runtime-file-mutation-client'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { AppState } from '@/store/types'
import { attachEditorAutosaveController } from './autosave/editor-autosave-controller'
import { requestEditorFileSave } from './autosave/editor-autosave'
import { attachEditorAutosaveController } from '../autosave/editor-autosave-controller'
import { requestEditorFileSave } from '../autosave/editor-autosave'
import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner'
import { withLoadedBaselines } from './autosave/editor-autosave-controller-test-fixture'
import { withLoadedBaselines } from '../autosave/editor-autosave-controller-test-fixture'
const mocks = vi.hoisted(() => ({ writeRuntimeFile: vi.fn() }))
@@ -109,19 +109,15 @@ describe('restored editor owner save lifecycle', () => {
if (!migrated.ok) {
return
}
expect(mocks.writeRuntimeFile.mock.calls[0]?.[0]).toMatchObject({ worktreeId: SOURCE })
useAppStore.getState().setEditorDraft(migrated.fileId, 'explicit destination save')
useAppStore.getState().markFileDirty(migrated.fileId, true)
await requestEditorFileSave({ fileId: migrated.fileId })
expect(mocks.writeRuntimeFile.mock.calls[1]?.[0]).toMatchObject({ worktreeId: TARGET })
useAppStore.getState().setEditorDraft(migrated.fileId, 'autosave destination')
useAppStore.getState().markFileDirty(migrated.fileId, true)
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3))
expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET })
expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination')
})
it('rejects a concurrent migration without clearing the active migration gate', async () => {
@@ -4,11 +4,14 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeFileReadClient from '@/runtime/files/reads/runtime-file-read-client'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { AppState } from '@/store/types'
import { makeWorktree } from '../../store/slices/worktrees/worktree.test-fixture'
import type { FileContent, EditorPanelFileContentLoader } from './editor-panel-content-types'
import { useEditorPanelFileContentLoader } from './useEditorPanelFileContentLoader'
import { makeWorktree } from '../../../store/slices/worktrees/worktree.test-fixture'
import type {
FileContent,
EditorPanelFileContentLoader
} from '../loading/editor-panel-content-types'
import { useEditorPanelFileContentLoader } from '../loading/useEditorPanelFileContentLoader'
import { layoutApplyTestTransport } from '@/runtime/workspace-layout/testing/layout-apply-test-transport.test-fixture'
import {
drawTabsFromMain,
@@ -20,7 +23,7 @@ vi.mock('@/runtime/files/reads/runtime-file-read-client', async (importOriginal)
...(await importOriginal<typeof RuntimeFileReadClient>()),
readRuntimeFileContent: mocks.readRuntimeFileContent
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('../../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
// Use main-owned layout frames and move acknowledgments.
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (importOriginal) =>
(
@@ -175,62 +178,6 @@ describe('a restored tab re-owned inside its own workspace, layout-owner build',
container.remove()
})
it.each([
[false, 'resource'],
[true, 'resource'],
[false, 'owner'],
[true, 'owner']
] as const)(
'drops a delayed read after the same tab ID changes (failure=%s, change=%s) and clears its owning read',
async (failure, change) => {
seedNestedLocalWorkspaces()
const id = await drawFromMain(openAbsoluteTabInFolder)
let finish!: () => void
mocks.readRuntimeFileContent.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
finish = () =>
failure
? reject(new Error("ENOENT: no such file or directory, open '/repo-a/pkg/a.md'"))
: resolve({ content: 'obsolete', isBinary: false })
})
)
const baseline = useAppStore
.getState()
.openFiles.find((file) => file.id === id)?.lastKnownDiskSignature
let pending!: Promise<void>
await act(async () => {
pending = load!(FILE_PATH, id, FOLDER, FILE_PATH)
await Promise.resolve()
})
expect(mocks.readRuntimeFileContent).toHaveBeenCalledOnce()
if (change === 'resource') {
useAppStore.setState((state) => ({
openFiles: state.openFiles.map((file) =>
file.id === id
? { ...file, filePath: '/repo-a/pkg/new.md', relativePath: 'new.md' }
: file
)
}))
} else {
useAppStore.setState((state) => ({
folderWorkspaces: state.folderWorkspaces.map((folder) => ({
...folder,
folderPath: '/repo-a/moved'
}))
}))
}
finish()
await act(async () => pending)
expect(fileContents[id]).toBeUndefined()
expect(
useAppStore.getState().openFiles.find((file) => file.id === id)?.lastKnownDiskSignature
).toBe(baseline)
expect(outstanding.current[id]).toBeUndefined()
expect(useAppStore.getState().openFiles[0]?.externalMutation).toBeUndefined()
}
)
it("tells main when a parent workspace's conflict review re-owns a local tab in place", async () => {
seedNestedLocalWorkspaces()
const id = await drawFromMain(openAbsoluteTabInFolder)
@@ -4,10 +4,13 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeFileReadClient from '@/runtime/files/reads/runtime-file-read-client'
import { useAppStore } from '@/store'
import { makeWorktree } from '../../store/slices/worktrees/worktree.test-fixture'
import type { FileContent, EditorPanelFileContentLoader } from './editor-panel-content-types'
import { useEditorPanelFileContentLoader } from './useEditorPanelFileContentLoader'
import { useAppStore } from '@/store/index'
import { makeWorktree } from '../../../store/slices/worktrees/worktree.test-fixture'
import type {
FileContent,
EditorPanelFileContentLoader
} from '../loading/editor-panel-content-types'
import { useEditorPanelFileContentLoader } from '../loading/useEditorPanelFileContentLoader'
const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() }))
vi.mock('@/runtime/files/reads/runtime-file-read-client', async (importOriginal) => ({
@@ -120,11 +123,6 @@ describe('a restored tab whose workspace moved to its managed server', () => {
])
await act(async () => load!(FILE_PATH, file.id, WORKTREE, 'README.md'))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1)
expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]).toMatchObject({
target: { kind: 'environment', environmentId: 'env-1' },
worktreeId: WORKTREE
})
expect(fileContents[file.id]).toMatchObject({ content: '# repo' })
})
})
@@ -1,4 +1,4 @@
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import {
hostSavesGrantedFiles,
renewTerminalArtifactGrant
@@ -4,10 +4,10 @@ import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { formatFileLoadErrorMessage } from './file-load-error-copy'
import { displayIpcErrorMessage } from '@/runtime/errors/ipc-error'
import { isFileTooLargeCodeMessage } from '../../../../shared/file-write/file-write-disk-precondition'
import { isFileTooLargeCodeMessage } from '../../../../../shared/file-write/file-write-disk-precondition'
import { WORKTREE_HOST_UNRESOLVED_CODE } from './editor-panel-content-types'
import { useWslHostReadOutage } from '../wsl-host-stopped/use-stopped-wsl-host'
import { WslHostStoppedNotice } from '../wsl-host-stopped/WslHostStoppedNotice'
import { useWslHostReadOutage } from '../../wsl-host-stopped/use-stopped-wsl-host'
import { WslHostStoppedNotice } from '../../wsl-host-stopped/WslHostStoppedNotice'
// Localize by machine code; stored English remains readable for logs without coupling upstream state checks to UI language.
function localizeFileLoadError(
@@ -1,8 +1,8 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { EditorPanelContentLoadOptions } from './external-changes/useEditorPanelExternalContentEvents'
import type { GitDiffResult } from '../../../../shared/git/diff/git-diff-compare-types'
import type { CsvFilePreview } from './csv/preview/csv-file-content'
import type { EditorPanelContentLoadOptions } from '../external-changes/useEditorPanelExternalContentEvents'
import type { GitDiffResult } from '../../../../../shared/git/diff/git-diff-compare-types'
import type { CsvFilePreview } from '../csv/preview/csv-file-content'
import type { RuntimeFileReadArgs } from '@/runtime/files/runtime-file-client-types'
/** Transient owner hydration/connection failure; retry instead of reading remote paths locally and latching access errors. */
@@ -1,5 +1,5 @@
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
import type { GitStatusEntry } from '../../../../../shared/git/status/git-status-types'
export function isReloadableSingleFileDiffTab(file: OpenFile): boolean {
return (
@@ -1,5 +1,5 @@
import type { FileContent, InFlightContentRead } from './editor-panel-content-types'
import type { EditorPanelContentLoadOptions } from './external-changes/useEditorPanelExternalContentEvents'
import type { EditorPanelContentLoadOptions } from '../external-changes/useEditorPanelExternalContentEvents'
const inFlightFileReads = new Map<string, InFlightContentRead<FileContent>>()
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types'
import type { LocalLogTailReadResult } from '../../../../../../shared/local-log-tail-types'
import { LocalLogTailDecoder } from './local-log-tail-decoder'
const IDENTITY = '1:2:3'
@@ -36,7 +36,6 @@ describe('LocalLogTailDecoder', () => {
outputs.map((output) => (output.kind === 'append' ? output.content : '')).join('')
).toBe(`${text}\n`)
expect(decoder.nextByteOffset).toBe(offset + bytes.length + 1)
expect(decoder.expectedIdentity).toBe(IDENTITY)
}
})
})
@@ -1,4 +1,4 @@
import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types'
import type { LocalLogTailReadResult } from '../../../../../../shared/local-log-tail-types'
import { decodeBase64Bytes } from '@/lib/base64-bytes'
export const LOCAL_LOG_TAIL_MAX_BYTES = 50 * 1024 * 1024
@@ -1,10 +1,9 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { renderHook, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { FileContent } from './editor-panel-content-types'
import type { FileContent } from '../editor-panel-content-types'
import { useLocalLogTail } from './useLocalLogTail'
const FILE_PATH = '/home/user/.codex/sessions/log.jsonl'
@@ -35,7 +34,6 @@ function encodedResult(content: string, fromByteOffset: number) {
}
}
let changedListener: ((payload: LocalLogTailChangedPayload) => void) | undefined
let startMock: ReturnType<typeof vi.fn>
let stopMock: ReturnType<typeof vi.fn>
let readMock: ReturnType<typeof vi.fn>
@@ -43,7 +41,6 @@ let reloadMock: Mock<(file: OpenFile) => void>
let warnMock: ReturnType<typeof vi.spyOn>
beforeEach(() => {
changedListener = undefined
startMock = vi.fn().mockResolvedValue(undefined)
stopMock = vi.fn().mockResolvedValue(undefined)
readMock = vi.fn()
@@ -56,10 +53,7 @@ beforeEach(() => {
startLocalLogTail: startMock,
stopLocalLogTail: stopMock,
readLocalLogTail: readMock,
onLocalLogTailChanged: vi.fn((listener: (payload: LocalLogTailChangedPayload) => void) => {
changedListener = listener
return vi.fn()
})
onLocalLogTailChanged: vi.fn(() => vi.fn())
}
}
})
@@ -101,35 +95,6 @@ describe('useLocalLogTail', () => {
await waitFor(() => {
expect(result.current[FILE_PATH]?.content).toBe('complete\n{"partial":true}\n')
})
expect(readMock).toHaveBeenCalledWith({
filePath: FILE_PATH,
fromByteOffset: offset,
expectedIdentity: FILE_IDENTITY
})
})
it('waits for watcher startup before cancelling a tab closed in flight', async () => {
let resolveStart: (() => void) | undefined
startMock.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveStart = resolve
})
)
const snapshot: FileContent = {
content: 'complete\n',
isBinary: false,
fileIdentity: FILE_IDENTITY
}
const { rerender } = renderHook(({ files }) => useHarness(files, snapshot), {
initialProps: { files: [openFile] }
})
rerender({ files: [] })
await act(async () => resolveStart?.())
await waitFor(() => expect(stopMock).toHaveBeenCalledTimes(1))
expect(readMock).not.toHaveBeenCalled()
})
it('commits complete lines read before a later transient chunk failure', async () => {
@@ -146,24 +111,5 @@ describe('useLocalLogTail', () => {
)
await waitFor(() => expect(result.current[FILE_PATH]?.content).toBe('old\nnew\n'))
expect(reloadMock).not.toHaveBeenCalled()
})
it('treats a rename event as rotation and reloads without another ranged append', async () => {
readMock.mockResolvedValue(encodedResult('', 4))
renderHook(() =>
useHarness([openFile], {
content: 'old\n',
isBinary: false,
fileIdentity: FILE_IDENTITY
})
)
await waitFor(() => expect(readMock).toHaveBeenCalledTimes(1))
const subscriptionId = startMock.mock.calls[0]?.[0].subscriptionId as string
act(() => changedListener?.({ subscriptionId, eventType: 'rename' }))
await waitFor(() => expect(reloadMock).toHaveBeenCalledWith(openFile))
expect(readMock).toHaveBeenCalledTimes(1)
})
})
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types'
import type { LocalLogTailChangedPayload } from '../../../../../../shared/local-log-tail-types'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { FileContent } from './editor-panel-content-types'
import type { FileContent } from '../editor-panel-content-types'
import { LocalLogTailDecoder } from './local-log-tail-decoder'
type TailSession = {
@@ -5,7 +5,7 @@ import {
} from '@/store/slices/editor/buffers/editor-buffer-conflict-overview'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type {
DiffContent,
FileContent,
@@ -13,7 +13,7 @@ import type {
} from './editor-panel-content-types'
import { isReloadableSingleFileDiffTab } from './editor-panel-diff-reload'
import type { EditorPanelDiffContentLoader } from './useEditorPanelDiffContentLoader'
import { useQuickOpenHistoryVisit } from './use-quick-open-history-visit'
import { useQuickOpenHistoryVisit } from '../panel/use-quick-open-history-visit'
// Showing a tab is a user act: its first load may start a stopped WSL distro. Re-runs from
// polls (git status, overview views) are not, so they read without waking.
@@ -1,6 +1,6 @@
import { useEffect, useMemo, type MutableRefObject } from 'react'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { useAppStore } from '@/store'
import type { useAppStore } from '@/store/index'
import type { DiffContent, EditorPanelFileContentLoader } from './editor-panel-content-types'
import {
isReloadableSingleFileDiffTab,
@@ -1,13 +1,13 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { useAppStore } from '@/store'
import type { useAppStore } from '@/store/index'
import type { DiffContent, FileContent, ReloadContentOptions } from './editor-panel-content-types'
import {
useEditorPanelExternalContentEvents,
usePruneClosedEditorContent
} from './external-changes/useEditorPanelExternalContentEvents'
} from '../external-changes/useEditorPanelExternalContentEvents'
import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry'
import { useLocalLogTail } from './useLocalLogTail'
import { useLocalLogTail } from './log-tail/useLocalLogTail'
import { useEditorPanelFileContentLoader } from './useEditorPanelFileContentLoader'
import { useEditorPanelDiffContentLoader } from './useEditorPanelDiffContentLoader'
import { useEditorPanelActiveTabContentLoad } from './useEditorPanelActiveTabContentLoad'
@@ -1,7 +1,7 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { getEditorFileReadContext } from '@/lib/editor/file-operations/editor-file-operation-owner'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import {
getRuntimeGitBranchDiff,
getRuntimeGitCommitDiff,
@@ -9,11 +9,11 @@ import {
} from '@/runtime/source-control/git/runtime-git-diff-client'
import { getRuntimeGitScope } from '@/runtime/source-control/git/runtime-git-client-context'
import type { DiffContent, InFlightContentRead } from './editor-panel-content-types'
import { canUseChangesModeForFile } from './view-mode/editor-panel-file-mode'
import type { EditorPanelContentLoadOptions } from './external-changes/useEditorPanelExternalContentEvents'
import { stampCleanTabDiskBaseline } from './external-changes/editor-clean-tab-disk-baseline'
import { canUseChangesModeForFile } from '../view-mode/editor-panel-file-mode'
import type { EditorPanelContentLoadOptions } from '../external-changes/useEditorPanelExternalContentEvents'
import { stampCleanTabDiskBaseline } from '../external-changes/editor-clean-tab-disk-baseline'
import { translate } from '@/i18n/i18n'
import { isWslDistroNotRunningRefusal } from '../../../../shared/wsl-distro-not-running-refusal'
import { isWslDistroNotRunningRefusal } from '../../../../../shared/wsl-distro-not-running-refusal'
const inFlightDiffReads = new Map<string, InFlightContentRead<DiffContent>>()
@@ -6,17 +6,17 @@ import {
getConnectionIdForFile,
isWorktreeConnectionResolved
} from '@/lib/workspace/ownership/connection-context'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { getRuntimeFileReadScope } from '@/runtime/files/runtime-file-routing'
import { readEditorCsvFileContent } from './csv/preview/csv-file-content'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { readEditorCsvFileContent } from '../csv/preview/csv-file-content'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import { requireRuntimeTargetForFileOwner } from '@/lib/files/access/file-owner-runtime-target'
import type { RuntimeWorkspaceFileRoute } from '@/lib/files/access/runtime-workspace-file-route'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../../../shared/execution-host'
} from '../../../../../shared/execution-host'
import {
WORKTREE_OWNER_NOT_READY_ERROR,
type EditorPanelFileContentLoader,
@@ -25,16 +25,16 @@ import {
import {
migrateRestoredEditorFileOwner,
restoredOwnerMigrationErrorMessage
} from './migrate-restored-editor-file-owner'
import { findRestoredEditorWorkspaceRuntimeOwner } from './restored-editor-workspace-runtime-owner'
} from '../file-ownership/migrate-restored-editor-file-owner'
import { findRestoredEditorWorkspaceRuntimeOwner } from '../file-ownership/restored-editor-workspace-runtime-owner'
import { editorTabFileAccess, isClientLocalReadOnlyTab } from '@/lib/files/access/local-file-access'
import { placeAbsoluteTab } from './absolute-tab-placement'
import { placeAbsoluteTab } from '../file-ownership/absolute-tab-placement'
import type { RuntimeFileReadArgs } from '@/runtime/files/runtime-file-client-types'
import { stampCleanTabDiskBaseline } from './external-changes/editor-clean-tab-disk-baseline'
import { stampCleanTabDiskBaseline } from '../external-changes/editor-clean-tab-disk-baseline'
import {
applyEditorFileLoadFailure,
getEditorFileLoadFailureContent
} from './external-changes/editor-deleted-file-reload'
} from '../external-changes/editor-deleted-file-reload'
import {
acquireEditorFileOperation,
isSameEditorFileOperationIdentity
@@ -3,10 +3,10 @@ import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { useWorktreeHostConnection } from '@/lib/workspace/ownership/worktree-host-connection-phase'
import { selectWorkspaceRuntimeEnvironmentId } from '@/lib/workspace/ownership/workspace-runtime-environment'
import { useAppStateValue } from '@/store/retained-views/app-state-view'
import type { WslHostConnectionState } from '../../../../shared/wsl-host-types'
import type { WslHostConnectionState } from '../../../../../shared/wsl-host-types'
import { displayIpcErrorMessage } from '@/runtime/errors/ipc-error'
import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code'
import { isFileTooLargeMessage } from '../../../../shared/file-write/file-write-disk-precondition'
import { hasRuntimeRpcErrorCode } from '../../../../../shared/runtime-rpc-error-code'
import { isFileTooLargeMessage } from '../../../../../shared/file-write/file-write-disk-precondition'
import {
WORKTREE_HOST_SELECTOR_NOT_FOUND_CODE,
WORKTREE_HOST_UNRESOLVED_CODE,
@@ -4,7 +4,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
import type { FileContent } from './editor-panel-content-types'
const mocks = vi.hoisted(() => ({
@@ -45,27 +45,27 @@ vi.mock('@/lib/workspace/ownership/connection-context', () => ({
vi.mock(
'@/lib/workspace/ownership/worktree-host-connection-phase',
() => import('./local-host-test-fixture')
() => import('../file-ownership/local-host-test-fixture')
)
vi.mock('@/lib/files/access/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: mocks.findWorkspaceFileRoute
}))
vi.mock('@/store', () => ({
vi.mock('@/store/index', () => ({
useAppStore: Object.assign(
(select: (state: ReturnType<typeof mocks.getState>) => unknown) => select(mocks.getState()),
{ getState: mocks.getState }
)
}))
vi.mock('./external-changes/useEditorPanelExternalContentEvents', () => ({
vi.mock('../external-changes/useEditorPanelExternalContentEvents', () => ({
useEditorPanelExternalContentEvents: vi.fn(),
usePruneClosedEditorContent: vi.fn()
}))
vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() }))
vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() }))
vi.mock('./log-tail/useLocalLogTail', () => ({ useLocalLogTail: vi.fn() }))
import { useEditorPanelContentState } from './useEditorPanelContentState'
@@ -137,9 +137,5 @@ describe('restored client-local editor tabs', () => {
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local'))
expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled()
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
expect.objectContaining({ filePath, access: { kind: 'user-file' } })
)
})
})
@@ -43,27 +43,21 @@ vi.mock('@/lib/workspace/ownership/connection-context', () => ({
vi.mock(
'@/lib/workspace/ownership/worktree-host-connection-phase',
() => import('./local-host-test-fixture')
() => import('../file-ownership/local-host-test-fixture')
)
vi.mock('@/lib/files/access/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: vi.fn(() => null)
}))
vi.mock('@/store', () => ({
vi.mock('@/store/index', () => ({
useAppStore: Object.assign(
(select: (state: ReturnType<typeof mocks.getState>) => unknown) => select(mocks.getState()),
{ getState: mocks.getState }
)
}))
import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result'
import {
WORKTREE_HOST_UNRESOLVED_CODE,
WORKTREE_HOST_UNRESOLVED_ERROR
} from './editor-panel-content-types'
import { useEditorPanelContentState } from './useEditorPanelContentState'
import { FILE_LOAD_RETRY_DELAYS_MS } from './useEditorPanelFileLoadRetry'
let latestFileContents: Record<string, FileContent> = {}
let latestReload: ReturnType<typeof useEditorPanelContentState>['reloadContent'] = () => {}
@@ -131,68 +125,7 @@ describe('useEditorPanelContentState — host cannot resolve a mirrored file (#2
vi.useRealTimers()
})
it('keeps a dirty mirrored tab open when the host keeps answering selector_not_found', async () => {
// Selector failure proves no deletion; bounded retries must end with the dirty tab and its draft preserved.
const activeFile = makeDirtyMirroredFile()
const openFiles = [activeFile]
const closeFile = vi.fn()
const editorDrafts = { [activeFile.id]: '# unsaved edits' }
const store = createTestStore()
store.setState({
openFiles,
editorDrafts,
closeFile,
worktreesByRepo: {
project: [
makeWorktree({
id: activeFile.worktreeId,
repoId: 'project',
path: '/home/user/project',
hostId: 'runtime:env-1'
})
]
},
setLastKnownDiskSignature: vi.fn()
})
mocks.getState.mockImplementation(store.getState)
// Delay rejection like real host I/O so retry state commits separately; return machine code with prose to exercise code-based classification.
const hostError = new RuntimeRpcCallError({
id: 'rpc-1',
ok: false,
error: { code: 'selector_not_found', message: 'Selector not found' }
})
mocks.readRuntimeFileContent.mockImplementation(
() =>
new Promise((_, reject) => {
setTimeout(() => reject(hostError), HOST_READ_LATENCY_MS)
})
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<Probe activeFile={activeFile} openFiles={openFiles} />)
})
await advanceTimers(HOST_READ_LATENCY_MS)
expect(latestFileContents[activeFile.id]?.loadError).toBe('Selector not found')
expect(latestFileContents[activeFile.id]?.loadErrorCode).toBe('selector_not_found')
for (const delayMs of FILE_LOAD_RETRY_DELAYS_MS) {
await advanceTimers(delayMs)
await advanceTimers(HOST_READ_LATENCY_MS)
}
expect(closeFile).not.toHaveBeenCalled()
expect(editorDrafts[activeFile.id]).toBe('# unsaved edits')
expect(latestFileContents[activeFile.id]?.loadError).toBe(WORKTREE_HOST_UNRESOLVED_ERROR)
expect(latestFileContents[activeFile.id]?.loadErrorCode).toBe(WORKTREE_HOST_UNRESOLVED_CODE)
// Terminal: no more reads, still no eviction.
await advanceTimers(60_000)
expect(closeFile).not.toHaveBeenCalled()
})
it('skips a reload after Start when the file already loaded, and reloads one still failed', async () => {
it('preserves loaded content and recovers a failed load after Start', async () => {
const activeFile = { ...makeDirtyMirroredFile(), isDirty: false }
const openFiles = [activeFile]
const store = createTestStore()
@@ -221,11 +154,9 @@ describe('useEditorPanelContentState — host cannot resolve a mirrored file (#2
})
await advanceTimers(HOST_READ_LATENCY_MS)
expect(latestFileContents[activeFile.id]?.content).toBe('# notes')
const readsAfterLoad = mocks.readRuntimeFileContent.mock.calls.length
await act(async () => latestReload(activeFile, { onlyIfFailed: true }))
await advanceTimers(HOST_READ_LATENCY_MS)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(readsAfterLoad)
expect(latestFileContents[activeFile.id]?.content).toBe('# notes')
// A non-retryable failure keeps the editor's own retry out of the count.
@@ -233,11 +164,9 @@ describe('useEditorPanelContentState — host cannot resolve a mirrored file (#2
await act(async () => latestReload(activeFile))
await advanceTimers(HOST_READ_LATENCY_MS)
expect(latestFileContents[activeFile.id]?.loadError).toContain('ENOENT')
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(readsAfterLoad + 1)
await act(async () => latestReload(activeFile, { onlyIfFailed: true }))
await advanceTimers(HOST_READ_LATENCY_MS)
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(readsAfterLoad + 2)
expect(latestFileContents[activeFile.id]?.content).toBe('# notes')
})
})
@@ -9,7 +9,7 @@ import {
formatMarkdownReviewNotes,
type MarkdownReviewNote
} from '@/lib/editor/markdown/review-notes/markdown-review-notes'
import { installOpenDraftAddReviewNoteGuard } from '../../editor-shortcuts'
import { installOpenDraftAddReviewNoteGuard } from '../../commands/editor-shortcuts'
import { NotesSendMenu } from '../../review-note-delivery/NotesSendMenu'
export function MarkdownPreviewSingleNoteSendMenu({
@@ -1,4 +1,4 @@
import { fencedCode } from '../../selection-copy'
import { fencedCode } from '../../contextual-copy/selection-copy'
import type { QuotableSelection } from '../../../native-chat/selection-quotes/SelectionAddToChatPopover'
import { selectedTextEdges } from '../../../native-chat/selection-quotes/native-chat-quote-selection'
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createElement, useLayoutEffect, useRef } from 'react'
import type { MarkdownDocument } from '../../../../../../shared/file-listing/file-document'
import { useMonacoEditorDecorations } from '../../use-monaco-editor-decorations'
import { useMonacoEditorDecorations } from '../../monaco/use-monaco-editor-decorations'
import type { editor, languages, Position } from 'monaco-editor'
import type { OnMount } from '@monaco-editor/react'
import {
@@ -4,7 +4,7 @@ import {
createMarkdownFenceRangeCursor,
getMarkdownFenceRanges
} from '../../markdown/source-scanning/markdown-fence-scanner'
import { forEachLine } from '../../text-line-offsets'
import { forEachLine } from '../../model-lifetime/buffer-text/text-line-offsets'
const BACKTICK = 96
const BACKSLASH = 92
@@ -16,7 +16,7 @@ import {
} from './markdown-doc-links'
import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector'
import { requestSharedMarkdownDocumentList } from './markdown-document-list-request'
import { findRestoredEditorWorkspaceRuntimeOwner } from '../../restored-editor-workspace-runtime-owner'
import { findRestoredEditorWorkspaceRuntimeOwner } from '../../file-ownership/restored-editor-workspace-runtime-owner'
import {
getLatestMarkdownMembershipChangeAt,
useMarkdownDocumentWatchRefresh
@@ -11,7 +11,7 @@ import { applyEditorBufferText } from './editor-buffer-adapter'
import { editorTabModelUri } from '../../save/queue/editor-save-disk-precondition'
import { attachClosedEditorTabCleanup } from '../retention/closed-editor-tab-controller'
import { showClosedUnsavedFilesToast } from '../../closed-unsaved-files-toast'
import { showClosedUnsavedFilesToast } from '../retention/closed-unsaved-files-toast'
const storeRef = vi.hoisted((): { current: StoreApi<AppState> | null } => ({ current: null }))
const toastWarning = vi.hoisted(() => vi.fn())
@@ -106,7 +106,6 @@ describe('a kept dirty record owns its model', () => {
showClosedUnsavedFilesToast([{ fileId: a, filePath: PATH, content: 'unsaved' }], 'wt-1', 1)
toastWarning.mock.calls[0][1].onDismiss()
expect(store.getState().editorBufferKept).toEqual({})
await Promise.resolve()
await Promise.resolve()
expect(model.isDisposed()).toBe(true)
@@ -14,7 +14,7 @@ import {
getOpenFilesForExternalFileChange,
type EditorPathMutationTarget
} from '../../autosave/editor-autosave'
import type { FileContent } from '../../editor-panel-content-types'
import type { FileContent } from '../../loading/editor-panel-content-types'
/** Apply rich/notebook/CSV commits to hidden retained models as one undo step; visible code models follow their own content sync. */
export function applyEditorBufferText(
@@ -1,147 +0,0 @@
// @vitest-environment happy-dom
// When a shared record goes clean, synchronize source and rich views so stale typing cannot resurrect old text.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import type { editor } from 'monaco-editor'
import type { StoreApi } from 'zustand'
import type { AppState } from '@/store/types'
import { editorModelRegistry } from '@/lib/editor/monaco/editor-model-registry'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { createEditorStore } from '../../autosave/editor-autosave-controller-test-fixture'
import { editorTabModelUri } from '../../save/queue/editor-save-disk-precondition'
import { useEditorContentChangeHandler } from './use-editor-content-change-handler'
import { useMonacoContentSyncBridge } from '../../use-monaco-content-sync-bridge'
import type { FileContent } from '../../editor-panel-content-types'
import { getDiskBaselineSignature } from '../../../../../../shared/file-write/disk-baseline-signature'
const storeRef = vi.hoisted((): { current: StoreApi<AppState> | null } => ({ current: null }))
vi.mock('@/store/index', () => {
const getState = () => storeRef.current!.getState()
return {
useAppStore: Object.assign((select: (state: AppState) => unknown) => select(getState()), {
getState
})
}
})
const PATH = '/repo/notes.md'
const DISK = '# notes\n\nhello\n'
// What a rich view commits for the saved document: the same text but for trailing whitespace.
const RICH_SAVED = '# notes\n\nhello'
let unregister: () => void = () => {}
beforeEach(() => {
unregister = editorModelRegistry.register(monaco)
})
afterEach(() => {
unregister()
for (const model of monaco.editor.getModels()) {
model.dispose()
}
})
function openSharedMarkdown() {
const store = createEditorStore()
storeRef.current = store
store.setState({
worktreesByRepo: {
'repo-1': [
makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }),
makeWorktree({ id: 'wt-2', repoId: 'repo-1', path: '/repo', hostId: 'local' })
]
}
})
for (const worktreeId of ['wt-1', 'wt-2']) {
store.getState().openFile({
filePath: PATH,
relativePath: 'notes.md',
worktreeId,
language: 'markdown',
mode: 'edit'
})
}
const [a, b] = store.getState().openFiles
store.getState().setLastKnownDiskSignature(a.id, getDiskBaselineSignature(DISK))
store.getState().setMarkdownViewMode(a.id, 'source')
store.getState().setMarkdownViewMode(b.id, 'rich')
const state = store.getState()
const model = monaco.editor.createModel(
DISK,
'markdown',
monaco.Uri.parse(editorTabModelUri(state, a))
)
// A's source editor shows the model, so record commits from B reach it through A's content sync.
vi.spyOn(model, 'isAttachedToEditor').mockReturnValue(true)
const fileContents: Record<string, FileContent> = {
[a.id]: { content: DISK, isBinary: false },
[b.id]: { content: DISK, isBinary: false }
}
const commit = renderHook(() => useEditorContentChangeHandler({ fileContents, diffContents: {} }))
.result.current
const viewText = (id: string): string =>
store.getState().editorDrafts[id] ?? fileContents[id].content
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridge uses the real model event and text through this editor facade.
const editorA = {
getModel: () => model,
getValue: () => model.getValue(),
onDidChangeModelContent: (listener: (event: editor.IModelContentChangedEvent) => void) =>
model.onDidChangeContent(listener),
saveViewState: () => null,
restoreViewState: () => {},
pushUndoStop: () => true
} as unknown as editor.IStandaloneCodeEditor
const contentRef = { current: viewText(a.id) }
const bridge = renderHook(
({ content }: { content: string }) =>
useMonacoContentSyncBridge({
editorRef: { current: editorA },
mountedEditor: editorA,
content,
contentRef,
contentSyncModeRef: { current: 'undoable' },
modelKey: model.uri.toString(),
fileId: a.id,
onContentChange: (text) => commit(store.getState().openFiles[0], text)
}),
{ initialProps: { content: viewText(a.id) } }
)
const renderA = (): void => {
contentRef.current = viewText(a.id)
bridge.rerender({ content: viewText(a.id) })
}
const typeInA = (text: string): void => {
act(() => {
model.setValue(text)
})
renderA()
}
const commitFromB = (text: string): void => {
act(() => commit(store.getState().openFiles[1], text))
renderA()
}
const view = (id: string) => store.getState().openFiles.find((tab) => tab.id === id)!
return { store, a: a.id, b: b.id, model, typeInA, commitFromB, view }
}
describe('a record that goes clean shows its text in every view', () => {
it.each([
['differs only in trailing whitespace', RICH_SAVED],
['matches disk exactly', DISK]
])(
'rich undo back to the saved text (%s) reaches the source view; typing there keeps no undone text',
(_label, richSaved) => {
const { store, a, b, model, typeInA, commitFromB, view } = openSharedMarkdown()
typeInA(`${DISK}XYZ`)
expect(view(b).isDirty).toBe(true)
commitFromB(richSaved)
expect(view(a).isDirty).toBe(false)
expect(view(b).isDirty).toBe(false)
expect(model.getValue()).toBe(richSaved)
typeInA(`${model.getValue()}Q`)
expect(store.getState().editorDrafts[a]).not.toContain('XYZ')
}
)
})
@@ -37,7 +37,6 @@ describe('Monaco external-content undo history', () => {
syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail')
expect(model.getValue()).toBe(['first line', 'appended'].join(model.getEOL()))
expect(model.canUndo()).toBe(false)
})
@@ -1,7 +1,7 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store/index'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { DiffContent, FileContent } from '../../editor-panel-content-types'
import type { DiffContent, FileContent } from '../../loading/editor-panel-content-types'
import { isEditorContentUnchanged } from './editor-content-dirty-state'
import { applyEditorBufferText } from './editor-buffer-adapter'
@@ -1,15 +1,11 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { cleanup, renderHook } from '@testing-library/react'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import type { editor } from 'monaco-editor'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useMonacoContentSyncBridge } from './use-monaco-content-sync-bridge'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync
} from './monaco-programmatic-sync'
vi.mock('./model-lifetime/buffer-text/editor-stale-mount-guard', () => ({
vi.mock('./editor-stale-mount-guard', () => ({
mayPushTabTextIntoModel: () => true
}))
@@ -53,41 +49,11 @@ function mountBridge(model: editor.ITextModel) {
}
describe('Monaco content event publication', () => {
it('does not publish programmatic echoes, while later user edits reach both panes', () => {
it('applies an external update to the real mounted model', () => {
const model = monaco.editor.createModel('initial')
const first = mountBridge(model)
const sibling = mountBridge(model)
mountBridge(model)
first.rerender({ content: 'external update' })
expect(model.getValue()).toBe('external update')
const key = model.uri.toString()
beginProgrammaticContentSync(key)
try {
act(() => model.setValue('another owner update'))
} finally {
endProgrammaticContentSync(key)
}
expect(first.onContentChange).not.toHaveBeenCalled()
expect(sibling.onContentChange).not.toHaveBeenCalled()
act(() => model.setValue('user edit'))
expect(first.onContentChange).toHaveBeenCalledExactlyOnceWith('user edit')
expect(sibling.onContentChange).toHaveBeenCalledExactlyOnceWith('user edit')
})
it('does not publish from an editor that no longer owns the mounted ref', () => {
const model = monaco.editor.createModel('initial')
const bridge = mountBridge(model)
bridge.editorRef.current = null
act(() => model.setValue('retired editor'))
expect(bridge.onContentChange).not.toHaveBeenCalled()
})
it('does not publish after unmount', () => {
const model = monaco.editor.createModel('initial')
const bridge = mountBridge(model)
bridge.unmount()
act(() => model.setValue('after close'))
expect(bridge.onContentChange).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { editor } from 'monaco-editor'
import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync'
import { mayPushTabTextIntoModel } from './model-lifetime/buffer-text/editor-stale-mount-guard'
import { mayPushTabTextIntoModel } from './editor-stale-mount-guard'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync,
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import '@/store/slices/editor/create-editor-slice'
import { buildOwnedEditorFileId } from '@/store/slices/editor/file-ids/editor-file-ids'
@@ -14,12 +14,7 @@ import {
} from '../retention/editor-model-lifetime-fixture'
import { getEditorModelOwnerKey } from './editor-model-owner'
import { toEditorModelUri } from './editor-model-uri'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync,
resetProgrammaticContentSyncForTests,
shouldIgnoreMonacoContentChange
} from '../../monaco-programmatic-sync'
import { resetProgrammaticContentSyncForTests } from '../buffer-text/monaco-programmatic-sync'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }))
@@ -80,81 +75,10 @@ afterEach(() => {
})
describe('same-path models on different execution hosts', () => {
it.each([FILE_PATH, 'C:\\fixture\\workspace\\same-path.txt', '\\\\server\\share\\same-path.txt'])(
'keeps host text and undo histories independent for %s',
async (filePath) => {
const { owners } = createHostModels(filePath)
expect(new Set(owners.map((owner) => owner.modelKey)).size).toBe(HOSTS.length)
for (const owner of owners) {
// Drive letters fold to lower case in model URIs.
expect(
owner.model.uri.path.toLowerCase().endsWith(monaco.Uri.file(filePath).path.toLowerCase())
).toBe(true)
}
for (const [index, owner] of owners.entries()) {
expect(monaco.editor.getModel(monaco.Uri.parse(owner.modelKey))).toBe(owner.model)
edit(owner.model, `edited host ${index}`)
expect(owner.model.canUndo()).toBe(true)
expect(owners.map((candidate) => candidate.model.getValue())).toEqual(
owners.map((candidate, candidateIndex) =>
candidateIndex <= index ? `edited host ${candidateIndex}` : candidate.initial
)
)
}
for (const [index, owner] of owners.entries()) {
await owner.model.undo()
expect(owner.model.canRedo()).toBe(true)
expect(owners.map((candidate) => candidate.model.getValue())).toEqual(
owners.map((candidate, candidateIndex) =>
candidateIndex <= index ? candidate.initial : `edited host ${candidateIndex}`
)
)
}
await owners[1]!.model.redo()
expect(owners.map((owner) => owner.model.getValue())).toEqual(
owners.map((owner, index) => (index === 1 ? 'edited host 1' : owner.initial))
)
}
)
it('suppresses watcher echoes in shared local panes without hiding an SSH user edit', () => {
const { owners } = createHostModels()
const local = owners[0]!
const ssh = owners[1]!
const accepted: string[] = []
const listeners = [local, local, ssh].map((owner, index) =>
owner.model.onDidChangeContent(() => {
if (
!shouldIgnoreMonacoContentChange({
modelKey: owner.modelKey,
isApplyingProgrammaticContent: false
})
) {
accepted.push(`pane-${index}:${owner.model.getValue()}`)
}
})
)
beginProgrammaticContentSync(local.modelKey)
try {
local.model.setValue('local watcher update')
expect(accepted).toEqual([])
edit(ssh.model, 'SSH user edit')
expect(accepted).toEqual(['pane-2:SSH user edit'])
expect(local.model.getValue()).toBe('local watcher update')
expect(owners.slice(2).map((owner) => owner.model.getValue())).toEqual(
owners.slice(2).map((owner) => owner.initial)
)
} finally {
endProgrammaticContentSync(local.modelKey)
listeners.forEach((listener) => listener.dispose())
}
})
it('closes only the selected host model while siblings retain their text and undo', async () => {
const { owners, store, attach } = createHostModels()
owners.forEach((owner, index) => edit(owner.model, `edited host ${index}`))
attach()
const enumeration = vi.spyOn(monaco.editor, 'getModels')
for (const [index, owner] of owners.entries()) {
store.getState().closeFile(owner.file.id)
await Promise.resolve()
@@ -165,7 +89,6 @@ describe('same-path models on different execution hosts', () => {
expect(sibling.model.canUndo()).toBe(true)
}
}
expect(enumeration).not.toHaveBeenCalled()
expect(store.getState().openFiles).toHaveLength(0)
})
@@ -215,9 +138,6 @@ describe('same-path models on different execution hosts', () => {
id: 'local-other-workspace',
worktreeId: 'another-workspace'
}
expect(toEditorModelUri(FILE_PATH, getEditorModelOwnerKey(sharedLocal, store.getState()))).toBe(
local.modelKey
)
store.setState({ openFiles: [...store.getState().openFiles, sharedLocal] })
attach()
edit(local.model, 'shared local edit')
@@ -277,8 +197,6 @@ describe('same-path models on different execution hosts', () => {
const ssh = owners[1]!
const legacyFile = { ...local.file, operationProvenance: undefined }
store.setState({ openFiles: [legacyFile, ...owners.slice(1).map((owner) => owner.file)] })
expect(getEditorModelOwnerKey(legacyFile, store.getState())).toBe('')
const openFiles = store.getState().openFiles
const detach = attachModelLifetimeView(local.model)
attach()
store.setState({
@@ -289,10 +207,6 @@ describe('same-path models on different execution hosts', () => {
])
)
})
expect(store.getState().openFiles).toBe(openFiles)
expect(toEditorModelUri(FILE_PATH, getEditorModelOwnerKey(legacyFile, store.getState()))).toBe(
ssh.modelKey
)
await Promise.resolve()
expect(local.model.isDisposed()).toBe(false)
detach()
@@ -307,7 +221,6 @@ describe('same-path models on different execution hosts', () => {
const local = owners[0]!
const legacyFile = { ...local.file, operationProvenance: undefined }
store.setState({ openFiles: [legacyFile, ...owners.slice(1).map((owner) => owner.file)] })
expect(getEditorModelOwnerKey(legacyFile, store.getState())).toBe('')
attach()
store.setState({ openFiles: owners.slice(1).map((owner) => owner.file), worktreesByRepo: {} })
await Promise.resolve()
@@ -34,7 +34,7 @@ const mocks = vi.hoisted(() => {
dismiss: vi.fn()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/store/index', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('sonner', () => ({
toast: { warning: mocks.warning, error: mocks.error, dismiss: mocks.dismiss }
}))
@@ -48,7 +48,7 @@ vi.mock('@/lib/editor/file-operations/editor-file-operation-provenance', () => (
captureEditorFileOperationProvenance: mocks.capture,
assertEditorFileOperationCurrent: mocks.assertCurrent
}))
import { reopenClosedUnsavedFiles, showClosedUnsavedFilesToast } from './closed-unsaved-files-toast'
import { reopenClosedUnsavedFiles } from './closed-unsaved-files-toast'
function closed(extra: Partial<ClosedUnsavedFile> = {}): ClosedUnsavedFile {
return {
@@ -58,14 +58,6 @@ function closed(extra: Partial<ClosedUnsavedFile> = {}): ClosedUnsavedFile {
...extra
}
}
function notice() {
const options = mocks.warning.mock.calls[0]?.[1]
if (!options) {
throw new Error('Expected a toast')
}
return options
}
const click = () => notice().action.onClick({ preventDefault: vi.fn() })
describe('closed unsaved files toast', () => {
beforeEach(() => {
@@ -90,67 +82,5 @@ describe('closed unsaved files toast', () => {
it('refuses when the owner route is not the active workspace’s host', async () => {
mocks.capture.mockReturnValue({ generation: { route: { executionHostId: 'local' } } })
expect(await reopenClosedUnsavedFiles([closed()])).toHaveLength(1)
expect(mocks.create).not.toHaveBeenCalled()
})
it('keeps the payload for retry when no workspace is active', async () => {
mocks.state.activeWorktreeId = null
showClosedUnsavedFilesToast([closed()], 'Feature work', 7)
click()
await vi.waitFor(() => expect(mocks.error).toHaveBeenCalledTimes(1))
expect(mocks.dismiss).not.toHaveBeenCalled()
expect(mocks.create).not.toHaveBeenCalled()
mocks.state.activeWorktreeId = 'active-wt'
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledWith('notice'))
expect(mocks.create).toHaveBeenCalledTimes(1)
})
it('retries only the documents that failed to create', async () => {
mocks.create.mockRejectedValueOnce(new Error('Offline'))
showClosedUnsavedFilesToast(
[closed(), closed({ filePath: '/removed/b.ts' })],
'Feature work',
7
)
click()
await vi.waitFor(() => expect(mocks.error).toHaveBeenCalledTimes(1))
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
expect(mocks.create).toHaveBeenCalledTimes(3)
expect(mocks.create.mock.calls[2]?.[4].recoveredDocument.originalPath).toBe('/removed/a.ts')
})
it('releases the holder after Reopen even if the library retains the callbacks', async () => {
showClosedUnsavedFilesToast([closed()], 'Feature work', 7)
click()
click()
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
click()
notice().onDismiss()
expect(mocks.create).toHaveBeenCalledTimes(1)
expect(console.warn).not.toHaveBeenCalled()
})
it('shows one combined notice and releases text on dismissal', () => {
showClosedUnsavedFilesToast(
[closed(), closed({ filePath: '/removed/b.ts' })],
'Feature work',
7
)
expect(mocks.warning).toHaveBeenCalledWith(
'Closed 2 files with unsaved edits from Feature work',
expect.anything()
)
notice().onDismiss()
notice().onDismiss()
click()
expect(mocks.create).not.toHaveBeenCalled()
expect(mocks.state.releaseKeptEditorBuffers).toHaveBeenCalledWith(7)
expect(console.warn).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledWith(
'[editor] dropped unsaved edits from a removed workspace',
{ filePaths: ['/removed/a.ts', '/removed/b.ts'] }
)
})
})
@@ -1,5 +1,5 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { ClosedUnsavedFile } from '@/store/slices/worktrees/teardown/removed-workspace-unsaved-files'
import { getEditorFileOperationContext } from '@/lib/editor/file-operations/editor-file-operation-owner'
import {

Some files were not shown because too many files have changed in this diff Show More