Skip to content
Closed
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
18 changes: 16 additions & 2 deletions src/main/ipc/ssh-host-partition-session-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ beforeEach(() => {

/** The observed shape from #12721: the runtime owns the tab list in `ssh:<targetId>` while the
* local blob still carries the worktree key with an empty list. */
function createStrandedStore(): InstanceType<typeof Store> {
function createStrandedStore(localTabs: TerminalTab[] = []): InstanceType<typeof Store> {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-ssh-partition-export-')))
directories.push(dir)
const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') })
Expand All @@ -172,7 +172,7 @@ function createStrandedStore(): InstanceType<typeof Store> {
store.addRepo(remoteRepo(OTHER_REPO_ID, '/elsewhere/checkout', OTHER_TARGET_ID))
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: { [WORKTREE_ID]: [] }
tabsByWorktree: { [WORKTREE_ID]: localTabs }
})
store.setWorkspaceSession(
{
Expand Down Expand Up @@ -235,4 +235,18 @@ describe('remoteWorkspace:setForConnectedTargets session fallback', () => {

expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([])
})

it('publishes the owning partition rather than leftover local tabs (#22503)', async () => {
// Leftover `local` rows used to win whenever they were non-empty, so every sync re-uploaded
// tabs the user had closed and the next reconnect respawned them as empty shells.
const store = createStrandedStore([
{ ...runtimeAuthoredTab(), id: 'tab-closed-long-ago', ptyId: null, title: 'Terminal 7' }
])

await publishToConnectedTarget(store)

expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([
'tab-runtime'
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -121,26 +121,41 @@ describe('ssh host partition hydration', () => {
).toBe('session-1')
})

it('leaves a workspace the local partition already holds tabs for untouched', async () => {
// The other direction of the same rule, and the reason adoption is only gap-filling: merging
// into a populated row would re-add tabs the user had closed on every launch.
it('leaves a workspace the local partition holds tabs for untouched when the host has none', async () => {
// The base keeps its own copy while the owning partition has nothing to say about the tabs.
const partitions = strandedPartitions([], [tab('tab-local')])
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)

expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
'tab-local'
])
})

it('lets a populated host row replace leftover local tabs (#22503)', async () => {
// `local` is never pruned for an SSH workspace, so its PTY-less rows are tabs closed since the
// split. Letting them win restored each one as a fresh shell on every launch.
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(strandedPartitions([tab('tab-runtime')], [tab('tab-local')])),
partitionedApi(strandedPartitions([tab('tab-runtime')], [tab('tab-local', { ptyId: null })])),
repos
)

expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
'tab-local'
'tab-runtime'
])
expect(read.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-runtime')
expect(read.contestedPrimaryHostBySessionKey[WORKTREE_ID]).toBe(SSH_HOST_ID)
})

it("leaves that workspace's other rows alone as well", async () => {
it('keeps a local tab that still names a PTY the host never listed', async () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(strandedPartitions([tab('tab-runtime')], [tab('tab-local')])),
repos
)

expect(read.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBeUndefined()
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
'tab-runtime',
'tab-local'
])
})

it('routes the reunited workspace back to the partition that owns it', async () => {
Expand Down Expand Up @@ -313,10 +328,7 @@ describe('ssh host partition rows the host has nothing for', () => {
)
})

it('still adopts a populated host row over the base leftovers', async () => {
// The other side of the same rule: the guard must be about the host having nothing, not about
// the base having something, or adoption stops repairing the split it exists for.
const partitions = emptyHostRowsOverBaseDraft(false)
function withHostOpenFile(partitions: ReturnType<typeof emptyHostRowsOverBaseDraft>) {
partitions[SSH_HOST_ID] = session({
...partitions[SSH_HOST_ID],
openFilesByWorktree: {
Expand All @@ -330,6 +342,17 @@ describe('ssh host partition rows the host has nothing for', () => {
]
}
})
return partitions
}

it('still adopts a populated host row over the base leftovers', async () => {
// The other side of the same rule: the guard must be about the host having nothing, not about
// the base having something, or adoption stops repairing the split it exists for.
const partitions = withHostOpenFile(emptyHostRowsOverBaseDraft(false))
const [baseFile] = partitions.local.openFilesByWorktree?.[WORKTREE_ID] ?? []
partitions.local.openFilesByWorktree = {
[WORKTREE_ID]: [{ ...baseFile, dirtyDraftContent: undefined }]
}

const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)

Expand All @@ -338,6 +361,24 @@ describe('ssh host partition rows the host has nothing for', () => {
).toEqual(['src/host.ts'])
})

it('keeps an unsaved draft the populated host row does not list', async () => {
// Replacing the row must not be how a draft dies: nothing downstream can recover it.
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(withHostOpenFile(emptyHostRowsOverBaseDraft(false))),
repos
)

expect(
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => [
file.relativePath,
file.dirtyDraftContent
])
).toEqual([
['src/host.ts', undefined],
['src/main.ts', 'unsaved work']
])
})

it('adopts the layout of a tab the host slice names only in unifiedTabs', async () => {
// `buildWorktreeIdByTabId` — the index the split routes by — resolves unified-only tabs as well
// as `tabsByWorktree` ones, so their tab-keyed rows are written to this partition. A read that
Expand Down
14 changes: 2 additions & 12 deletions src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import type { DiffSource, EditorOpenTargetOptions, OpenFile } from '../types/ope
import { areLocalWindowsWslPathAliases } from '../../../../../../shared/cross-platform-path'
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome'
import { buildOwnedEditorFileId, runtimeOwnerKey } from '../../../../../../shared/editor-file-id'

export function runtimeOwnerKey(runtimeEnvironmentId: string | null | undefined): string | null {
return runtimeEnvironmentId?.trim() || null
}
export { buildOwnedEditorFileId, runtimeOwnerKey }

export function isSameEditorOwner(
file: Pick<OpenFile, 'worktreeId' | 'runtimeEnvironmentId'>,
Expand Down Expand Up @@ -37,15 +36,6 @@ export function canReuseLocalWslAlias(
)
}

export function buildOwnedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeOwnerKey(runtimeEnvironmentId) ?? 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}

export function buildDiffEditorFileId(
worktreeId: string,
diffSource: DiffSource,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* A draft that stranded-partition adoption carries into a host-won row has to come back as a tab.
* The shape of the carried unified entry is not enough: hydration assigns editor ids by owner and
* migrates tab ids between files that share a path, so this runs the real tab and editor hydration.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type {
PersistedOpenFile,
WorkspaceSessionState
} from '../../../../shared/workspace-session-state-types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import { buildOwnedEditorFileId } from '../../../../shared/editor-file-id'
import { adoptStrandedHostPartitionSession } from '../../../../shared/workspace-session-stranded-partition-adoption'
import { createStoreSessionMockApi } from './store-session-test-harness'
import { createTestStore, makeWorktree } from './store-test-helpers'

vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})

createStoreSessionMockApi()

const WORKTREE_ID = 'repo-1::/workspace'
const SAME_PATH = '/workspace/src/same.ts'

function file(filePath: string, overrides: Partial<PersistedOpenFile> = {}): PersistedOpenFile {
return {
filePath,
relativePath: filePath.slice('/workspace/'.length),
worktreeId: WORKTREE_ID,
language: 'typescript',
...overrides
}
}

function editorTab(id: string, sortOrder = 0): Tab {
return {
id,
entityId: id,
groupId: 'g1',
worktreeId: WORKTREE_ID,
contentType: 'editor',
label: id,
customLabel: null,
color: null,
sortOrder,
createdAt: 1
}
}

function group(tabOrder: string[]): TabGroup {
return { id: 'g1', worktreeId: WORKTREE_ID, activeTabId: tabOrder[0] ?? null, tabOrder }
}

function session(overrides: Partial<WorkspaceSessionState>): WorkspaceSessionState {
return { ...getDefaultWorkspaceSession(), ...overrides }
}

/** Adopt, then restore the result the way startup does. */
function restore(base: WorkspaceSessionState, host: WorkspaceSessionState) {
const adopted = adoptStrandedHostPartitionSession(base, host).session
const store = createTestStore()
store.setState({
repos: [
{ id: 'repo-1', path: '/workspace', displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
'repo-1': [makeWorktree({ id: WORKTREE_ID, repoId: 'repo-1', path: '/workspace' })]
},
activeWorktreeId: WORKTREE_ID
})
store.getState().hydrateTabsSession(adopted)
store.getState().hydrateEditorSession(adopted)
return store.getState()
}

/** Every restored file paired with the editor tab that shows it, or null when none does. */
function visibleFiles(state: ReturnType<typeof restore>) {
const tabs = state.unifiedTabsByWorktree[WORKTREE_ID] ?? []
return state.openFiles.map((open) => ({
runtime: open.runtimeEnvironmentId ?? null,
draft: state.editorDrafts[open.id] ?? null,
tab: tabs.find((tab) => tab.contentType === 'editor' && tab.entityId === open.id)?.id ?? null
}))
}

describe('a carried draft through real hydration', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('shows a runtime-owned draft whose path the host holds for the local runtime', () => {
const state = restore(
session({
openFilesByWorktree: {
[WORKTREE_ID]: [
file(SAME_PATH, { runtimeEnvironmentId: 'env-1', dirtyDraftContent: 'x' })
]
}
}),
session({
openFilesByWorktree: { [WORKTREE_ID]: [file(SAME_PATH)] },
unifiedTabs: { [WORKTREE_ID]: [editorTab(SAME_PATH)] },
tabGroups: { [WORKTREE_ID]: [group([SAME_PATH])] }
})
)

expect(visibleFiles(state)).toEqual([
{ runtime: null, draft: null, tab: SAME_PATH },
{
runtime: 'env-1',
draft: 'x',
tab: buildOwnedEditorFileId(SAME_PATH, WORKTREE_ID, 'env-1')
}
])
})

it('shows a local draft whose path the host holds for a runtime under the bare path', () => {
// The host's own tab is persisted under the bare path, which hydration hands to the runtime
// file; the carried local file has to be reached through its local owned id instead.
const state = restore(
session({
openFilesByWorktree: { [WORKTREE_ID]: [file(SAME_PATH, { dirtyDraftContent: 'x' })] }
}),
session({
openFilesByWorktree: {
[WORKTREE_ID]: [file(SAME_PATH, { runtimeEnvironmentId: 'env-1' })]
},
unifiedTabs: { [WORKTREE_ID]: [editorTab(SAME_PATH)] },
tabGroups: { [WORKTREE_ID]: [group([SAME_PATH])] }
})
)

expect(visibleFiles(state)).toEqual([
{
runtime: 'env-1',
draft: null,
tab: buildOwnedEditorFileId(SAME_PATH, WORKTREE_ID, 'env-1')
},
{ runtime: null, draft: 'x', tab: SAME_PATH }
])
})

it('keeps the bare path as the tab id of an ordinary local draft', () => {
const draftPath = '/workspace/src/draft.ts'
const state = restore(
session({
openFilesByWorktree: { [WORKTREE_ID]: [file(draftPath, { dirtyDraftContent: 'x' })] }
}),
session({
openFilesByWorktree: { [WORKTREE_ID]: [file('/workspace/src/host.ts')] },
unifiedTabs: { [WORKTREE_ID]: [editorTab('/workspace/src/host.ts')] },
tabGroups: { [WORKTREE_ID]: [group(['/workspace/src/host.ts'])] }
})
)

expect(visibleFiles(state)).toEqual([
{ runtime: null, draft: null, tab: '/workspace/src/host.ts' },
{ runtime: null, draft: 'x', tab: draftPath }
])
})

it("re-keys the base's own entry persisted under the bare path of a runtime-owned draft", () => {
const state = restore(
session({
openFilesByWorktree: {
[WORKTREE_ID]: [
file(SAME_PATH, { runtimeEnvironmentId: 'env-1', dirtyDraftContent: 'x' })
]
},
unifiedTabs: { [WORKTREE_ID]: [{ ...editorTab(SAME_PATH), customLabel: 'mine' }] }
}),
session({
openFilesByWorktree: { [WORKTREE_ID]: [file(SAME_PATH)] },
unifiedTabs: { [WORKTREE_ID]: [editorTab(SAME_PATH)] },
tabGroups: { [WORKTREE_ID]: [group([SAME_PATH])] }
})
)

const ownedId = buildOwnedEditorFileId(SAME_PATH, WORKTREE_ID, 'env-1')
expect(visibleFiles(state).map((entry) => entry.tab)).toEqual([SAME_PATH, ownedId])
expect(
state.unifiedTabsByWorktree[WORKTREE_ID]?.find((tab) => tab.id === ownedId)?.customLabel
).toBe('mine')
})
})
19 changes: 19 additions & 0 deletions src/shared/editor-file-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Editor file identity shared by the renderer's editor slice and the session read path.
*
* Why in /shared: stranded-partition adoption builds unified entries for editor files it carries
* across partitions, and those must carry the id hydration will give the file, or the file restores
* with no tab to show it.
*/
export function runtimeOwnerKey(runtimeEnvironmentId: string | null | undefined): string | null {
return runtimeEnvironmentId?.trim() || null
}

export function buildOwnedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeOwnerKey(runtimeEnvironmentId) ?? 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}
Loading
Loading