diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts index 47f37f1db8d..7b2ce35ad0b 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-round-trip-normalization.ts @@ -7,8 +7,6 @@ import { isRecord } from '../../moving-wsl-projects/wsl-move-canonical-form' /** Session fields the real pipeline does not carry yet, with why; each still differs today. */ export const WSL_MOVE_ROUND_TRIP_KNOWN_GAPS: Readonly> = { - markdownFrontmatterVisible: 'Dropped by the export (file-keyed by the UNC path); a UI toggle.', - closedTerminalTabTombstonesByTabId: 'Reopen-closed-tab records are not carried.', activeWorktreeIdsOnShutdown: 'Never seeded: focus belongs to whoever looks at the host.', terminalSurfaceTombstonesByPaneKey: 'Fences of local PTY incarnations; the host starts its own.' } diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts index 234d861f452..0b55fe77e31 100644 --- a/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-mover-profiles.ts @@ -37,7 +37,10 @@ export function wslMoverProfileScope( repoIds: new Set(name === 'mixed-windows-wsl' ? ['repo-a'] : repoIds), folderWorkspaceIds: new Set(name === 'folders-and-automations' ? ['folder-1'] : []), ownerHostId: 'local', - hostAliases: side === 'source' ? ['local'] : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`] + hostAliases: + side === 'source' + ? ['local'] + : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`, `wsl:${WSL_MOVER_DISTRO}`] } } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts index 948efebc1ad..79a3e24e4f2 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts @@ -9,7 +9,7 @@ import type { Store } from '../loading-store/store' import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite' import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores' import { wslMoveRewriteScope, type WslMoveRewriteScope } from './wsl-move-rewrite-scope' -import { withWslMoveSeededDrafts } from './wsl-move-seeded-drafts' +import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state' import { wslMoveHostSessionSeed, wslMoveSessionGlobalsPatch } from './wsl-move-session-rewrite' import { rewriteWslMoveUiRouting } from './wsl-move-ui-routing-rewrite' @@ -113,7 +113,7 @@ export function applyWslMoveSessionFocus( /** Seeds the host partition with the carried rows of moved workspaces it lacks; returns their ids. */ export function applyWslMoveHostSessionSeed( profile: Pick, - cutover: Pick, + cutover: Pick, scope: WslMoveRewriteScope, alreadySeeded: ReadonlySet ): string[] { @@ -131,13 +131,13 @@ export function applyWslMoveHostSessionSeed( if (!seed) { return [] } - const drafted = withWslMoveSeededDrafts( + const withViewState = withWslMoveSeededViewState( seed.session, profile.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID), - scope, + { ...scope, destinationEnvironmentId: cutover.destinationEnvironmentId }, new Set(seed.workspaceIds) ) // A move's one-shot seed, or its redo after a stale window; layout commands refuse runtime hosts. - profile.setWorkspaceSession(drafted ?? seed.session, hostId) + profile.setWorkspaceSession(withViewState ?? seed.session, hostId) return seed.workspaceIds } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts index 99aea8c7c50..35bb7a8f725 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-store-coverage.test.ts @@ -65,6 +65,9 @@ const STORE_BY_ROW: Record = { 'session.activeWorktreeId': SESSION_GLOBALS, 'session.activeWorkspaceExecutionHostId': SESSION_GLOBALS, 'session.activeWorkspaceKey': SESSION_GLOBALS, + // This desktop's copy, laid over the host partition the seed writes. + 'session.markdownFrontmatterVisible': HOST_SESSION_SEED, + 'session.closedTerminalTabTombstonesByTabId': HOST_SESSION_SEED, 'ui.lastActiveWorktreeId': UI_ROUTING, 'ui.manualRepoOrder': UI_ROUTING, 'ui.explorerDisplayRootByWorktree': UI_ROUTING, diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.test.ts deleted file mode 100644 index 2a7220111fd..00000000000 --- a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import type { - PersistedOpenFile, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import { withWslMoveSeededDrafts } from './wsl-move-seeded-drafts' - -const UNC = 'repo-a::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' -const HOST = 'repo-a::/home/dev/app' -const scope = { - inScope: (owner: string) => owner.startsWith('repo-a::'), - worktreeId: (id: string) => (id === UNC ? HOST : id), - path: (value: string) => value.replace('\\\\wsl.localhost\\Ubuntu', '').replaceAll('\\', '/') -} - -const file = (filePath: string, worktreeId: string, draft?: string): PersistedOpenFile => ({ - filePath, - relativePath: 'README.md', - worktreeId, - language: 'markdown', - ...(draft === undefined ? {} : { dirtyDraftContent: draft, lastKnownDiskSignature: 'sig-1' }) -}) - -const seed = (): WorkspaceSessionState => ({ - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [HOST]: [file('/home/dev/app/README.md', HOST)] } -}) -const local = { - openFilesByWorktree: { - [UNC]: [file('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\README.md', UNC, '# unsaved')] - } -} - -describe('withWslMoveSeededDrafts', () => { - it("lays this desktop's draft and its disk baseline over the seeded file", () => { - const next = withWslMoveSeededDrafts(seed(), local, scope, new Set([HOST])) - - expect(next?.openFilesByWorktree?.[HOST]).toEqual([ - { ...file('/home/dev/app/README.md', HOST, '# unsaved') } - ]) - }) - - it('leaves a workspace this run did not seed alone, so a file closed on the host stays closed', () => { - expect(withWslMoveSeededDrafts(seed(), local, scope, new Set())).toBeNull() - }) - - it('never replaces a draft the seeded record already has', () => { - const drafted = seed() - drafted.openFilesByWorktree = { - [HOST]: [file('/home/dev/app/README.md', HOST, '# newer')] - } - expect(withWslMoveSeededDrafts(drafted, local, scope, new Set([HOST]))).toBeNull() - }) -}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.ts deleted file mode 100644 index e71607c9f16..00000000000 --- a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-drafts.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Unsaved editor drafts of a moved project. The manifest never carries them (drafts stay on this - * desktop), so the host partition's seed has each drafted file without its contents. This lays - * the desktop's own draft over the seeded record, as the SSH move's draft rename does. - */ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { isHostBoundOpenFile } from '../migrating-orcad-catalog/orcad-source-draft-rename' -import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope' - -/** `seed` with the local drafts of `seededWorkspaceIds` laid over it; null when none applies. */ -export function withWslMoveSeededDrafts( - seed: WorkspaceSessionState, - local: Pick, - scope: Pick, - seededWorkspaceIds: ReadonlySet -): WorkspaceSessionState | null { - const openFilesByWorktree = { ...seed.openFilesByWorktree } - let changed = false - for (const [owner, files] of Object.entries(local.openFilesByWorktree ?? {})) { - if (!scope.inScope(owner)) { - continue - } - const hostKey = scope.worktreeId(owner) - // Only workspaces this run seeds: a file the user since closed on the host stays closed. - if (!seededWorkspaceIds.has(hostKey)) { - continue - } - for (const file of files) { - if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) { - continue - } - const hostPath = scope.path(file.filePath) - const held = openFilesByWorktree[hostKey] ?? [] - const index = held.findIndex( - (entry) => entry.filePath === hostPath && entry.dirtyDraftContent === undefined - ) - if (index === -1) { - continue - } - const next = [...held] - next[index] = { - ...held[index], - dirtyDraftContent: file.dirtyDraftContent, - ...(file.lastKnownDiskSignature !== undefined - ? { lastKnownDiskSignature: file.lastKnownDiskSignature } - : {}) - } - openFilesByWorktree[hostKey] = next - changed = true - } - } - return changed ? { ...seed, openFilesByWorktree } : null -} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts new file mode 100644 index 00000000000..1d9cdaa3667 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { withWslMoveSeededViewState } from './wsl-move-seeded-view-state' + +const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const UNC = `repo-a::${UNC_ROOT}` +const HOST = 'repo-a::/home/dev/app' +const scope = { + destinationEnvironmentId: 'wsl:Ubuntu', + inScope: (owner: string) => owner.startsWith('repo-a::'), + worktreeId: (id: string) => (id === UNC ? HOST : id), + path: (value: string) => value.replace('\\\\wsl.localhost\\Ubuntu', '').replaceAll('\\', '/') +} + +const file = (filePath: string, worktreeId: string, draft?: string): PersistedOpenFile => ({ + filePath, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + ...(draft === undefined ? {} : { dirtyDraftContent: draft, lastKnownDiskSignature: 'sig-1' }) +}) + +const seed = (): WorkspaceSessionState => ({ + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [HOST]: [file('/home/dev/app/README.md', HOST)] } +}) +const local = { + openFilesByWorktree: { [UNC]: [file(`${UNC_ROOT}\\README.md`, UNC, '# unsaved')] } +} + +describe('withWslMoveSeededViewState', () => { + it("lays this desktop's draft and its disk baseline over the seeded file", () => { + const next = withWslMoveSeededViewState(seed(), local, scope, new Set([HOST])) + + expect(next?.openFilesByWorktree?.[HOST]).toEqual([ + file('/home/dev/app/README.md', HOST, '# unsaved') + ]) + }) + + it('re-keys frontmatter visibility to the host file, and moves closed-tab tombstones', () => { + const localId = `editor:${encodeURIComponent(UNC)}:local:${encodeURIComponent(`${UNC_ROOT}\\README.md`)}` + const next = withWslMoveSeededViewState( + seed(), + { + markdownFrontmatterVisible: { [localId]: false, 'editor:other%3A%3Ax:ssh-1:%2Fx': true }, + closedTerminalTabTombstonesByTabId: { 'tab-old': { closedAt: 1, worktreeId: UNC } } + }, + scope, + new Set([HOST]) + ) + + expect(next?.markdownFrontmatterVisible).toEqual({ + [`editor:${encodeURIComponent(HOST)}:wsl%3AUbuntu:${encodeURIComponent('/home/dev/app/README.md')}`]: false + }) + expect(next?.closedTerminalTabTombstonesByTabId).toEqual({ + 'tab-old': { closedAt: 1, worktreeId: HOST } + }) + }) + + it('leaves a workspace this run did not seed alone, so a file closed on the host stays closed', () => { + expect(withWslMoveSeededViewState(seed(), local, scope, new Set())).toBeNull() + }) + + it('never replaces a draft the seeded record already has', () => { + const drafted = seed() + drafted.openFilesByWorktree = { [HOST]: [file('/home/dev/app/README.md', HOST, '# newer')] } + expect(withWslMoveSeededViewState(drafted, local, scope, new Set([HOST]))).toBeNull() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts new file mode 100644 index 00000000000..e2a69e3802f --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-seeded-view-state.ts @@ -0,0 +1,153 @@ +/** + * This desktop's view state of a moved project that the manifest never carries, laid over the + * host partition's seed: unsaved editor drafts (drafts stay on this desktop, as the SSH move's + * draft rename keeps them), markdown frontmatter visibility (keyed by a UNC file id the export + * cannot rewrite) and closed-terminal-tab tombstones. Only for workspaces seeded in this run, so + * anything the user since changed on the host stays as it is. + */ +import type { ClosedTerminalTabTombstone } from '../../../shared/closed-terminal-tab-tombstones' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import { isHostBoundOpenFile } from '../migrating-orcad-catalog/orcad-source-draft-rename' +import type { WslMoveRewriteScope } from './wsl-move-rewrite-scope' + +type ViewStateScope = Pick & { + /** The environment the moved files open under on this desktop: their editor ids name it. */ + destinationEnvironmentId: string +} + +type LocalViewState = Pick< + WorkspaceSessionState, + 'openFilesByWorktree' | 'markdownFrontmatterVisible' | 'closedTerminalTabTombstonesByTabId' +> + +/** `seed` with this desktop's view state of `seededWorkspaceIds` laid over it; null if unchanged. */ +export function withWslMoveSeededViewState( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seededWorkspaceIds: ReadonlySet +): WorkspaceSessionState | null { + const seeded = (owner: string): string | null => { + if (!scope.inScope(owner)) { + return null + } + const hostKey = scope.worktreeId(owner) + return seededWorkspaceIds.has(hostKey) ? hostKey : null + } + const drafts = withDrafts(seed, local, scope, seeded) + const frontmatter = withFrontmatter(seed, local, scope, seeded) + const tombstones = withClosedTabTombstones(seed, local, seeded) + if (!drafts && !frontmatter && !tombstones) { + return null + } + return { + ...seed, + ...(drafts ? { openFilesByWorktree: drafts } : {}), + ...(frontmatter ? { markdownFrontmatterVisible: frontmatter } : {}), + ...(tombstones ? { closedTerminalTabTombstonesByTabId: tombstones } : {}) + } +} + +type SeededKey = (owner: string) => string | null + +function withDrafts( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seeded: SeededKey +): WorkspaceSessionState['openFilesByWorktree'] | null { + const openFilesByWorktree = { ...seed.openFilesByWorktree } + let changed = false + for (const [owner, files] of Object.entries(local.openFilesByWorktree ?? {})) { + const hostKey = seeded(owner) + if (!hostKey) { + continue + } + for (const file of files) { + if (file.dirtyDraftContent === undefined || isHostBoundOpenFile(file)) { + continue + } + const hostPath = scope.path(file.filePath) + const held = openFilesByWorktree[hostKey] ?? [] + const index = held.findIndex( + (entry) => entry.filePath === hostPath && entry.dirtyDraftContent === undefined + ) + if (index === -1) { + continue + } + const next = [...held] + next[index] = { + ...held[index], + dirtyDraftContent: file.dirtyDraftContent, + ...(file.lastKnownDiskSignature !== undefined + ? { lastKnownDiskSignature: file.lastKnownDiskSignature } + : {}) + } + openFilesByWorktree[hostKey] = next + changed = true + } + } + return changed ? openFilesByWorktree : null +} + +/** `editor:::`, each part URI-encoded (ownedEditorFileId). */ +function parseEditorFileId( + id: string +): { worktreeId: string; runtimeKey: string; filePath: string } | null { + const parts = id.split(':') + if (parts.length !== 4 || parts[0] !== 'editor') { + return null + } + try { + const [worktreeId, runtimeKey, filePath] = parts.slice(1).map(decodeURIComponent) + return { worktreeId, runtimeKey, filePath } + } catch { + return null + } +} + +function withFrontmatter( + seed: WorkspaceSessionState, + local: LocalViewState, + scope: ViewStateScope, + seeded: SeededKey +): Record | null { + const next = { ...seed.markdownFrontmatterVisible } + let changed = false + for (const [id, visible] of Object.entries(local.markdownFrontmatterVisible ?? {})) { + const parsed = parseEditorFileId(id) + // Another host's file keeps its own id; only this computer's files moved. + const hostKey = parsed?.runtimeKey === 'local' ? seeded(parsed.worktreeId) : null + if (!parsed || !hostKey) { + continue + } + const hostId = ownedEditorFileId( + scope.path(parsed.filePath), + hostKey, + scope.destinationEnvironmentId + ) + if (!Object.hasOwn(next, hostId)) { + next[hostId] = visible + changed = true + } + } + return changed ? next : null +} + +function withClosedTabTombstones( + seed: WorkspaceSessionState, + local: LocalViewState, + seeded: SeededKey +): Record | null { + const next = { ...seed.closedTerminalTabTombstonesByTabId } + let changed = false + for (const [tabId, tombstone] of Object.entries(local.closedTerminalTabTombstonesByTabId ?? {})) { + const hostKey = seeded(tombstone.worktreeId) + if (hostKey && !Object.hasOwn(next, tabId)) { + next[tabId] = { ...tombstone, worktreeId: hostKey } + changed = true + } + } + return changed ? next : null +} diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 35dd749e360..f6de90eaa6b 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -143,7 +143,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { localOnlyScrollbackByTabId: moveDisposition(25, { newManifestSection: true }), remoteSessionIdsByTabId: { disposition: 'drop-pty-binding', item: 36 }, browserPagesByWorkspace: moveDisposition(29), - markdownFrontmatterVisible: moveDisposition(28), + markdownFrontmatterVisible: moveDisposition(28, { + rewriteDesktopCopy: true, + note: 'Keyed by a UNC file id; host-session-seed re-keys this desktop copy to the host file.' + }), sleepingAgentSessionsByPaneKey: moveDisposition(33), terminalPtyIncarnationsByPaneKey: { disposition: 'drop-pty-binding', @@ -151,7 +154,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { }, terminalTopologyRevisionByRepoId: { disposition: 'rederive', item: 35 }, terminalSurfaceTombstonesByPaneKey: moveDisposition(34), - closedTerminalTabTombstonesByTabId: moveDisposition(34) + closedTerminalTabTombstonesByTabId: moveDisposition(34, { + rewriteDesktopCopy: true, + note: 'Laid over the host partition by host-session-seed, with the host worktree id.' + }) } as const satisfies Record /** Nested PTY bindings dropped wherever they appear in moved session rows (item 36). */