mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user