diff --git a/src/main/runtime/mobile-session-editor-projection.test.ts b/src/main/runtime/mobile-session-editor-projection.test.ts index 1e8d9ff5a1d..b0a2803d984 100644 --- a/src/main/runtime/mobile-session-editor-projection.test.ts +++ b/src/main/runtime/mobile-session-editor-projection.test.ts @@ -54,6 +54,14 @@ function ownedSession(): WorkspaceSessionState { } } +function activeOwnedSession(): WorkspaceSessionState { + return { + ...ownedSession(), + activeTabTypeByWorktree: { [WT]: 'editor' }, + activeFileIdByWorktree: { [WT]: ownedEditorFileId(FILE, WT, undefined) } + } +} + describe('headless editor tabs under an owned id', () => { it('lists and retires the unified tab with the layout-owner switch on', () => { const session = ownedSession() @@ -65,6 +73,21 @@ describe('headless editor tabs under an owned id', () => { expect(retired?.tabGroups?.[WT]?.[0]?.tabOrder).toEqual([]) }) + it('shows it as the active file', () => { + expect(buildHeadlessMobileSessionEditorTabs(WT, activeOwnedSession())).toEqual([ + expect.objectContaining({ id: 'tab-1', isActive: true }) + ]) + }) + + it('clears the active file when it retires', () => { + const retired = retireHeadlessEditorTab(activeOwnedSession(), WT, { + id: 'tab-1', + filePath: FILE + }) + + expect(retired?.activeFileIdByWorktree?.[WT]).toBeNull() + }) + it('keeps matching by path alone with the switch off', () => { mocks.layoutOwnerIsMain = false expect(buildHeadlessMobileSessionEditorTabs(WT, ownedSession())).toEqual([ diff --git a/src/main/runtime/mobile-session-editor-projection.ts b/src/main/runtime/mobile-session-editor-projection.ts index a307b3880c5..f44dae89398 100644 --- a/src/main/runtime/mobile-session-editor-projection.ts +++ b/src/main/runtime/mobile-session-editor-projection.ts @@ -33,6 +33,7 @@ function editorTab( file: PersistedOpenFile ): HeadlessEditorTab { const unifiedTab = editorUnifiedTab(session, worktreeId, file) + const activeFileId = session.activeFileIdByWorktree?.[worktreeId] const common = { // A file's id is its path, which is also the unified tab's entity. id: unifiedTab?.id ?? file.filePath, @@ -42,7 +43,8 @@ function editorTab( isDirty: file.dirtyDraftContent !== undefined, isActive: session.activeTabTypeByWorktree?.[worktreeId] === 'editor' && - session.activeFileIdByWorktree?.[worktreeId] === file.filePath, + !!activeFileId && + editorTabShowsFile(activeFileId, file, worktreeId), color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true } @@ -97,7 +99,8 @@ export function retireHeadlessEditorTab( tabOrder: withoutTab(group.tabOrder) ?? [], ...(group.recentTabIds ? { recentTabIds: withoutTab(group.recentTabIds) } : {}) })) - const wasActiveFile = session.activeFileIdByWorktree?.[worktreeId] === tab.filePath + const activeFileId = session.activeFileIdByWorktree?.[worktreeId] + const wasActiveFile = !!activeFileId && editorTabShowsFile(activeFileId, file, worktreeId) return { ...session, openFilesByWorktree: { diff --git a/src/renderer/src/runtime/layout-apply-hold-release.ts b/src/renderer/src/runtime/layout-apply-hold-release.ts new file mode 100644 index 00000000000..111a7c1dd50 --- /dev/null +++ b/src/renderer/src/runtime/layout-apply-hold-release.ts @@ -0,0 +1,32 @@ +// The holds one layout.apply takes, released together and once: on its answer, or 15 s after the +// send when the call or main's frame never comes (the holds would otherwise outlast main's frames). + +const HOLD_TIMEOUT_MS = 15_000 + +export function releaseLayoutApplyHolds( + commandType: string, + holds: readonly ((() => void) | null)[] +): () => void { + const held = holds.filter((hold) => hold !== null) + let released = false + // Why a task later: a handler that resolves a promise (drag-out) acts in its awaiting caller, + // microtasks after the handler returns. + const release = (): void => { + clearTimeout(expiry) + if (released) { + return + } + released = true + for (const hold of held) { + setTimeout(hold, 0) + } + } + const expiry = + held.length > 0 + ? setTimeout(() => { + console.warn(`[layout-owner] ${commandType} hold released: no answer in 15s`) + release() + }, HOLD_TIMEOUT_MS) + : undefined + return release +} diff --git a/src/renderer/src/runtime/layout-apply-send.test.ts b/src/renderer/src/runtime/layout-apply-send.test.ts index 24db2d01153..2c52386984d 100644 --- a/src/renderer/src/runtime/layout-apply-send.test.ts +++ b/src/renderer/src/runtime/layout-apply-send.test.ts @@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' const mocks = vi.hoisted(() => { - const sent: { reply: LayoutApplyReply | Error } = { reply: { ok: true, result: {} } } + const sent: { reply: LayoutApplyReply | Error | Promise } = { + reply: { ok: true, result: {} } + } const owner: { kind: 'resolved' | 'ambiguous' } = { kind: 'resolved' } - return { sent, owner } + // A frame that never comes keeps its waiter here. + const frame: { arrives: boolean; waiting: (() => void)[] } = { arrives: true, waiting: [] } + const paneRelease = vi.fn() + return { sent, owner, frame, paneRelease } }) vi.mock('@/lib/resolve-owner', () => ({ runtimeEnvironmentIdUnlessTwin: () => null, @@ -20,9 +25,12 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ }) })) vi.mock('@/runtime/layout-frame-position', () => ({ - afterLayoutFrame: (_seq: number | undefined, run: () => void) => run() + afterLayoutFrame: (_seq: number | undefined, run: () => void) => + mocks.frame.arrives ? run() : mocks.frame.waiting.push(run) +})) +vi.mock('@/runtime/layout-owner-pane-intents', () => ({ + holdTabPaneTree: () => mocks.paneRelease })) -vi.mock('@/runtime/layout-owner-pane-intents', () => ({ holdTabPaneTree: () => () => {} })) import { recordLayoutApply, sendLayoutApply } from './layout-apply-send' import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds' @@ -144,3 +152,87 @@ describe('sendLayoutApply', () => { await vi.waitFor(() => expect(isDrawnGroupLayoutHeld('wt-1')).toBe(false)) }) }) + +describe('sendLayoutApply holds with no answer', () => { + const RATIOS = { type: 'setGroupRatios', groupLayout: { type: 'leaf', groupId: 'g' } } as const + const NO_ANSWER = 'setGroupRatios hold released: no answer in 15s' + + beforeEach(() => { + vi.useFakeTimers() + mocks.frame.arrives = true + mocks.frame.waiting = [] + mocks.paneRelease.mockClear() + vi.spyOn(console, 'warn') + .mockClear() + .mockImplementation(() => {}) + return () => vi.useRealTimers() + }) + + function sendHeld(workspace: string, command: Parameters[2] = RATIOS) { + const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn(), drawn: true as const } + sendLayoutApply(NO_STATE, workspace, command, handlers) + return handlers + } + + function ranNoHandler(handlers: ReturnType): void { + expect(handlers.apply).not.toHaveBeenCalled() + expect(handlers.asToday).not.toHaveBeenCalled() + expect(handlers.refused).not.toHaveBeenCalled() + } + + it('frees the holds 15 s after a call that never answers, runs no handler and warns once', async () => { + mocks.sent.reply = new Promise(() => {}) + const handlers = sendHeld('wt-silent') + + await vi.advanceTimersByTimeAsync(14_999) + expect(isDrawnGroupLayoutHeld('wt-silent')).toBe(true) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + + expect(isDrawnGroupLayoutHeld('wt-silent')).toBe(false) + ranNoHandler(handlers) + expect(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith(`[layout-owner] ${NO_ANSWER}`) + }) + + it("frees them when the answer came but main's frame never did", async () => { + mocks.sent.reply = { ok: true, result: {} } + mocks.frame.arrives = false + const handlers = sendHeld('wt-no-frame') + + await vi.advanceTimersByTimeAsync(15_000) + await vi.advanceTimersByTimeAsync(1) + + expect(isDrawnGroupLayoutHeld('wt-no-frame')).toBe(false) + ranNoHandler(handlers) + expect(console.warn).toHaveBeenCalledWith(`[layout-owner] ${NO_ANSWER}`) + }) + + it("frees a pane command's tree hold too, once, and a late frame still runs its handler", async () => { + mocks.sent.reply = { ok: true, result: { tabId: 'tab-1' } } + mocks.frame.arrives = false + const handlers = sendHeld('wt-pane', RENAME) + + await vi.advanceTimersByTimeAsync(15_000) + await vi.advanceTimersByTimeAsync(1) + expect(mocks.paneRelease).toHaveBeenCalledOnce() + + mocks.frame.waiting.forEach((run) => run()) + await vi.advanceTimersByTimeAsync(1) + expect(handlers.apply).toHaveBeenCalledWith({ tabId: 'tab-1' }) + expect(mocks.paneRelease).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledOnce() + }) + + it('an answer in time stops the timer', async () => { + mocks.sent.reply = { ok: true, result: {} } + const handlers = sendHeld('wt-answered') + + await vi.advanceTimersByTimeAsync(0) + expect(handlers.apply).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(20_000) + + expect(console.warn).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 3f6bf6f7877..4b396eb7a34 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -7,6 +7,7 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { afterLayoutFrame } from '@/runtime/layout-frame-position' import { holdTabPaneTree } from '@/runtime/layout-owner-pane-intents' import { holdDrawnTabs, type DrawnTabHold } from '@/runtime/layout-owner-drawn-tab-holds' +import { releaseLayoutApplyHolds } from '@/runtime/layout-apply-hold-release' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { TabGroupLayoutNode } from '../../../shared/tab-types' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' @@ -240,16 +241,7 @@ export function sendLayoutApply( PANE_COMMANDS.has(command.type) && 'tabId' in command ? holdTabPaneTree(command.tabId) : null const tabHolds = handlers.drawn ? drawnTabHolds(worktreeId, command) : [] const tabHold = tabHolds.length > 0 ? holdDrawnTabs(tabHolds) : null - // Why a task later: a handler that resolves a promise (drag-out) acts in its awaiting caller, - // microtasks after the handler returns. - const release = (): void => { - if (hold) { - setTimeout(hold, 0) - } - if (tabHold) { - setTimeout(tabHold, 0) - } - } + const release = releaseLayoutApplyHolds(command.type, [hold, tabHold]) void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { command: { ...command, workspace: worktreeId } }) diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection-owned-editor.test.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection-owned-editor.test.ts index 44e974b8bd1..194ca3f9a73 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection-owned-editor.test.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection-owned-editor.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ownedEditorFileId } from '../../workspace-session-pane-ownership' +import { editorFileKey } from '../workspace-layout-editor-tab-file' import { projectSessionTabs, type SessionTabsFacts } from './old-client-session-tabs-projection' import { build, testContext, WS } from '../workspace-layout-command.test-fixture' @@ -39,7 +40,13 @@ function projectOwnedReadme() { const view = { groups: {}, panes: {}, - editorDrafts: { [WS]: { [FILE]: { dirtyDraftContent: '# draft' } } } + editorDrafts: { + [WS]: { + [editorFileKey({ filePath: FILE, runtimeEnvironmentId: 'env-1' })]: { + dirtyDraftContent: '# draft' + } + } + } } return { tabId: results[0]!.tabId!, diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts index 21cd02003a6..3724292589a 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts @@ -32,7 +32,7 @@ import { type DesktopLayoutView, type LayoutContentFacts } from '../workspace-layout-beside' -import { editorTabFile } from '../workspace-layout-editor-tab-file' +import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file' import type { LayoutContentTab, LayoutTab, @@ -267,7 +267,8 @@ function editorRow( title: file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File', filePath: file.filePath, relativePath: file.relativePath, - isDirty: scope.view.editorDrafts[scope.key]?.[file.filePath]?.dirtyDraftContent !== undefined, + isDirty: + scope.view.editorDrafts[scope.key]?.[editorFileKey(file)]?.dirtyDraftContent !== undefined, color: tab.color ?? null, isPinned: tab.isPinned === true, isActive: scope.activeTabId === tab.id diff --git a/src/shared/workspace-layout/workspace-layout-beside.ts b/src/shared/workspace-layout/workspace-layout-beside.ts index 5330b7061cd..0fa036ddd2b 100644 --- a/src/shared/workspace-layout/workspace-layout-beside.ts +++ b/src/shared/workspace-layout/workspace-layout-beside.ts @@ -79,7 +79,7 @@ export type DesktopLayoutView = Pick & lastFocusedAt: Record> /** Terminal tab id → focused and expanded pane. */ panes: Record> - /** Workspace key → file path → unsaved draft. */ + /** Workspace key → file key (`editorFileKey`) → unsaved draft. */ editorDrafts: Record> } diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index bf13c7998dc..77a83d19d49 100644 --- a/src/shared/workspace-layout/workspace-layout-content-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -4,6 +4,7 @@ import type { EditorTabView } from '../editor-tab-view-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../layout-owner-switch' import { applied, refuse, workspaceOrEmpty, type Applied } from './workspace-layout-command-steps' import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import { sameEditorFile } from './workspace-layout-editor-tab-file' import { admitsInitialSurface } from './workspace-layout-initial-surface' import type { LayoutContentTab, @@ -88,7 +89,7 @@ export function openEditorTab( }) } -/** The file's record, replacing any held for its path. */ +/** The file's record, replacing any held for its path and owner. */ function withEditorFile( model: WorkspaceLayoutModel, key: string, @@ -98,7 +99,7 @@ function withEditorFile( return model } const workspace = model.workspaces[key]! - const files = (workspace.editorFiles ?? []).filter((entry) => entry.filePath !== file.filePath) + const files = (workspace.editorFiles ?? []).filter((entry) => !sameEditorFile(entry, file)) return withWorkspace(model, key, { ...workspace, editorFiles: [...files, file] }) } diff --git a/src/shared/workspace-layout/workspace-layout-editor-file-owner.test.ts b/src/shared/workspace-layout/workspace-layout-editor-file-owner.test.ts new file mode 100644 index 00000000000..85072bdc42d --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-editor-file-owner.test.ts @@ -0,0 +1,198 @@ +// A local file and a runtime-environment file at one path in one workspace are two records: each +// open, reload, move and close touches its own owner's record only. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../constants' +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import { ownedEditorFileId } from '../workspace-session-pane-ownership' +import type { PersistedOpenFile, WorkspaceSessionState } from '../workspace-session-state-types' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { CommandOf } from './workspace-layout-command-types' +import { build, testContext } from './workspace-layout-command.test-fixture' +import { loadWorkspaceLayout } from './workspace-layout-load' +import type { LayoutEditorFile, WorkspaceLayoutModel } from './workspace-layout-model' +import { patchStoredSession } from './workspace-layout-record-patch' +import { saveWorkspaceLayout } from './workspace-layout-save' +import { addWorkspace, emptySession, GIT_KEY } from './workspace-layout-session.test-fixture' + +const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true })) +vi.mock('../layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) + +beforeEach(() => { + mocks.layoutOwnerIsMain = true +}) + +const FLOATING = FLOATING_TERMINAL_WORKTREE_ID +const PATH = '/Users/dev/notes/todo.md' +const ENV = 'env-1' +const LOCAL_FILE: LayoutEditorFile = { + filePath: PATH, + relativePath: 'todo.md', + language: 'markdown' +} +const RUNTIME_FILE: LayoutEditorFile = { ...LOCAL_FILE, runtimeEnvironmentId: ENV } + +const localId = (key: string): string => ownedEditorFileId(PATH, key, null) +const runtimeId = (key: string): string => ownedEditorFileId(PATH, key, ENV) + +const open = (key: string, fileId: string, file: LayoutEditorFile): CommandOf<'openEditorTab'> => ({ + type: 'openEditorTab', + workspace: key, + tabId: fileId, + fileId, + contentType: 'editor', + file +}) + +/** Both owners' tabs open in `key`: the local one under `localTabId`, the runtime one owned. */ +function bothOpen(key: string, localTabId: string) { + const ctx = testContext() + const built = build(ctx, [ + open(key, localTabId, LOCAL_FILE), + open(key, runtimeId(key), RUNTIME_FILE) + ]) + return { ctx, model: built.model } +} + +const owners = (model: WorkspaceLayoutModel, key: string) => + (model.workspaces[key]?.editorFiles ?? []).map((file) => file.runtimeEnvironmentId ?? 'local') + +function closeTab(model: WorkspaceLayoutModel, key: string, tabId: string): WorkspaceLayoutModel { + const closed = applyLayoutCommand( + model, + { type: 'closeTabs', workspace: key, tabIds: [tabId] }, + testContext() + ) + if (!closed.ok) { + throw new Error(closed.code) + } + return closed.model +} + +function persisted(key: string, file: LayoutEditorFile, draft: string): PersistedOpenFile { + return { ...file, worktreeId: key, dirtyDraftContent: draft } +} + +describe('editor file records keyed by path and owner, layout-owner build', () => { + it("opening the second owner's file keeps the first owner's record", () => { + const { model } = bothOpen(FLOATING, localId(FLOATING)) + + expect(owners(model, FLOATING)).toEqual(['local', ENV]) + }) + + it("reopening one owner's file replaces only that owner's record", () => { + const { ctx, model } = bothOpen(FLOATING, localId(FLOATING)) + const renamed = { ...RUNTIME_FILE, relativePath: 'renamed.md' } + + const again = build(ctx, [open(FLOATING, runtimeId(FLOATING), renamed)], model) + + expect(again.model.workspaces[FLOATING]!.editorFiles).toEqual([LOCAL_FILE, renamed]) + }) + + it('a reload keeps both records, each with its own unsaved draft', () => { + const stored: WorkspaceSessionState = addWorkspace(emptySession(), FLOATING, [ + { + id: 'group-1', + tabs: [ + { id: localId(FLOATING), kind: 'editor' }, + { id: runtimeId(FLOATING), kind: 'editor' } + ] + } + ]) + stored.openFilesByWorktree = { + [FLOATING]: [ + persisted(FLOATING, LOCAL_FILE, 'local draft'), + persisted(FLOATING, RUNTIME_FILE, 'runtime draft') + ] + } + let id = 0 + const loaded = loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, stored, { + mintId: () => `minted-${++id}`, + mintLeafId: () => `00000000-0000-4000-8000-${String(++id).padStart(12, '0')}` + }) + + const saved = saveWorkspaceLayout(loaded) + + expect(saved.openFilesByWorktree?.[FLOATING]).toEqual(stored.openFilesByWorktree[FLOATING]) + }) + + it("moving one owner's tab moves only that owner's record", () => { + const ctx = testContext() + const { model } = build(ctx, [ + open(GIT_KEY, PATH, LOCAL_FILE), + open(GIT_KEY, runtimeId(GIT_KEY), RUNTIME_FILE), + open(FLOATING, localId(FLOATING), LOCAL_FILE) + ]) + const groupId = model.workspaces[FLOATING]!.groups[0]!.id + + const moved = build( + ctx, + [ + { + type: 'moveTabToWorkspace', + workspace: GIT_KEY, + toWorkspace: FLOATING, + tabs: [ + { + tabId: runtimeId(GIT_KEY), + newTabId: runtimeId(FLOATING), + newEntityId: runtimeId(FLOATING) + } + ], + groupId, + file: RUNTIME_FILE + } + ], + model + ) + + expect(owners(moved.model, GIT_KEY)).toEqual(['local']) + expect(owners(moved.model, FLOATING)).toEqual(['local', ENV]) + }) + + it.each([ + ['floating, both owned', FLOATING, localId(FLOATING)], + ['a path-id local tab', GIT_KEY, PATH] + ])('closing the local tab keeps the runtime record (%s)', (_name, key, localTabId) => { + const { model } = bothOpen(key, localTabId) + + expect(owners(closeTab(model, key, localTabId), key)).toEqual([ENV]) + }) + + it.each([ + ['floating, both owned', FLOATING, localId(FLOATING)], + ['a path-id local tab', GIT_KEY, PATH] + ])('closing the runtime tab keeps the local record (%s)', (_name, key, localTabId) => { + const { model } = bothOpen(key, localTabId) + + expect(owners(closeTab(model, key, runtimeId(key)), key)).toEqual(['local']) + }) + + it("writing the second owner's open onto the stored session adds its record", () => { + const local = { ...LOCAL_FILE, worktreeId: FLOATING } + const runtime = { ...RUNTIME_FILE, worktreeId: FLOATING } + const session = (files: PersistedOpenFile[]): WorkspaceSessionState => ({ + ...emptySession(), + openFilesByWorktree: { [FLOATING]: files } + }) + + const patched = patchStoredSession( + session([local]), + session([local]), + session([local, runtime]) + ) + + expect(patched.openFilesByWorktree?.[FLOATING]).toEqual([local, runtime]) + }) + + it('match by path alone when the switch is off, as a headless host does today', () => { + mocks.layoutOwnerIsMain = false + const { model } = bothOpen(FLOATING, localId(FLOATING)) + + expect(owners(model, FLOATING)).toEqual([ENV]) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-editor-tab-file.ts b/src/shared/workspace-layout/workspace-layout-editor-tab-file.ts index 07b00f6e104..6c35805dca7 100644 --- a/src/shared/workspace-layout/workspace-layout-editor-tab-file.ts +++ b/src/shared/workspace-layout/workspace-layout-editor-tab-file.ts @@ -16,6 +16,32 @@ export function editorTabShowsFile(entityId: string, file: FileOwner, worktreeId ) } +/** + * A file record's key among its workspace's records: its path, and in a layout-owner build its + * runtime owner too, since a local and a runtime-environment file can share a path. + */ +export function editorFileKey(file: FileOwner): string { + // Why no worktree: every record keyed here belongs to one workspace, or moves between two. + return ORCA_LAYOUT_OWNER_IS_MAIN + ? ownedEditorFileId(file.filePath, '', file.runtimeEnvironmentId) + : file.filePath +} + +export function sameEditorFile(a: FileOwner, b: FileOwner): boolean { + return editorFileKey(a) === editorFileKey(b) +} + +/** Whether an editor file id (a bare path or an owned id) names one of the workspace's records. */ +export function holdsEditorFileId( + workspace: { worktreeId: string; editorFiles?: readonly FileOwner[] }, + fileId: string +): boolean { + return ( + workspace.editorFiles?.some((file) => editorTabShowsFile(fileId, file, workspace.worktreeId)) ?? + false + ) +} + export function editorTabFile( workspace: { worktreeId: string; editorFiles?: readonly F[] }, entityId: string @@ -24,3 +50,39 @@ export function editorTabFile( editorTabShowsFile(entityId, file, workspace.worktreeId) ) } + +type EditorWorkspace = { + worktreeId: string + tabs: readonly { entityId: string }[] + editorFiles?: readonly F[] +} + +/** + * The records an editor tab shows. The window gives a path's bare id to one owner only, so a bare + * id that matches several records names those no owned-id tab claims. + */ +function editorTabFiles(workspace: EditorWorkspace, entityId: string): F[] { + const shown = (workspace.editorFiles ?? []).filter((file) => + editorTabShowsFile(entityId, file, workspace.worktreeId) + ) + if (shown.length < 2) { + return shown + } + const ids = new Set(workspace.tabs.map((tab) => tab.entityId)) + const unclaimed = shown.filter( + (file) => + !ids.has(ownedEditorFileId(file.filePath, workspace.worktreeId, file.runtimeEnvironmentId)) + ) + return unclaimed.length > 0 ? unclaimed : shown +} + +/** Whether a record stays once `entityId`'s last tab closed: not that tab's, or another tab's too. */ +export function keptAfterEditorClose( + before: EditorWorkspace, + after: { tabs: readonly { entityId: string }[] }, + entityId: string +): (file: F) => boolean { + const closed = new Set(editorTabFiles(before, entityId)) + const shown = new Set(after.tabs.flatMap((tab) => editorTabFiles(before, tab.entityId))) + return (file) => !closed.has(file) || shown.has(file) +} diff --git a/src/shared/workspace-layout/workspace-layout-load-records.ts b/src/shared/workspace-layout/workspace-layout-load-records.ts index 5e21e75f152..f8316b75c63 100644 --- a/src/shared/workspace-layout/workspace-layout-load-records.ts +++ b/src/shared/workspace-layout/workspace-layout-load-records.ts @@ -6,7 +6,7 @@ import { EDITOR_DRAFT_FIELDS } from './workspace-layout-beside' import { childRecord, omitStoredFields, pickStoredFields } from './stored-record-fields' -import { editorTabShowsFile } from './workspace-layout-editor-tab-file' +import { editorFileKey, editorTabShowsFile } from './workspace-layout-editor-tab-file' import type { WorkspaceLoadArgs } from './workspace-layout-load-types' import type { LayoutBrowserTab, LayoutEditorFile, LayoutTab } from './workspace-layout-model' @@ -54,7 +54,7 @@ export function loadEditorFiles( return files.map((file) => { const draft = pickStoredFields(file, EDITOR_DRAFT_FIELDS) if (Object.keys(draft).length > 0) { - childRecord(view.editorDrafts, key)[file.filePath] = draft + childRecord(view.editorDrafts, key)[editorFileKey(file)] = draft } const fileTabs = tabs.filter( (tab) => tab.kind === 'editor' && editorTabShowsFile(tab.entityId, file, worktreeId) diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.ts b/src/shared/workspace-layout/workspace-layout-record-patch.ts index f0a8fe823fa..f16cfc8d417 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.ts @@ -3,7 +3,9 @@ // key order, carried leftovers); diffing the save before the command against the save after it // isolates the command, and only those records and fields are written. +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../layout-owner-switch' import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { editorFileKey } from './workspace-layout-editor-tab-file' import { adoptTouchedFoldedRows } from './workspace-layout-folded-rows' type Json = Record @@ -13,7 +15,15 @@ const LIST_FIELDS: Record unknown> = { tabsByWorktree: (record) => record.id, unifiedTabs: (record) => record.id, tabGroups: (record) => record.id, - openFilesByWorktree: (record) => record.filePath, + // Why the switch: a local and a runtime-environment file can share a path in a layout-owner build. + openFilesByWorktree: (record) => + ORCA_LAYOUT_OWNER_IS_MAIN && typeof record.filePath === 'string' + ? editorFileKey({ + filePath: record.filePath, + runtimeEnvironmentId: + typeof record.runtimeEnvironmentId === 'string' ? record.runtimeEnvironmentId : null + }) + : record.filePath, browserTabsByWorktree: (record) => record.id } diff --git a/src/shared/workspace-layout/workspace-layout-save.ts b/src/shared/workspace-layout/workspace-layout-save.ts index 384fdabffeb..86161cc02e7 100644 --- a/src/shared/workspace-layout/workspace-layout-save.ts +++ b/src/shared/workspace-layout/workspace-layout-save.ts @@ -15,7 +15,7 @@ import { FACT_SESSION_FIELDS, VIEW_SESSION_FIELDS } from './workspace-layout-beside' -import { editorTabShowsFile } from './workspace-layout-editor-tab-file' +import { editorFileKey, editorTabShowsFile } from './workspace-layout-editor-tab-file' import type { LoadedWorkspaceLayout } from './workspace-layout-load-types' import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' import { paneKeyOf, tabsInOrder, type WorkspaceLayout } from './workspace-layout-model' @@ -107,7 +107,7 @@ function saveWorkspace( ...(previewTabs.some((tab) => editorTabShowsFile(tab.entityId, file, worktreeId)) ? { isPreview: true } : {}), - ...view.editorDrafts[key]?.[file.filePath] + ...view.editorDrafts[key]?.[editorFileKey(file)] })) } if (workspace.browserTabs) { diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 79845c83f28..ee2617fe706 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -1,8 +1,9 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../layout-owner-switch' import { filterRecord, omitStoredFields, withoutKey } from './stored-record-fields' import { canReplacePreviewContentType, partitionPinnedTabOrder } from './tab-order' import { getNextTerminalOrdinal } from './terminal-tab-ordinal' import { layoutContainsLeafId } from './terminal-pane-tree' -import { editorTabShowsFile } from './workspace-layout-editor-tab-file' +import { editorTabShowsFile, keptAfterEditorClose } from './workspace-layout-editor-tab-file' import { applied, boundPtyIds, @@ -104,7 +105,10 @@ export function closeTab( next = { ...next, editorFiles: next.editorFiles.filter( - (file) => !editorTabShowsFile(tab.entityId, file, next.worktreeId) + // Why the switch: shipped builds close through this code too. + ORCA_LAYOUT_OWNER_IS_MAIN + ? keptAfterEditorClose(workspace, next, tab.entityId) + : (file) => !editorTabShowsFile(tab.entityId, file, next.worktreeId) ) } } diff --git a/src/shared/workspace-layout/workspace-layout-view-prune.ts b/src/shared/workspace-layout/workspace-layout-view-prune.ts index 758f53cf727..a4f7af8143b 100644 --- a/src/shared/workspace-layout/workspace-layout-view-prune.ts +++ b/src/shared/workspace-layout/workspace-layout-view-prune.ts @@ -7,6 +7,7 @@ import { } from '../worktree/host-qualified-identity' import { filterRecord, withoutKey } from './stored-record-fields' import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' +import { editorFileKey, holdsEditorFileId } from './workspace-layout-editor-tab-file' import type { LayoutTerminalTab, WorkspaceLayout, @@ -24,7 +25,7 @@ function terminalTabs(workspace: WorkspaceLayout): LayoutTerminalTab[] { function clearMissingIds( byWorkspace: Record | undefined, workspaces: Workspaces, - ids: (workspace: WorkspaceLayout) => readonly string[] + holds: (workspace: WorkspaceLayout, id: string) => boolean ): Record | undefined { if (!byWorkspace) { return undefined @@ -32,11 +33,16 @@ function clearMissingIds( return Object.fromEntries( Object.entries(byWorkspace).map(([key, id]) => { const workspace = workspaces[key] - return [key, id === null || !workspace || ids(workspace).includes(id) ? id : null] + return [key, id === null || !workspace || holds(workspace, id) ? id : null] }) ) } +const holdsId = + (ids: (workspace: WorkspaceLayout) => readonly string[]) => + (workspace: WorkspaceLayout, id: string): boolean => + ids(workspace).includes(id) + /** Keeps a held workspace's entries for ids it still has; other workspaces are untouched. */ function keepHeldIds( byWorkspace: Record>, @@ -54,8 +60,8 @@ function keepHeldIds( const tabIds = (workspace: WorkspaceLayout): string[] => workspace.tabs.map((tab) => tab.id) const groupIds = (workspace: WorkspaceLayout): string[] => workspace.groups.map((group) => group.id) -const filePaths = (workspace: WorkspaceLayout): string[] => - workspace.editorFiles?.map((file) => file.filePath) ?? [] +const fileKeys = (workspace: WorkspaceLayout): string[] => + workspace.editorFiles?.map(editorFileKey) ?? [] const browserTabIds = (workspace: WorkspaceLayout): string[] => workspace.browserTabs?.map((tab) => tab.id) ?? [] const terminalTabIds = (workspace: WorkspaceLayout): string[] => @@ -113,7 +119,7 @@ export function pruneViewRecord( groups, lastFocusedAt: keepHeldIds(record.lastFocusedAt, workspaces, tabIds), panes, - editorDrafts: keepHeldIds(record.editorDrafts, workspaces, filePaths) + editorDrafts: keepHeldIds(record.editorDrafts, workspaces, fileKeys) } if (activeGroups) { pruned.activeGroupIdByWorktree = filterRecord( @@ -125,21 +131,21 @@ export function pruneViewRecord( pruned.activeTabIdByWorktree = clearMissingIds( record.activeTabIdByWorktree, workspaces, - terminalTabIds + holdsId(terminalTabIds) ) } if (record.activeFileIdByWorktree) { pruned.activeFileIdByWorktree = clearMissingIds( record.activeFileIdByWorktree, workspaces, - filePaths + holdsEditorFileId ) } if (record.activeBrowserTabIdByWorktree) { pruned.activeBrowserTabIdByWorktree = clearMissingIds( record.activeBrowserTabIdByWorktree, workspaces, - browserTabIds + holdsId(browserTabIds) ) } return pruned diff --git a/src/shared/workspace-layout/workspace-layout-views-owned-file.test.ts b/src/shared/workspace-layout/workspace-layout-views-owned-file.test.ts new file mode 100644 index 00000000000..430b676219b --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-views-owned-file.test.ts @@ -0,0 +1,71 @@ +// The active file under an owned id (a floating file, a second owner of a path) is a file the +// workspace holds: reading or pruning the view keeps it, as for a bare path id. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ownedEditorFileId } from '../workspace-session-pane-ownership' +import { emptyModel, WS } from './workspace-layout-command.test-fixture' +import type { WorkspaceLayout } from './workspace-layout-model' +import { pruneViewRecord } from './workspace-layout-view-prune' +import { + emptyLayoutViewRecord, + resolveWorkspaceSelection, + type LayoutViewRecord +} from './workspace-layout-views' + +const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true })) +vi.mock('../layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) + +beforeEach(() => { + mocks.layoutOwnerIsMain = true +}) + +const PATH = '/srv/app/README.md' +const OWNED = ownedEditorFileId(PATH, WS, 'env-1') + +function workspace(): WorkspaceLayout { + return { + worktreeId: WS, + groups: [{ id: 'g1', tabOrder: ['t1'] }], + tabs: [ + { id: 't1', entityId: OWNED, kind: 'editor', createdAt: 1, customTitle: null, color: null } + ], + editorFiles: [ + { + filePath: PATH, + relativePath: 'README.md', + language: 'markdown', + runtimeEnvironmentId: 'env-1' + } + ] + } +} + +const activeOn = (id: string): LayoutViewRecord => ({ + ...emptyLayoutViewRecord(), + activeFileIdByWorktree: { [WS]: id } +}) + +describe('an owned-id active file, layout-owner build', () => { + it('reads back as the active file', () => { + expect(resolveWorkspaceSelection(activeOn(OWNED), WS, workspace()).activeFileId).toBe(OWNED) + }) + + it('stays through a prune', () => { + const pruned = pruneViewRecord(activeOn(OWNED), { + ...emptyModel(), + workspaces: { [WS]: workspace() } + }) + + expect(pruned.activeFileIdByWorktree?.[WS]).toBe(OWNED) + }) + + it('falls back when no record holds it', () => { + const gone = ownedEditorFileId(PATH, WS, 'env-2') + + expect(resolveWorkspaceSelection(activeOn(gone), WS, workspace()).activeFileId).toBeNull() + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-views.ts b/src/shared/workspace-layout/workspace-layout-views.ts index b6e90d3db05..8fd308b2281 100644 --- a/src/shared/workspace-layout/workspace-layout-views.ts +++ b/src/shared/workspace-layout/workspace-layout-views.ts @@ -4,6 +4,7 @@ import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' import type { DesktopLayoutView } from './workspace-layout-beside' +import { holdsEditorFileId } from './workspace-layout-editor-tab-file' import type { WorkspaceLayout } from './workspace-layout-model' /** The one workspace window of an app instance. */ @@ -131,9 +132,7 @@ export function resolveWorkspaceSelection( : (workspace.groups[0]?.id ?? null), groups, panes, - activeFileId: workspace.editorFiles?.some((file) => file.filePath === activeFileId) - ? (activeFileId ?? null) - : null, + activeFileId: activeFileId && holdsEditorFileId(workspace, activeFileId) ? activeFileId : null, activeBrowserTabId: workspace.browserTabs?.some((tab) => tab.id === activeBrowserTabId) ? (activeBrowserTabId ?? null) : null diff --git a/src/shared/workspace-layout/workspace-layout-workspace-move.ts b/src/shared/workspace-layout/workspace-layout-workspace-move.ts index 1258e9369fd..547b3fdcbbc 100644 --- a/src/shared/workspace-layout/workspace-layout-workspace-move.ts +++ b/src/shared/workspace-layout/workspace-layout-workspace-move.ts @@ -10,6 +10,7 @@ import { type Applied } from './workspace-layout-command-steps' import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import { sameEditorFile } from './workspace-layout-editor-tab-file' import type { LayoutTab, WorkspaceLayout, WorkspaceLayoutModel } from './workspace-layout-model' import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal' import { withoutKey } from './stored-record-fields' @@ -105,7 +106,7 @@ function moveEditorFile( file: NonNullable['file']> ): { source: WorkspaceLayout; target: WorkspaceLayout } { const others = (files: WorkspaceLayout['editorFiles']) => - files?.filter((entry) => entry.filePath !== file.filePath) + files?.filter((entry) => !sameEditorFile(entry, file)) const sourceFiles = others(source.editorFiles) return { source: sourceFiles ? { ...source, editorFiles: sourceFiles } : source,