diff --git a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts index aa88fcd6d53..ccca65f73c8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts @@ -1,3 +1,4 @@ +import type { RuntimeMobileSessionMarkdownTab } from '../../../shared/runtime-types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { posix as pathPosix } from 'node:path' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' @@ -5,6 +6,9 @@ import type { Tab } from '../../../shared/tab-types' import type { OpenFile } from '../store/slices/editor' import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { recordWebSessionFocusIntent } from './web-session-focus-intent' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { makeState as makePublisherState } from './sync-runtime-graph-test-harness' import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state' import { ENV, @@ -553,3 +557,299 @@ describe('applyWebSessionTabsSnapshot', () => { }) }) }) + +describe('applyWebSessionTabsSnapshot host-invariant editor dedup', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + const variant = (id: string, isActive: boolean): RuntimeMobileSessionMarkdownTab => ({ + type: 'markdown', + id, + title: 'README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive, + sourceFileId: README_ID, + sourceFilePath: '/repo/README.md', + sourceRelativePath: 'README.md', + documentVersion: 'file:/repo/README.md' + }) + const envVariantId = (env: string) => buildOwnedEditorFileId('/repo/README.md', WT, env) + + function stateWithVariant(id: string, retained: OpenFile): WebSessionTabsSyncState { + return makeState({ + activeFileId: retained.id, + activeFileIdByWorktree: { [WT]: retained.id }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' }, + activeGroupIdByWorktree: { [WT]: 'host-group-1' }, + openFiles: [retained], + editorDrafts: { [retained.id]: '# unsaved client edit' }, + unifiedTabsByWorktree: { + [WT]: [ + { + id, + entityId: retained.id, + worktreeId: WT, + groupId: 'host-group-1', + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW - 1, + isPreview: false + } + ] + }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: id, + tabOrder: [id], + recentTabIds: [id] + } + ] + } + }) + } + + const ownedFile: OpenFile = { + id: README_ID, + filePath: '/repo/README.md', + relativePath: 'README.md', + worktreeId: WT, + language: 'markdown', + mode: 'edit', + isDirty: true, + runtimeEnvironmentId: ENV, + mirroredFromRuntimeSession: true + } + + it('mirrors one tab when a snapshot lists the same file under several environment variants', () => { + const ids = [ + envVariantId('env-a'), + envVariantId('env-b'), + envVariantId('env-c'), + buildOwnedEditorFileId('/repo/README.md', WT, null), + '/repo/README.md' + ] + const published = buildMobileSessionTabSnapshots( + makePublisherState({ + openFiles: [ownedFile], + unifiedTabsByWorktree: { + [WT]: ids.flatMap((id) => stateWithVariant(id, ownedFile).unifiedTabsByWorktree[WT] ?? []) + } + }) + )[0] + if (!published) { + throw new Error('Desktop did not publish its editor tabs') + } + expect(published.tabs).toHaveLength(5) + const tabs = published.tabs.filter((tab) => tab.type === 'markdown') + expect(tabs).toHaveLength(5) + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot(tabs, { activeTabId: null, activeTabType: null }), + ENV, + NOW + ) + + expect(patch.openFiles).toHaveLength(1) + expect(patch.unifiedTabsByWorktree?.[WT]).toHaveLength(1) + expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.id).toBe(envVariantId('env-a')) + }) + + it('keeps the variant a follow navigation targets over an active one', () => { + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([variant(envVariantId('env-a'), true), variant(envVariantId('env-b'), false)], { + activeTabId: envVariantId('env-b'), + activeTabType: 'markdown', + navigationIntent: 'follow' + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toHaveLength(1) + expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.id).toBe(envVariantId('env-b')) + }) + + it('leaves split tabs with opaque ids alone', () => { + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([variant(envVariantId('env-a'), false), variant('split-tab-7f3a', false)], { + activeTabId: null, + activeTabType: null + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toHaveLength(2) + }) + + it('keeps the visible local variant when a status snapshot marks another active', () => { + const visibleId = envVariantId('env-b') + const state = stateWithVariant(visibleId, ownedFile) + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([variant(envVariantId('env-a'), true), variant(visibleId, false)], { + activeTabId: envVariantId('env-a'), + activeTabType: 'markdown' + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([visibleId]) + expect((patch.groupsByWorktree ?? state.groupsByWorktree)[WT]?.[0]?.activeTabId).toBe(visibleId) + expect((patch.openFiles ?? state.openFiles)[0]).toMatchObject({ + id: ownedFile.id, + isDirty: true + }) + expect(patch.editorDrafts ?? state.editorDrafts).toEqual(state.editorDrafts) + }) + + it('retains a pending relay draft identity even when navigation selects another variant', () => { + const retained: OpenFile = { + ...ownedFile, + id: '/repo/README.md', + runtimeEnvironmentId: null, + pendingOwnerMigration: true + } + const state = stateWithVariant(envVariantId('env-b'), retained) + const selectedId = envVariantId('env-a') + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([variant(selectedId, true), variant(envVariantId('env-b'), false)], { + activeTabId: selectedId, + activeTabType: 'markdown', + navigationIntent: 'follow' + }), + ENV, + NOW + ) + + expect(patch.openFiles ?? state.openFiles).toEqual([retained]) + expect((patch.openFiles ?? state.openFiles)[0]).toBe(retained) + expect(patch.unifiedTabsByWorktree?.[WT]).toMatchObject([ + { id: selectedId, entityId: retained.id } + ]) + expect(patch.unifiedTabsByWorktree?.[WT]).toHaveLength(1) + expect(patch.editorDrafts ?? state.editorDrafts).toEqual(state.editorDrafts) + }) + + it('honors a pending caller focus intent over the visible and host-active variants', () => { + const visibleId = envVariantId('env-b') + const requestedId = envVariantId('env-c') + const state = stateWithVariant(visibleId, ownedFile) + recordWebSessionFocusIntent({ environmentId: ENV }, WT, requestedId) + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([ + variant(envVariantId('env-a'), true), + variant(visibleId, false), + variant(requestedId, false) + ]), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([requestedId]) + expect((patch.groupsByWorktree ?? state.groupsByWorktree)[WT]?.[0]?.activeTabId).toBe( + requestedId + ) + }) + + it.each([ + { filePath: '/workspace/notes/Read me #1%.md', worktree: 'folder:/workspace' }, + { filePath: 'C:\\workspace\\docs\\Read me.md', worktree: 'folder:C:\\workspace' }, + { filePath: '\\\\server\\share\\Read me.md', worktree: 'folder:\\\\server\\share' } + ])('collapses encoded variants for the host path $filePath', ({ filePath, worktree }) => { + const tabs = ['host:a', 'host:b'].map((environment) => ({ + ...variant(buildOwnedEditorFileId(filePath, worktree, environment), false), + filePath, + sourceFilePath: filePath + })) + const state = makeState({ activeWorktreeId: worktree }) + const snapshot = makeSnapshot(tabs, { worktree, activeTabId: null, activeTabType: null }) + const patch = applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW) + const nextState = { ...state, ...patch } + const replay = applyWebSessionTabsSnapshot(nextState, snapshot, ENV, NOW + 1) + + expect(patch.openFiles).toMatchObject([{ filePath, worktreeId: worktree }]) + expect(patch.openFiles).toHaveLength(1) + expect(patch.unifiedTabsByWorktree?.[worktree]).toHaveLength(1) + expect((replay.openFiles ?? nextState.openFiles)[0]?.id).toBe(patch.openFiles?.[0]?.id) + expect( + (replay.unifiedTabsByWorktree ?? nextState.unifiedTabsByWorktree)[worktree] + ).toHaveLength(1) + }) + + it('keeps edit and preview views distinct while collapsing edit aliases', () => { + const preview = { ...variant(envVariantId('env-c'), false), mode: 'markdown-preview' as const } + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([ + variant(envVariantId('env-a'), false), + variant(envVariantId('env-b'), false), + preview + ]), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toHaveLength(2) + expect(patch.openFiles).toMatchObject([ + { id: README_ID, mode: 'edit' }, + { mode: 'markdown-preview', markdownPreviewSourceFileId: README_ID } + ]) + }) + + it('leaves a malformed environment segment opaque', () => { + const malformedId = `editor:${encodeURIComponent(WT)}:%:${encodeURIComponent('/repo/README.md')}` + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([variant(envVariantId('env-a'), false), variant(malformedId, false)]), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([ + envVariantId('env-a'), + malformedId + ]) + }) + + it('keeps distinct backing source identities at the same path', () => { + const first = { ...variant(envVariantId('env-a'), true), sourceFileId: 'local-file-owner' } + const second = { ...variant(envVariantId('env-b'), false), sourceFileId: 'remote-file-owner' } + const patch = applyWebSessionTabsSnapshot(makeState(), makeSnapshot([first, second]), ENV, NOW) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([first.id, second.id]) + }) + + it('keeps legacy file views without a backing source identity', () => { + const tabs = ['env-a', 'env-b'].map((environment) => ({ + type: 'file' as const, + id: envVariantId(environment), + title: 'README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + language: 'plaintext', + isDirty: false, + isActive: false + })) + const patch = applyWebSessionTabsSnapshot(makeState(), makeSnapshot(tabs), ENV, NOW) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual( + tabs.map((tab) => tab.id) + ) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 2d63988f600..914edaa3259 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -1,4 +1,5 @@ import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index' +import { resolveWebSessionVisibleTabId } from '../web-session-focus-intent' import { chooseTargetGroupId, buildHostGroupIdByTabId } from './tab-group-layout-tree' import { buildMirroredBrowserTabs, @@ -35,7 +36,8 @@ export function prepareWebSessionTabsSnapshotBrowser( mirroredTerminalIds, removedTerminalIds, reconcilesNonAgentTabs, - batchContext + batchContext, + navigationIntentTab } = base const targetGroupId = chooseTargetGroupId(state, snapshot) const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) @@ -123,7 +125,8 @@ export function prepareWebSessionTabsSnapshotBrowser( targetGroupId, mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, now, - (fileId) => state.editorDrafts?.[fileId] !== undefined + (fileId) => state.editorDrafts?.[fileId] !== undefined, + navigationIntentTab?.id ?? resolveWebSessionVisibleTabId(state, worktreeId) ) const mirroredAgentTabs = buildMirroredAgentTabs( snapshot, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts index a04fe7cf787..8c4fb5ac595 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts @@ -11,6 +11,7 @@ import type { ReadyEditorSurface, MirroredEditorTab } from './state' import type { WebSessionExistingTabIndex } from '../web-session-existing-tab-index' import { isReadyEditorTab } from './terminal-surfaces' import { createMirroredEditorFileResolver } from './mirrored-editor-file-identity' +import { buildOwnedEditorFileId } from '../../store/slices/editor/file-ids/editor-file-ids' export function buildTerminalUnifiedTab( tab: TerminalTab, @@ -100,6 +101,71 @@ export function buildEditorUnifiedTab( } } +// Older peers can echo one file under IDs minted by several environments. +function mintedEnvironmentSegment(tab: ReadyEditorSurface, worktreeId: string): string | null { + if ( + tab.type !== 'markdown' || + typeof tab.sourceFileId !== 'string' || + !tab.sourceFileId || + tab.sourceFileId === tab.sourceFilePath + ) { + return null + } + if (tab.id === tab.filePath) { + return '' + } + const prefix = `editor:${encodeURIComponent(worktreeId)}:` + const suffix = `:${encodeURIComponent(tab.filePath)}` + if ( + !tab.id.startsWith(prefix) || + !tab.id.endsWith(suffix) || + tab.id.length <= prefix.length + suffix.length + ) { + return null + } + const segment = tab.id.slice(prefix.length, tab.id.length - suffix.length) + let runtimeKey: string + try { + runtimeKey = decodeURIComponent(segment) + } catch { + return null + } + return buildOwnedEditorFileId(tab.filePath, worktreeId, runtimeKey) === tab.id ? segment : null +} + +function dedupeReadyEditorTabsBySource( + tabs: readonly ReadyEditorSurface[], + worktreeId: string, + preferredHostTabId: string | null +): ReadyEditorSurface[] { + const bucketOf = (tab: ReadyEditorSurface): string => + JSON.stringify([ + worktreeId, + tab.type === 'markdown' ? tab.mode : 'edit', + tab.filePath, + tab.type === 'markdown' ? tab.sourceFileId : null + ]) + const rank = (tab: ReadyEditorSurface): number => + tab.id === preferredHostTabId ? 2 : tab.isActive ? 1 : 0 + const winnerByBucket = new Map() + for (const tab of tabs) { + const segment = mintedEnvironmentSegment(tab, worktreeId) + if (segment === null) { + continue + } + const bucket = bucketOf(tab) + const current = winnerByBucket.get(bucket) + const tabRank = rank(tab) + if (!current || tabRank > current.rank) { + winnerByBucket.set(bucket, { segment, rank: tabRank }) + } + } + return tabs.filter((tab) => { + const segment = mintedEnvironmentSegment(tab, worktreeId) + return segment === null || segment === winnerByBucket.get(bucketOf(tab))?.segment + }) +} + export function buildMirroredEditorTabs( snapshot: RuntimeMobileSessionTabsResult, environmentId: string, @@ -109,7 +175,8 @@ export function buildMirroredEditorTabs( fallbackGroupId: string, sortOffset: number, now: number, - hasLocalDraft: (fileId: string) => boolean + hasLocalDraft: (fileId: string) => boolean, + preferredHostTabId: string | null = null ): MirroredEditorTab[] { const readyTabs = snapshot.tabs.filter(isReadyEditorTab) if (readyTabs.length === 0) { @@ -120,7 +187,12 @@ export function buildMirroredEditorTabs( for (const tab of readyTabs) { resolveFile(tab, existingTabIndex.getEditorUnifiedTabByHostId(tab.id)) } - return readyTabs.map((tab, index) => { + const distinctTabs = dedupeReadyEditorTabsBySource( + readyTabs, + snapshot.worktree, + preferredHostTabId + ) + return distinctTabs.map((tab, index) => { const identity = resolveFile(tab, existingTabIndex.getEditorUnifiedTabByHostId(tab.id)) const { fileId, existingFile, sourceFileId } = identity const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) diff --git a/tests/e2e/headless-editor-mirror-identities.unit.test.ts b/tests/e2e/headless-editor-mirror-identities.unit.test.ts new file mode 100644 index 00000000000..b362575d752 --- /dev/null +++ b/tests/e2e/headless-editor-mirror-identities.unit.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildHeadlessMobileSessionEditorTabs } from '../../src/main/runtime/mobile-session-editor-projection' +import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' +import { buildOwnedEditorFileId } from '../../src/renderer/src/store/slices/editor/file-ids/editor-file-ids' +import { applyWebSessionTabsSnapshot } from '../../src/renderer/src/runtime/web-session-tabs-sync/snapshot-api' +import { + ENV, + NOW, + WT, + makeSnapshot, + makeState +} from '../../src/renderer/src/runtime/web-session-tabs-sync-test-harness' + +const host = vi.hoisted(() => ({ layoutOwnerIsMain: true })) +vi.mock('../../src/shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return host.layoutOwnerIsMain + } +})) + +describe('headless editor identity in an older desktop mirror', () => { + it('keeps two owners when the producer represents both source identities by the path', () => { + const filePath = '/repo/README.md' + const owners = [null, 'other-host'] + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [WT]: owners.map((owner) => ({ + filePath, + relativePath: 'README.md', + worktreeId: WT, + language: 'markdown', + runtimeEnvironmentId: owner, + ...(owner ? { dirtyDraftContent: 'remote draft' } : {}) + })) + }, + unifiedTabs: { + [WT]: owners.map((owner, index) => ({ + id: buildOwnedEditorFileId(filePath, WT, owner), + entityId: buildOwnedEditorFileId(filePath, WT, owner), + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: index, + createdAt: NOW, + isPreview: false + })) + } + } + const tabs = buildHeadlessMobileSessionEditorTabs(WT, session) + expect(tabs).toHaveLength(2) + expect(tabs.map((tab) => tab.id)).toEqual( + owners.map((owner) => buildOwnedEditorFileId(filePath, WT, owner)) + ) + expect(tabs.map((tab) => tab.isDirty)).toEqual([false, true]) + + host.layoutOwnerIsMain = false + const patch = applyWebSessionTabsSnapshot(makeState(), makeSnapshot(tabs), ENV, NOW) + + expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual( + tabs.map((tab) => tab.id) + ) + }) +})