mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
refactor(editor): group core modules by feature owner (#42325)
This commit is contained in:
@@ -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
@@ -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}"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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: {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
+3
-2
@@ -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')
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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'
|
||||
+3
-3
@@ -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 & {
|
||||
+6
-6
@@ -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'
|
||||
|
||||
+9
-17
@@ -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')
|
||||
+2
-2
@@ -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
-1
@@ -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,
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-42
@@ -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'
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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+ '(.+)'/
|
||||
|
||||
+1
-252
@@ -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', {
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+7
-7
@@ -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,
|
||||
+1
-3
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+3
-3
@@ -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
-1
@@ -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.
|
||||
|
||||
+4
-6
@@ -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']
|
||||
+4
-8
@@ -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 () => {
|
||||
+8
-61
@@ -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)
|
||||
+7
-9
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
|
||||
import {
|
||||
hostSavesGrantedFiles,
|
||||
renewTerminalArtifactGrant
|
||||
+3
-3
@@ -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(
|
||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
-2
@@ -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
-1
@@ -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
|
||||
+3
-57
@@ -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)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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 = {
|
||||
+2
-2
@@ -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
-1
@@ -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,
|
||||
+3
-3
@@ -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'
|
||||
+5
-5
@@ -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>>()
|
||||
|
||||
+9
-9
@@ -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
-3
@@ -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,
|
||||
+5
-9
@@ -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' } })
|
||||
)
|
||||
})
|
||||
})
|
||||
+3
-74
@@ -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')
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
-147
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
|
||||
+4
-38
@@ -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
-1
@@ -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,
|
||||
+2
-89
@@ -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()
|
||||
|
||||
+2
-72
@@ -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
-1
@@ -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
Reference in New Issue
Block a user