Editor records keyed by owner, phone active file, and a bound on frame holds (dark, layout-owner build only) (#30473)

* fix(layout): editor file records keyed by path and owner, so two hosts' files at one path stay apart (layout-owner build only)

Opening a runtime-environment file at a path a local file already held replaced the local record,
the stored-session patch and unsaved drafts matched by path alone, a move took every owner's record
of the path, and closing a bare-path tab removed both owners' records.

* fix(mobile): a windowless host's editor tab under its owned id shows as active, and retiring it clears the active file (layout-owner build only)

* fix(layout): a layout change's tab and pane holds end 15 s after it was sent when main never answers (layout-owner build only)

* fix(layout): main's view reads and prunes keep an active file under its owned id (layout-owner build only)
This commit is contained in:
Jinwoo Hong
2026-10-18 21:34:37 -04:00
committed by GitHub
parent 8ffd3742ed
commit 613c5986e3
19 changed files with 543 additions and 41 deletions
@@ -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([
@@ -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: {
@@ -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
}
@@ -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<LayoutApplyReply> } = {
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<typeof sendLayoutApply>[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<typeof sendHeld>): 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<LayoutApplyReply>(() => {})
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)
})
})
+2 -10
View File
@@ -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<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
command: { ...command, workspace: worktreeId }
})
@@ -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!,
@@ -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
@@ -79,7 +79,7 @@ export type DesktopLayoutView = Pick<WorkspaceSessionState, ViewSessionField> &
lastFocusedAt: Record<string, Record<string, number>>
/** Terminal tab id → focused and expanded pane. */
panes: Record<string, Pick<TerminalLayoutSnapshot, 'activeLeafId' | 'expandedLeafId'>>
/** Workspace key → file path → unsaved draft. */
/** Workspace key → file key (`editorFileKey`) → unsaved draft. */
editorDrafts: Record<string, Record<string, EditorDraft>>
}
@@ -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] })
}
@@ -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])
})
})
@@ -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<F extends FileOwner>(
workspace: { worktreeId: string; editorFiles?: readonly F[] },
entityId: string
@@ -24,3 +50,39 @@ export function editorTabFile<F extends FileOwner>(
editorTabShowsFile(entityId, file, workspace.worktreeId)
)
}
type EditorWorkspace<F> = {
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<F extends FileOwner>(workspace: EditorWorkspace<F>, 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<F extends FileOwner>(
before: EditorWorkspace<F>,
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)
}
@@ -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)
@@ -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<string, unknown>
@@ -13,7 +15,15 @@ const LIST_FIELDS: Record<string, (record: Json) => 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
}
@@ -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) {
@@ -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)
)
}
}
@@ -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<string, string | null> | undefined,
workspaces: Workspaces,
ids: (workspace: WorkspaceLayout) => readonly string[]
holds: (workspace: WorkspaceLayout, id: string) => boolean
): Record<string, string | null> | 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<T>(
byWorkspace: Record<string, Record<string, T>>,
@@ -54,8 +60,8 @@ function keepHeldIds<T>(
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
@@ -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()
})
})
@@ -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
@@ -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<CommandOf<'moveTabToWorkspace'>['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,