Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/claude-structured-session-integration.test.ts',
'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts',
'src/main/runtime/exit-provenance-audit.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/orchestration-cli-subprocess.test.ts',
'src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts',
'src/main/runtime/orchestration-mailbox-crash-recovery.test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void {
worktreeId: file.worktreeId,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
expectedExternalSshTargetId: file.externalSshTargetId,
expectedRuntimeEnvironmentId: file.runtimeEnvironmentId,
access: editorTabFileAccess(state, file)
})
.then((result) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function ExternalFileChangeCompareDialog({
worktreeId: file.worktreeId,
connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined,
expectedExternalSshTargetId: file.externalSshTargetId,
expectedRuntimeEnvironmentId: file.runtimeEnvironmentId,
access
})
.then((result) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,28 @@ it('keeps a live model and releases only its closed sibling', async () => {
expect(live.model.isDisposed()).toBe(false)
})

it('preserves a shared URI until its final file owner closes', async () => {
it('releases a shared URI when all clean duplicate records close', async () => {
const { store, attach, add } = createModelLifetimeFixture()
const { file, model } = add('shared-a')
store.setState({ openFiles: [file, { ...file, id: 'shared-b', readOnly: true }] })
store.setState({ openFiles: [file, { ...file, id: 'shared-b' }] })
attach()
store.getState().closeFile(file.id)
await Promise.resolve()
expect(store.getState().openFiles).toHaveLength(0)
expect(model.isDisposed()).toBe(true)
})

it('keeps a dirty shared-model duplicate alive until its own close', async () => {
const { store, attach, add } = createModelLifetimeFixture()
const { file, model } = add('shared-dirty-a')
const sibling = { ...file, id: 'shared-dirty-b', isDirty: true }
store.setState({ openFiles: [file, sibling], editorDrafts: { [sibling.id]: 'unsaved text' } })
attach()
store.getState().closeFile(file.id)
await Promise.resolve()
expect(store.getState().openFiles.map((openFile) => openFile.id)).toEqual(['shared-b'])
expect(model.isDisposed()).toBe(false)
store.getState().closeFile('shared-b')
expect(store.getState().editorDrafts[sibling.id]).toBe('unsaved text')
store.getState().closeFile(sibling.id)
await Promise.resolve()
expect(model.isDisposed()).toBe(true)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resetModelLifetimeFixtures
} from './editor-model-lifetime-fixture'
import { getEditorModelOwnerKey } from './editor-model-owner'
import { buildPersistedEditorFileRecords } from '@/lib/workspace-session-editor-records'
import { toEditorModelUri } from './editor-model-uri'
import {
beginProgrammaticContentSync,
Expand Down Expand Up @@ -78,6 +79,65 @@ afterEach(() => {
})

describe('same-path models on different execution hosts', () => {
it('persists captured ownership while preserving contradictory records until explicitly closed', async () => {
const { owners, store, attach } = createHostModels()
const remote = owners[3]!
const stale = {
...remote.file,
id: 'stale-fields',
runtimeEnvironmentId: 'wrong-hub',
externalSshTargetId: 'wrong-target'
}
store.setState({ openFiles: [remote.file, stale] })
const records = Object.values(
buildPersistedEditorFileRecords([remote.file, stale], {}, {}).openFilesByWorktree
).flat()
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({
runtimeEnvironmentId: 'hub-a',
externalSshTargetId: 'target'
})
attach()
store.getState().closeFile(remote.file.id)
await Promise.resolve()
expect(store.getState().openFiles).toEqual([stale])
expect(remote.model.isDisposed()).toBe(false)
store.getState().closeFile(stale.id)
await Promise.resolve()
expect(store.getState().openFiles).toHaveLength(0)
expect(remote.model.isDisposed()).toBe(true)
})

it.each(['clean', 'same-draft', 'different-drafts'] as const)(
'persists all captured owners of same-path %s documents',
(kind) => {
const { owners } = createHostModels()
const files = owners.map((owner) => ({ ...owner.file, isDirty: kind !== 'clean' }))
const drafts = Object.fromEntries(
files.map((file, index) => [
file.id,
kind === 'same-draft' ? 'identical unsaved text' : `draft ${index}`
])
)
const records = Object.values(
buildPersistedEditorFileRecords(files, drafts, {}).openFilesByWorktree
).flat()
expect(records).toHaveLength(HOSTS.length)
expect(
records.map((record) => [record.runtimeEnvironmentId, record.externalSshTargetId ?? null])
).toEqual([
[null, null],
[null, 'target'],
['hub-a', null],
['hub-a', 'target'],
['hub-b', 'target']
])
expect(records.map((record) => record.dirtyDraftContent)).toEqual(
files.map((file) => (kind === 'clean' ? undefined : drafts[file.id]))
)
}
)

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) => {
Expand Down Expand Up @@ -258,6 +318,7 @@ describe('same-path models on different execution hosts', () => {
}
const detach = attachModelLifetimeView(local.model)
edit(runtime.model, 'runtime independent edit')
store.setState({ editorDrafts: { [runtime.file.id]: 'runtime independent edit' } })
attach()
store.setState({
openFiles: store
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void {
worktreeId: file.worktreeId,
connectionId: getFileConnectionId(file),
expectedExternalSshTargetId: file.externalSshTargetId,
expectedRuntimeEnvironmentId: file.runtimeEnvironmentId,
access: editorTabFileAccess(state, file)
})
if (disposed) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ function startTracking(
describe('Monaco cursor-line ownership lifetime', () => {
it('releases positions after 250 distinct owned editor tabs close', () => {
const store = createEditorStore()
store.setState({ unifiedTabsByWorktree: {} })
for (let index = 0; index < 250; index += 1) {
const file = makeFile(`editor:wt-1:local:file-${index}`, `/repo/file-${index}.ts`)
store.setState({ openFiles: [file] })
Expand All @@ -62,7 +61,7 @@ describe('Monaco cursor-line ownership lifetime', () => {
...makeFile('editor:wt-1:remote-a:file.ts', '/repo/file.ts'),
runtimeEnvironmentId: 'remote-a'
}
store.setState({ openFiles: [local, remote], unifiedTabsByWorktree: {} })
store.setState({ openFiles: [local, remote] })
const localTracking = startTracking(store, local, 12)
const remoteTracking = startTracking(store, remote, 35)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export function createRichMarkdownImageResolverContext({
worktreeId,
worktreePath: worktreeRoot,
connectionId: getConnectionId(worktreeId),
expectedExternalSshTargetId: externalSshTargetId
expectedExternalSshTargetId: externalSshTargetId,
expectedRuntimeEnvironmentId: runtimeEnvironmentId
}
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,15 @@ export function useMarkdownPreviewSourceFoundation({
worktreeId: sourceRoutingWorktreeId,
worktreePath: worktreeRoot,
connectionId: sourceConnectionId,
expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId
expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId,
expectedRuntimeEnvironmentId: sourceOpenFile?.runtimeEnvironmentId
}
: undefined,
[
settings,
sourceConnectionId,
sourceOpenFile?.externalSshTargetId,
sourceOpenFile?.runtimeEnvironmentId,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export function useEditorPanelFileContentLoader({
worktreeId: readWorktreeId,
connectionId: readConnectionId,
expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId,
expectedRuntimeEnvironmentId: restoredOpenFile?.runtimeEnvironmentId,
includeLocalLogMetadata: isLiveTailLogTab,
access
},
Expand Down
23 changes: 15 additions & 8 deletions src/renderer/src/hooks/editor-external-watch-disk-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,17 @@ function readFileForEchoVerification(args: {
worktreeId: string | null | undefined
connectionId: string | undefined
expectedExternalSshTargetId?: string
expectedRuntimeEnvironmentId?: string | null
}): ReturnType<typeof readRuntimeFileContent> {
const key = [
args.runtimeEnvironmentId ?? '',
args.connectionId ?? '',
args.expectedExternalSshTargetId ?? '',
const key = JSON.stringify([
args.runtimeEnvironmentId ?? null,
args.connectionId ?? null,
args.expectedExternalSshTargetId ?? null,
args.expectedRuntimeEnvironmentId ?? null,
args.worktreeId ?? null,
args.relativePath,
args.filePath
].join('::')
])
let pending = inFlightEchoVerificationReads.get(key)
if (!pending) {
pending = readRuntimeFileContent({
Expand All @@ -72,7 +76,8 @@ function readFileForEchoVerification(args: {
relativePath: args.relativePath,
worktreeId: args.worktreeId ?? undefined,
connectionId: args.connectionId,
expectedExternalSshTargetId: args.expectedExternalSshTargetId
expectedExternalSshTargetId: args.expectedExternalSshTargetId,
expectedRuntimeEnvironmentId: args.expectedRuntimeEnvironmentId
})
inFlightEchoVerificationReads.set(key, pending)
const release = (): void => {
Expand Down Expand Up @@ -226,7 +231,8 @@ export function scheduleEditorSelfMoveEchoVerification(
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
expectedExternalSshTargetId: file.externalSshTargetId,
expectedRuntimeEnvironmentId: file.runtimeEnvironmentId
})
.then((result) => {
const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content)
Expand Down Expand Up @@ -261,7 +267,8 @@ export function scheduleSelfWriteAwareEditorExternalReload(
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId,
expectedExternalSshTargetId: file.externalSshTargetId
expectedExternalSshTargetId: file.externalSshTargetId,
expectedRuntimeEnvironmentId: file.runtimeEnvironmentId
})
.then((result) => {
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { readRuntimeFileContent } from '@/runtime/runtime-file-client'
import { scheduleSelfWriteAwareEditorExternalReload } from './editor-external-watch-disk-verification'

vi.mock('@/runtime/runtime-file-client', () => ({ readRuntimeFileContent: vi.fn() }))

const target = {
worktreeId: 'repo::/repo',
worktreePath: '/repo',
connectionId: 'ssh-1',
runtimeEnvironmentId: 'hub-a'
}
const notification = { ...target, relativePath: 'file.txt' }

function file(runtimeEnvironmentId: string | null): OpenFile {
return {
id: '/repo/file.txt',
filePath: '/repo/file.txt',
relativePath: 'file.txt',
worktreeId: target.worktreeId,
language: 'plaintext',
isDirty: false,
mode: 'edit',
runtimeEnvironmentId,
externalSshTargetId: 'ssh-1'
}
}

beforeEach(() => {
vi.mocked(readRuntimeFileContent).mockReset()
useAppStore.setState({ openFiles: [] })
})

describe('external editor verification ownership', () => {
it('keeps verification reads separate when workspace selectors differ', async () => {
const releases: (() => void)[] = []
vi.mocked(readRuntimeFileContent).mockImplementation(
() =>
new Promise((resolve) => {
releases.push(() => resolve({ content: 'saved', isBinary: false }))
})
)
try {
for (const worktreeId of [target.worktreeId, 'other-repo::/repo']) {
scheduleSelfWriteAwareEditorExternalReload(
target,
notification,
{ ...file('hub-a'), worktreeId },
{ content: 'saved' }
)
}
expect(readRuntimeFileContent).toHaveBeenCalledTimes(2)
expect(readRuntimeFileContent).toHaveBeenLastCalledWith(
expect.objectContaining({ worktreeId: 'other-repo::/repo' })
)
} finally {
releases.forEach((release) => release())
await Promise.resolve()
}
})

it.each([false, true])(
'shares reads only for the same recorded owner (%s)',
async (different) => {
const releases: (() => void)[] = []
vi.mocked(readRuntimeFileContent).mockImplementation(
() =>
new Promise((resolve) => {
releases.push(() => resolve({ content: 'saved', isBinary: false }))
})
)
try {
for (const runtime of ['hub-a', different ? null : 'hub-a']) {
scheduleSelfWriteAwareEditorExternalReload(target, notification, file(runtime), {
content: 'saved'
})
}
expect(readRuntimeFileContent).toHaveBeenCalledTimes(different ? 2 : 1)
if (different) {
expect(readRuntimeFileContent).toHaveBeenLastCalledWith(
expect.objectContaining({
settings: { activeRuntimeEnvironmentId: 'hub-a' },
expectedExternalSshTargetId: 'ssh-1',
expectedRuntimeEnvironmentId: null
})
)
}
} finally {
releases.forEach((release) => release())
await Promise.resolve()
}
}
)
})
24 changes: 24 additions & 0 deletions src/renderer/src/hooks/ipc-tab-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,30 @@ describe('handleSwitchRecentTab', () => {
expect(store.setActiveTabType).toHaveBeenCalledWith('browser', 'wt-1')
})

it('skips an MRU entry naming a tab that no longer renders', () => {
const store = makeStore('editor')
store.activeFileId = 'file-c'
store.groupsByWorktree = {
'wt-1': [
{
id: 'group-1',
activeTabId: 'tab-c',
tabOrder: ['tab-a', 'tab-c'],
recentTabIds: ['tab-a', 'tab-pruned', 'tab-c']
}
]
}
getStateMock.mockReturnValue(store)
getActiveTabNavOrderMock.mockReturnValue([
{ type: 'editor', id: 'file-a', tabId: 'tab-a' },
{ type: 'editor', id: 'file-c', tabId: 'tab-c' }
])

expect(handleSwitchRecentTab()).toBe(true)
expect(store.setActiveFile).toHaveBeenCalledWith('file-a')
expect(store.activateTab).toHaveBeenCalledWith('tab-a')
})

it('returns false when the MRU stack has no previous visible tab', () => {
const store = makeStore('terminal')
store.groupsByWorktree = {
Expand Down
Loading
Loading