diff --git a/config/scripts/generate-monaco-associations.mjs b/config/scripts/generate-monaco-associations.mjs index ea81be3fa47..7e3db50af40 100644 --- a/config/scripts/generate-monaco-associations.mjs +++ b/config/scripts/generate-monaco-associations.mjs @@ -7,7 +7,7 @@ import ts from 'typescript-api' const require = createRequire(import.meta.url) const editorEntry = require.resolve('monaco-editor/esm/vs/editor/editor.main.js') export const associationsPath = fileURLToPath( - new URL('../../src/renderer/src/lib/monaco-language-associations.json', import.meta.url) + new URL('../../src/shared/editor-language/monaco-language-associations.json', import.meta.url) ) // Monaco omits common Ruby task, template and configuration files. diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 86738c4cb37..d8acb95b6c6 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -3,6 +3,7 @@ "include": [ "../src/cli/**/*", "../src/shared/**/*", + "../src/shared/editor-language/monaco-language-associations.json", "../src/packages/process-host/src/__fixtures__/**/*", "../src/main/agent-state-file-reader.ts", "../src/main/gitlab/project-ref-parser.ts", diff --git a/src/main/runtime/construction/runtime-file-command-state.ts b/src/main/runtime/construction/runtime-file-command-state.ts index 6cc4666f013..938a7e21549 100644 --- a/src/main/runtime/construction/runtime-file-command-state.ts +++ b/src/main/runtime/construction/runtime-file-command-state.ts @@ -33,6 +33,13 @@ import type { Repo } from '../../../shared/repo-types' import { isWslMoveWorktreeBlocked } from '../../persistence/moving-wsl-projects/wsl-move-background-backoff' import { worktreeRowTarget } from '../../persistence/host-qualified-worktree-meta' import type { RuntimeRepoSelector } from '../runtime-repo-selector' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import { + openEditorFileInWindow, + openEditorFileThroughLayoutOwner +} from '../files/editor-open-through-layout-owner' export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchCleanup { abstract resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ @@ -132,17 +139,28 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv if (isChatScratchWorktreeId(worktreeId)) { throw new Error('chat_scratch_not_openable') } - if (!this.notifier?.openFile) { - throw new Error('renderer_unavailable') - } - this.notifier.openFile( + const open = { worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation, - ...(position ? [position] : []) - ) + position + } + // Switch point: main writes the tab itself, window or none; a window only shows it. + return ORCA_LAYOUT_OWNER_IS_MAIN + ? openEditorFileThroughLayoutOwner( + { + applyLayoutCommand: (command) => this.applyLayoutCommand(command), + groupFor: (id) => this.editorOpenGroupFor(id), + ...(this.notifier?.revealEditorTab + ? { revealEditorTab: (shown) => this.notifier?.revealEditorTab?.(shown) } + : {}), + openInWindow: (inWindow) => openEditorFileInWindow(this.notifier, inWindow) + }, + open + ) + : openEditorFileInWindow(this.notifier, open) }, openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation) => { if (isChatScratchWorktreeId(worktreeId)) { @@ -162,6 +180,11 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv } }) + abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + + /** The group a file main opens lands in: the window's live one, else the saved view's. */ + protected abstract editorOpenGroupFor(worktreeId: string): string | undefined + protected readonly fileWatcherRemoval = createRuntimeFileWatcherRemoval(this.fileCommands) closeFileWatchersForRemoval = this.fileWatcherRemoval.close diff --git a/src/main/runtime/editor-open-active-group.test.ts b/src/main/runtime/editor-open-active-group.test.ts new file mode 100644 index 00000000000..ec31a7f97a3 --- /dev/null +++ b/src/main/runtime/editor-open-active-group.test.ts @@ -0,0 +1,74 @@ +import './orca-runtime-test-lifecycle.spec' +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeStore } from './runtime-store-contract' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession +} from './orca-runtime-test-fixtures.spec' +import { openEditorFileThroughLayoutOwner } from './files/editor-open-through-layout-owner' +import { ownedEditorFileId } from '../../shared/workspace-session-pane-ownership' +import { + addWorkspace, + emptySession, + leaf +} from '../../shared/workspace-layout/workspace-layout-session.test-fixture' + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +class RuntimeWithEditorOpenGroup extends OrcaRuntimeService { + groupForTest(worktreeId: string): string | undefined { + return this.editorOpenGroupFor(worktreeId) + } +} + +/** A host whose saved view shows the first of two groups. */ +function twoGroupHost() { + const saved = addWorkspace(emptySession(), TEST_WORKTREE_ID, [ + { id: 'g1', tabs: [{ id: 't1', leaves: [[leaf(1)]] }] }, + { id: 'g2', tabs: [{ id: 't2', leaves: [[leaf(2)]] }] } + ]) + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(saved) + Object.assign(runtimeStore, { getWorkspaceSessionHostIds: () => ['local'] }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures. + const runtime = new RuntimeWithEditorOpenGroup(runtimeStore as RuntimeStore) + const open = () => + openEditorFileThroughLayoutOwner( + { + applyLayoutCommand: (command) => runtime.applyLayoutCommand(command), + groupFor: (id) => runtime.groupForTest(id), + openInWindow: () => { + throw new Error('opened through the window') + } + }, + { worktreeId: TEST_WORKTREE_ID, filePath: '/tmp/worktree-a/a.ts', relativePath: 'a.ts' } + ) + const groupOfFile = () => + getSession().unifiedTabs?.[TEST_WORKTREE_ID]?.find( + (tab) => tab.entityId === ownedEditorFileId('/tmp/worktree-a/a.ts', TEST_WORKTREE_ID, null) + )?.groupId + return { runtime, open, groupOfFile } +} + +describe('a file main opens lands in the group the window shows', () => { + it('the group the window switched to just now, not the saved one', async () => { + const host = twoGroupHost() + host.runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + activeGroupByWorkspace: { [TEST_WORKTREE_ID]: 'g2' } + }) + + await host.open() + + expect(host.groupOfFile()).toBe('g2') + }) + + it("the saved view's group while no window has reported one", async () => { + const host = twoGroupHost() + + await host.open() + + expect(host.groupOfFile()).toBe('g1') + }) +}) diff --git a/src/main/runtime/files/editor-open-through-layout-owner.test.ts b/src/main/runtime/files/editor-open-through-layout-owner.test.ts new file mode 100644 index 00000000000..f9ef201def0 --- /dev/null +++ b/src/main/runtime/files/editor-open-through-layout-owner.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import { + openEditorFileThroughLayoutOwner, + type EditorOpenHost +} from './editor-open-through-layout-owner' + +const OPEN = { worktreeId: 'wt', filePath: '/repo/src/a.ts', relativePath: 'src/a.ts' } + +function host(replies: LayoutApplyReply[], groupId?: string) { + const sent: WindowLayoutCommand[] = [] + const reveal = vi.fn() + const openInWindow = vi.fn() + const value: EditorOpenHost = { + applyLayoutCommand: async (command) => { + sent.push(command) + return replies.shift() ?? { ok: true, result: {} } + }, + groupFor: () => groupId, + revealEditorTab: reveal, + openInWindow + } + return { value, sent, reveal, openInWindow } +} + +describe('a file main opens itself', () => { + it("writes the tab in the window's reported group, then shows it at its frame", async () => { + const h = host([{ ok: true, result: { tabId: 'main-tab' }, frameSeq: 7 }], 'g2') + await openEditorFileThroughLayoutOwner(h.value, OPEN) + expect(h.sent).toEqual([ + expect.objectContaining({ + type: 'openEditorTab', + workspace: 'wt', + contentType: 'editor', + groupId: 'g2', + file: { filePath: '/repo/src/a.ts', relativePath: 'src/a.ts', language: 'typescript' } + }) + ]) + expect(h.sent[0]).not.toHaveProperty('fileId') + expect(h.reveal).toHaveBeenCalledWith({ ...OPEN, tabId: 'main-tab', frameSeq: 7 }) + }) + + it('opens by main’s own rule when the reported group has closed', async () => { + const h = host( + [ + { ok: false, code: 'group_not_found' }, + { ok: true, result: { tabId: 'main-tab' } } + ], + 'gone' + ) + await openEditorFileThroughLayoutOwner(h.value, OPEN) + expect(h.sent[1]).not.toHaveProperty('groupId') + expect(h.reveal).toHaveBeenCalledOnce() + }) + + it('opens through the window for a workspace a paired server owns', async () => { + const h = host([{ ok: false, code: 'host_not_owned' }]) + await openEditorFileThroughLayoutOwner(h.value, OPEN) + expect(h.openInWindow).toHaveBeenCalledWith(OPEN) + expect(h.reveal).not.toHaveBeenCalled() + }) + + it('fails the open main refused', async () => { + const h = host([{ ok: false, code: 'workspace_not_found' }]) + await expect(openEditorFileThroughLayoutOwner(h.value, OPEN)).rejects.toThrow( + 'editor_open_refused: workspace_not_found' + ) + }) + + it('needs no window', async () => { + const h = host([{ ok: true, result: { tabId: 'main-tab' } }]) + const { revealEditorTab: _none, ...headless } = h.value + await expect(openEditorFileThroughLayoutOwner(headless, OPEN)).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/files/editor-open-through-layout-owner.ts b/src/main/runtime/files/editor-open-through-layout-owner.ts new file mode 100644 index 00000000000..caf914666f0 --- /dev/null +++ b/src/main/runtime/files/editor-open-through-layout-owner.ts @@ -0,0 +1,81 @@ +// A file open main makes itself in a layout-owner build (CLI, phone, a host with no window): main +// writes the editor tab, opening it in the group the window last reported, and a window, if one is +// attached, only shows it. A paired server's workspace keeps today's window open. + +import { randomUUID } from 'node:crypto' +import { detectLanguage } from '../../../shared/editor-language/editor-language-detect' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { + RuntimeEditorFileOpen, + RuntimeEditorTabReveal +} from '../../../shared/runtime-file-contracts' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import type { RuntimeNotifier } from '../runtime-notifier-contract' + +export type EditorFileOpen = RuntimeEditorFileOpen + +export type EditorOpenHost = { + applyLayoutCommand: (command: WindowLayoutCommand) => Promise + /** The group the window last reported for the workspace, else its saved view's. */ + groupFor: (worktreeId: string) => string | undefined + /** Shows main's tab in an attached window; absent with no window. */ + revealEditorTab?: (reveal: RuntimeEditorTabReveal) => void + /** Today's open, through the window: a paired server owns the workspace's layout. */ + openInWindow: (open: EditorFileOpen) => void +} + +export async function openEditorFileThroughLayoutOwner( + host: EditorOpenHost, + open: EditorFileOpen +): Promise { + const basename = open.relativePath.split(/[\\/]/).pop() || open.relativePath + const runtimeEnvironmentId = open.runtimeEnvironmentId?.trim() + const command: WindowLayoutCommand = { + type: 'openEditorTab', + workspace: open.worktreeId, + tabId: randomUUID(), + contentType: 'editor', + file: { + filePath: open.filePath, + relativePath: open.relativePath, + language: detectLanguage(basename), + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) + }, + createdAt: Date.now() + } + const groupId = host.groupFor(open.worktreeId) + let reply = await host.applyLayoutCommand(groupId ? { ...command, groupId } : command) + // A group closed since the window reported it: open where main's own rule places it. + if (!reply.ok && reply.code === 'group_not_found' && groupId) { + reply = await host.applyLayoutCommand(command) + } + if (!reply.ok) { + if (reply.code === 'host_not_owned') { + host.openInWindow(open) + return + } + throw new Error(`editor_open_refused: ${reply.code}`) + } + const tabId = reply.result.tabId + if (tabId) { + host.revealEditorTab?.({ ...open, tabId, frameSeq: reply.frameSeq }) + } +} + +/** Today's open: the window draws the tab. */ +export function openEditorFileInWindow( + notifier: Pick | null | undefined, + open: EditorFileOpen +): void { + if (!notifier?.openFile) { + throw new Error('renderer_unavailable') + } + notifier.openFile( + open.worktreeId, + open.filePath, + open.relativePath, + open.runtimeEnvironmentId, + open.navigation, + ...(open.position ? [open.position] : []) + ) +} diff --git a/src/main/runtime/files/file-access.ts b/src/main/runtime/files/file-access.ts index 4ada17ee33b..a9a47ca82c9 100644 --- a/src/main/runtime/files/file-access.ts +++ b/src/main/runtime/files/file-access.ts @@ -55,7 +55,7 @@ export class RuntimeFileAccess { const filePath = joinWorktreeRelativePath(worktree.path, relativePath) // Why: CLI/agents treat opened:true as success; stat first so missing paths and directories fail the RPC instead of opening a ghost tab. await this.assertOpenTargetIsFile(filePath, target) - this.host.openFile( + await this.host.openFile( worktree.id, filePath, relativePath, diff --git a/src/main/runtime/files/file-command-host.ts b/src/main/runtime/files/file-command-host.ts index 8ae67d1e881..d49d254fba9 100644 --- a/src/main/runtime/files/file-command-host.ts +++ b/src/main/runtime/files/file-command-host.ts @@ -43,7 +43,7 @@ export type RuntimeFileCommandHost = { runtimeEnvironmentId?: string | null, navigation?: RuntimeNavigationTarget, position?: RuntimeFileOpenPosition - ): void + ): void | Promise openDiff( worktreeId: string, filePath: string, diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 0370b603e04..ef450a86ac5 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -1,4 +1,7 @@ -import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts' +import type { + RuntimeEditorTabReveal, + RuntimeFileOpenPosition +} from '../../shared/runtime-file-contracts' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { @@ -139,6 +142,8 @@ export type RuntimeNotifier = { navigation?: RuntimeNavigationTarget, position?: RuntimeFileOpenPosition ): void + /** Layout-owner builds: shows a tab main already wrote for a file it opened. */ + revealEditorTab?(reveal: RuntimeEditorTabReveal): void openDiff?( worktreeId: string, filePath: string, diff --git a/src/main/runtime/session-graph/session-graph-promotion.ts b/src/main/runtime/session-graph/session-graph-promotion.ts index 320b20dffc6..c8d6a781ecf 100644 --- a/src/main/runtime/session-graph/session-graph-promotion.ts +++ b/src/main/runtime/session-graph/session-graph-promotion.ts @@ -13,11 +13,24 @@ import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileS import type { OrcaRuntimeWithSyncMobileSessionTabs as SyncMobileSessionTabsMethods } from '../session-tabs/mobile-session-reconciliation' import { OrcaRuntimeWithNotifySshStateChanged } from '../workspaces/workspace-host-notifications' import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import { ActiveGroupViewFacts } from '../view-facts/active-group-view-facts' import type { ExecutionHostId } from '../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged { + /** The group each workspace shows, as the attached window last reported it (syncWindowGraph). */ + protected readonly activeGroupViewFacts = new ActiveGroupViewFacts() + + protected editorOpenGroupFor(worktreeId: string): string | undefined { + return this.activeGroupViewFacts.groupFor(worktreeId, () => { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return hostId + ? this.store?.getWorkspaceSession?.(hostId)?.activeGroupIdByWorktree?.[worktreeId] + : undefined + }) + } + protected abstract beginGraphReload( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 3b8a4681d30..4b51d2e35d7 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -96,6 +96,7 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta if (graph.singleGroupWorkspaceGridSize) { this.singleGroupWorkspaceGridSize = graph.singleGroupWorkspaceGridSize } + this.activeGroupViewFacts.merge(graph.activeGroupByWorkspace) const graphWasReady = this.graphStatus === 'ready' const previousTabs = this.tabs const previousLeaves = this.leaves diff --git a/src/main/runtime/view-facts/active-group-view-facts.test.ts b/src/main/runtime/view-facts/active-group-view-facts.test.ts new file mode 100644 index 00000000000..03a17442796 --- /dev/null +++ b/src/main/runtime/view-facts/active-group-view-facts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { ActiveGroupViewFacts } from './active-group-view-facts' + +describe("the window's active group, as main keeps it", () => { + it("is the saved view's until a window reports one", () => { + const facts = new ActiveGroupViewFacts() + expect(facts.groupFor('wt', () => 'saved')).toBe('saved') + facts.merge({ wt: 'live' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('live') + }) + + it('keeps a workspace a later report does not name, and a report without the field changes nothing', () => { + const facts = new ActiveGroupViewFacts() + facts.merge({ a: 'g1' }) + facts.merge({ b: 'g2' }) + facts.merge(undefined) + expect(facts.groupFor('a', () => null)).toBe('g1') + expect(facts.groupFor('b', () => null)).toBe('g2') + }) + + it('is none once the window reports none, not the saved one', () => { + const facts = new ActiveGroupViewFacts() + facts.merge({ a: 'g1' }) + facts.merge({ a: null }) + expect(facts.groupFor('a', () => 'saved')).toBeUndefined() + }) +}) diff --git a/src/main/runtime/view-facts/active-group-view-facts.ts b/src/main/runtime/view-facts/active-group-view-facts.ts new file mode 100644 index 00000000000..1192d4cf0f9 --- /dev/null +++ b/src/main/runtime/view-facts/active-group-view-facts.ts @@ -0,0 +1,21 @@ +// The tab group each workspace shows, as the window last reported it: a view fact, kept in memory +// only. Main opens a CLI or phone file there; with no window, in the last group it was told. A +// removed workspace's entry stays: a stale group is refused and the open falls back to main's rule. + +export class ActiveGroupViewFacts { + /** Workspace key → the group the window shows; null when it shows none. */ + private readonly reported = new Map() + + /** A window's report: each named workspace's entry replaces the last; others are kept. */ + merge(report: Readonly> | undefined): void { + for (const [workspace, groupId] of Object.entries(report ?? {})) { + this.reported.set(workspace, groupId) + } + } + + /** The reported group, else the saved view's (after a restart no window has reported yet). */ + groupFor(workspace: string, savedGroupId: () => string | null | undefined): string | undefined { + const groupId = this.reported.has(workspace) ? this.reported.get(workspace) : savedGroupId() + return groupId ?? undefined + } +} diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 286b1aca08f..00ed7c98ed5 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -195,6 +195,7 @@ export function registerRuntimeWindowLifecycle( ...(navigation ? { navigation } : {}), ...(position ? { position } : {}) }), + revealEditorTab: (reveal) => send('ui:revealEditorTabFromRuntime', reveal), openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) => send('ui:openDiffFromMobile', { worktreeId, diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 3c5250d2f22..20f5f7501e6 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -1,4 +1,7 @@ -import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts' +import type { + RuntimeEditorTabReveal, + RuntimeFileOpenPosition +} from '../../shared/runtime-file-contracts' import { ipcRenderer } from 'electron' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' @@ -278,5 +281,13 @@ export const uiTerminalAndSessionTabsApi = { ) => callback(data) ipcRenderer.on('ui:openFileFromMobile', listener) return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) + }, + onRevealEditorTabFromRuntime: ( + callback: (data: RuntimeEditorTabReveal) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: RuntimeEditorTabReveal) => + callback(data) + ipcRenderer.on('ui:revealEditorTabFromRuntime', listener) + return () => ipcRenderer.removeListener('ui:revealEditorTabFromRuntime', listener) } } satisfies Partial diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index d392b9af38d..bdb7dfb8ee9 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,4 +1,7 @@ -import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts' +import type { + RuntimeEditorTabReveal, + RuntimeFileOpenPosition +} from '../../shared/runtime-file-contracts' import type { JiraQuickFilterState } from '../../shared/jira-quick-filters' import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared/jira-saved-filters' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' @@ -272,6 +275,8 @@ export type UiCommandEventApi = { position?: RuntimeFileOpenPosition }) => void ) => () => void + /** Layout-owner builds: shows a tab main wrote for a file it opened. */ + onRevealEditorTabFromRuntime: (callback: (data: RuntimeEditorTabReveal) => void) => () => void onOpenDiffFromMobile: ( callback: (data: { worktreeId: string diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge-reveal.test.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge-reveal.test.ts new file mode 100644 index 00000000000..63538e3a105 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge-reveal.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeEditorTabReveal } from '../../../../shared/runtime-file-contracts' +import { createTestStore, makeWorktree, seedStore } from '../../store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '../../store/slices/store-session-test-harness' +import type * as HostPersistence from '@/lib/workspace-session-host-persistence' +import { + _resetLayoutFramePositionForTests, + beginLayoutFramePositions, + noteLayoutFramePosition +} from '@/runtime/layout-frame-position' + +const { storeRef } = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return { storeRef: ref } +}) + +function testStore(): ReturnType { + if (!storeRef.current) { + throw new Error('test store not created') + } + return storeRef.current +} + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => testStore().getState(), + subscribe: (...args: Parameters['subscribe']>) => + testStore().subscribe(...args), + setState: (...args: Parameters['setState']>) => + testStore().setState(...args) + } +})) +vi.mock('@/components/terminal-pane/closed-terminal-leaf-notice', () => ({ + applyClosedTerminalLeafNotice: vi.fn() +})) +vi.mock('@/components/terminal/terminal-tab-actions', () => ({ closeTerminalTab: vi.fn() })) +vi.mock('@/components/sidebar/sleep-worktree-flow', () => ({ runSleepWorktree: vi.fn() })) +vi.mock('@/lib/workspace-session', () => ({ buildWorkspaceSessionPayload: vi.fn() })) +vi.mock('@/lib/workspace-session-host-persistence', async (importOriginal) => ({ + ...(await importOriginal()), + persistWorkspaceSessionByHost: vi.fn() +})) + +import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' + +const WT = 'repo1::/repo1/wt' +/** A workspace only main holds: the window draws none of its tabs yet. */ +const UNDRAWN = 'repo1::/repo1/undrawn' +const FILE = { filePath: '/repo1/wt/notes.md', relativePath: 'notes.md' } + +function setup(): { + reveal: (payload: RuntimeEditorTabReveal) => void + tabId: string + activeTabId: () => string | null | undefined + activeWorktreeId: () => string | null + store: ReturnType +} { + let reveal: ((payload: RuntimeEditorTabReveal) => void) | undefined + const noop = (): (() => void) => () => {} + vi.stubGlobal('window', { + api: { + ...createStoreSessionMockApi(), + ui: { + onOpenFileFromMobile: noop, + onOpenDiffFromMobile: noop, + onCloseTerminal: noop, + onSleepWorktree: noop, + onResumeSleepingAgents: noop, + onRevealEditorTabFromRuntime: (cb: (payload: RuntimeEditorTabReveal) => void) => { + reveal = cb + return () => {} + } + } + } + }) + const store = createTestStore() + storeRef.current = store + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT, repoId: 'repo1', path: '/repo1/wt' }), + makeWorktree({ id: UNDRAWN, repoId: 'repo1', path: '/repo1/undrawn' }) + ] + } + }) + store.getState().setActiveWorktree(WT) + store.getState().openFile({ + ...FILE, + worktreeId: WT, + language: 'markdown', + runtimeEnvironmentId: null, + mode: 'edit' + }) + // A terminal tab in front, so showing the file's tab is visible in the selection. + store.getState().createTab(WT) + const fileTab = store + .getState() + .unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === FILE.filePath)! + registerMobileAndTerminalCloseIpcBridge([], vi.fn()) + if (!reveal) { + throw new Error('reveal listener was not registered') + } + return { + reveal, + tabId: fileTab.id, + activeWorktreeId: () => store.getState().activeWorktreeId, + store, + activeTabId: () => { + const state = store.getState() + const groupId = state.activeGroupIdByWorktree[WT] + return state.groupsByWorktree[WT]?.find((group) => group.id === groupId)?.activeTabId + } + } +} + +afterEach(() => { + _resetLayoutFramePositionForTests() + vi.unstubAllGlobals() +}) + +describe('showing a tab main wrote for a file it opened', () => { + it('waits for the frame that draws the tab, then shows it', () => { + const h = setup() + beginLayoutFramePositions() + noteLayoutFramePosition(4) + h.reveal({ worktreeId: WT, ...FILE, tabId: h.tabId, frameSeq: 5 }) + expect(h.activeTabId()).not.toBe(h.tabId) + noteLayoutFramePosition(5) + expect(h.activeTabId()).toBe(h.tabId) + }) + + it('does nothing when the tab is gone by the time its frame comes in', () => { + const h = setup() + const before = h.activeTabId() + h.reveal({ worktreeId: WT, ...FILE, tabId: 'closed-tab', frameSeq: 5 }) + expect(h.activeTabId()).toBe(before) + }) + + it("visits a workspace the window has not drawn yet, then shows main's tab once it draws", async () => { + const h = setup() + beginLayoutFramePositions() + // Stands in for the frame sync: once the workspace draws, its flush brings main's tab in. + h.store.subscribe((state) => { + if (UNDRAWN in state.groupsByWorktree && !state.unifiedTabsByWorktree[UNDRAWN]?.length) { + queueMicrotask(() => + h.store.getState().createUnifiedTab(UNDRAWN, 'editor', { + id: 'main-tab', + entityId: '/repo1/undrawn/notes.md' + }) + ) + } + }) + h.reveal({ worktreeId: UNDRAWN, ...FILE, tabId: 'main-tab', navigation: 'host' }) + expect(h.activeWorktreeId()).toBe(UNDRAWN) + // The window seeds the visited workspace's root group after render, a later task. + await Promise.resolve() + h.store.getState().ensureWorktreeRootGroup(UNDRAWN) + await vi.waitFor(() => { + const state = h.store.getState() + const group = state.groupsByWorktree[UNDRAWN]?.find( + (entry) => entry.id === state.activeGroupIdByWorktree[UNDRAWN] + ) + expect(group?.activeTabId).toBe('main-tab') + expect(state.activeTabTypeByWorktree[UNDRAWN]).toBe('editor') + }) + }) + + it('leaves an undrawn workspace unvisited for an open that does not steer this window', () => { + const h = setup() + h.reveal({ worktreeId: UNDRAWN, ...FILE, tabId: 'main-tab', navigation: 'caller' }) + expect(h.activeWorktreeId()).toBe(WT) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index 2130c5e2b6a..24ed31b9ae1 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -12,6 +12,9 @@ import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal' import { selectFloatingWorkspacePanelVisible } from '@/store/floating-workspace-panel-selector' import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' import { useAppStore } from '../../store' +import { afterLayoutFrame, layoutFramesFlowing } from '@/runtime/layout-frame-position' +import { framesDrawWorkspace } from '@/runtime/workspace-layout-frame-drawn-workspaces' +import type { RuntimeEditorTabReveal } from '../../../../shared/runtime-file-contracts' import type { AppState } from '../../store/types' import type { EditorTabSelection } from '../../store/slices/editor/types/open-file' import { @@ -40,6 +43,46 @@ function openRuntimeEditorTab( store.setActiveTabType('editor', worktreeId) } +/** Shows main's editor tab if the window draws it; false when it does not (closed, or undrawn). */ +function showMainEditorTab( + { worktreeId, tabId, navigation, position }: RuntimeEditorTabReveal, + positionFile: ReturnType +): boolean { + const tab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((entry) => entry.id === tabId) + if (!tab) { + return false + } + openRuntimeEditorTab(worktreeId, navigation, (store, selection) => { + if (selection !== 'none') { + store.activateTab(tabId, selection === 'background' ? { recordFocus: false } : {}) + } + positionFile(tab.entityId, position, selection === 'focus') + }) + return true +} + +/** + * Runs `show` once a visited workspace starts drawing: its root group is seeded after render, and + * main's tabs draw in the frame flush that queues. Dropped if the user leaves first or frames stop. + */ +function showOnceWorkspaceDraws({ worktreeId }: RuntimeEditorTabReveal, show: () => void): void { + const unsubscribe = useAppStore.subscribe((state) => { + const viewed = isFloatingWorkspaceId(worktreeId) + ? selectFloatingWorkspacePanelVisible(state) + : state.activeWorktreeId === worktreeId + if (!viewed || !layoutFramesFlowing()) { + unsubscribe() + } else if (framesDrawWorkspace(state)(worktreeId)) { + unsubscribe() + // Why after: the frame sync subscribed at startup, so this update queued its flush first. + // (Before the frame sync starts, no flush comes and the tab shows on a later visit.) + queueMicrotask(show) + } + }) +} + export function registerMobileAndTerminalCloseIpcBridge( unsubs: (() => void)[], requestSleepingAgentWake: (worktreeId: string) => void @@ -68,6 +111,33 @@ export function registerMobileAndTerminalCloseIpcBridge( ) ) + // Layout-owner builds: main wrote the tab of a file it opened; show it once its frame draws it. + // Why optional: an older preload lingering through an in-place reload lacks the channel. + if (window.api.ui.onRevealEditorTabFromRuntime) { + unsubs.push( + window.api.ui.onRevealEditorTabFromRuntime((reveal) => + // No stream coming ('none' or ended): runs now, and an undrawn tab is left unshown. + afterLayoutFrame(reveal.frameSeq, () => { + if (showMainEditorTab(reveal, positionFile)) { + return + } + const store = useAppStore.getState() + const { worktreeId, navigation } = reveal + // An undrawn workspace draws main's tabs on its first visit; a background open waits for it. + if ( + framesDrawWorkspace(store)(worktreeId) || + !layoutFramesFlowing() || + (navigation !== undefined && !navigationTargetsHost(navigation)) + ) { + return + } + revealWorkspaceSurface(store, worktreeId) + showOnceWorkspaceDraws(reveal, () => showMainEditorTab(reveal, positionFile)) + }) + ) + ) + } + unsubs.push( window.api.ui.onOpenDiffFromMobile( ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation }) => { diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index a58c0e8e5c7..587bb0198ec 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -1,173 +1,2 @@ -import { detectMonacoFilenameLanguage } from './monaco-filename-language' - -function extname(filePath: string): string { - const lastDot = filePath.lastIndexOf('.') - const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) - if (lastDot <= lastSep) { - return '' - } - return filePath.slice(lastDot) -} - -const EXT_TO_LANGUAGE: Record = { - // Why: Monaco's built-in language registry maps .tsx/.cts/.mts onto the - // 'typescript' language id and .jsx/.mjs/.cjs onto 'javascript' — there is - // no separate 'typescriptreact'/'javascriptreact' id. Returning the base id - // is what gives .tsx/.jsx files syntax highlighting in the editor. - '.ts': 'typescript', - '.tsx': 'typescript', - '.cts': 'typescript', - '.mts': 'typescript', - '.js': 'javascript', - '.jsx': 'javascript', - '.mjs': 'javascript', - '.cjs': 'javascript', - '.json': 'json', - '.jsonc': 'json', - // Why: JSONL is one JSON value per line; a dedicated 'jsonl' language gives - // JSON-style color without attaching JSON whole-document diagnostics that - // would flag every record after line one as trailing content. - '.jsonl': 'jsonl', - '.ipynb': 'notebook', - '.md': 'markdown', - '.mdx': 'markdown', - // Why: Quarto/R Markdown are Markdown plus a YAML header and executable - // cells; the 'markdown' grammar leaves ```{r}/```{python} cells uncolored. - '.qmd': 'quarto', - '.rmd': 'quarto', - '.rmarkdown': 'quarto', - '.mmd': 'mermaid', - '.mermaid': 'mermaid', - '.css': 'css', - '.scss': 'scss', - '.less': 'less', - '.html': 'html', - '.htm': 'html', - // Why: stopgap until a real JSP grammar — 'html' colors the markup; <% %> and ${} stay plain. - '.jsp': 'html', - '.jspf': 'html', - // Why: Monaco declares Liquid as both '.liquid' and '.html.liquid'; the final-extension - // lookup below covers the compound form, so the single entry is enough. - '.liquid': 'liquid', - '.twig': 'twig', - '.xml': 'xml', - '.svg': 'xml', - '.py': 'python', - '.rs': 'rust', - '.go': 'go', - '.java': 'java', - '.kt': 'kotlin', - '.kts': 'kotlin', - '.c': 'c', - '.h': 'c', - '.cpp': 'cpp', - '.cc': 'cpp', - '.cxx': 'cpp', - '.hpp': 'cpp', - '.cs': 'csharp', - // Why: Monaco's apex grammar claims only '.cls'; triggers and anonymous-Apex scripts share it. - '.cls': 'apex', - '.trigger': 'apex', - '.apex': 'apex', - '.rb': 'ruby', - '.php': 'php', - '.swift': 'swift', - '.sh': 'shell', - '.bash': 'shell', - '.zsh': 'shell', - '.fish': 'shell', - '.bat': 'bat', - '.cmd': 'bat', - '.ps1': 'powershell', - '.yaml': 'yaml', - '.yml': 'yaml', - '.toml': 'ini', - '.ini': 'ini', - '.cfg': 'ini', - '.conf': 'ini', - '.sql': 'sql', - '.graphql': 'graphql', - '.gql': 'graphql', - '.dockerfile': 'dockerfile', - '.proto': 'proto', - '.lua': 'lua', - '.r': 'r', - '.scala': 'scala', - '.dart': 'dart', - '.ex': 'elixir', - '.exs': 'elixir', - '.erl': 'erlang', - '.hrl': 'erlang', - '.hs': 'haskell', - '.clj': 'clojure', - // Why: Monaco registers Solidity under the id 'sol'; 'solidity' is only an alias. - '.sol': 'sol', - '.vue': 'vue', - '.svelte': 'svelte', - '.astro': 'astro', - '.sv': 'systemverilog', - '.svh': 'systemverilog', - '.v': 'verilog', - '.vh': 'verilog', - '.nim': 'nim', - '.nims': 'nim', - '.nimble': 'nim', - '.typ': 'typst', - '.tf': 'hcl', - '.hcl': 'hcl', - '.abap': 'abap', - '.prisma': 'graphql', - '.csv': 'csv', - '.tsv': 'tsv' -} - -const FILENAME_TO_LANGUAGE: Record = { - Dockerfile: 'dockerfile', - Makefile: 'makefile', - 'CMakeLists.txt': 'cmake', - '.gitignore': 'ini', - '.gitattributes': 'ini', - '.editorconfig': 'ini', - '.env': 'ini', - '.env.local': 'ini', - '.env.development': 'ini', - '.env.production': 'ini', - '.bashrc': 'shell', - '.bash_profile': 'shell', - '.bash_login': 'shell', - '.bash_logout': 'shell', - '.profile': 'shell', - '.zshrc': 'shell', - '.zshenv': 'shell', - '.zprofile': 'shell', - '.zlogin': 'shell', - '.zlogout': 'shell' -} - -// Exact match wins; lowercase map covers case-insensitive filesystems. -const FILENAME_LOWER_TO_LANGUAGE: Record = Object.fromEntries( - Object.entries(FILENAME_TO_LANGUAGE).map(([name, language]) => [name.toLowerCase(), language]) -) - -export function detectLanguage(filePath: string): string { - // Check exact filename first - const parts = filePath.split(/[\\/]/) - const filename = parts.at(-1)! - if (Object.hasOwn(FILENAME_TO_LANGUAGE, filename)) { - return FILENAME_TO_LANGUAGE[filename] - } - const lowerFilename = filename.toLowerCase() - if (Object.hasOwn(FILENAME_LOWER_TO_LANGUAGE, lowerFilename)) { - return FILENAME_LOWER_TO_LANGUAGE[lowerFilename] - } - - // Check extension - const ext = extname(filename).toLowerCase() - const lowerName = filename.toLowerCase() - // Scoped dotenv names fall back to INI only when no specific extension matches. - return ( - EXT_TO_LANGUAGE[ext] ?? - detectMonacoFilenameLanguage(filename) ?? - (lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext') - ) -} +// Renderer-facing name for the shared detector; main names a file's language with it too. +export { detectLanguage } from '../../../shared/editor-language/editor-language-detect' diff --git a/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.test.ts b/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.test.ts new file mode 100644 index 00000000000..ae3e2d1a292 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { changedActiveGroups } from './active-group-publication' + +describe('the active groups a graph publication carries', () => { + it('names only the workspaces whose group changed', () => { + expect(changedActiveGroups({ a: 'g1', b: 'g3' }, { a: 'g1', b: 'g2' })).toEqual({ b: 'g3' }) + }) + + it('names a workspace that no longer shows a group as none', () => { + expect(changedActiveGroups({}, { a: 'g1' })).toEqual({ a: null }) + }) + + it('carries nothing when nothing changed', () => { + expect(changedActiveGroups({ a: 'g1' }, { a: 'g1' })).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.ts new file mode 100644 index 00000000000..85b6dec2da6 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/active-group-publication.ts @@ -0,0 +1,20 @@ +// The window's active tab group per workspace, a view fact main opens CLI and phone files into: +// only what changed since main last accepted it, null for a workspace that no longer shows one. + +export function changedActiveGroups( + current: Readonly>, + published: Readonly> +): Record | undefined { + const changed: Record = {} + for (const [workspace, groupId] of Object.entries(current)) { + if (published[workspace] !== groupId) { + changed[workspace] = groupId + } + } + for (const workspace of Object.keys(published)) { + if (!(workspace in current)) { + changed[workspace] = null + } + } + return Object.keys(changed).length > 0 ? changed : undefined +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index 4b1f9d9bf15..196cda2811c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -30,6 +30,8 @@ import { getMountedTerminalTabRegisteredAt, mountedTerminalTabEntries } from '../mounted-terminal-tab-registry' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { changedActiveGroups } from './active-group-publication' export async function syncRuntimeGraph(): Promise { if (!graphState.syncEnabled || !graphState.getStoreState) { @@ -62,6 +64,10 @@ export async function syncRuntimeGraph(): Promise { const publication = partitionMobileSessionPublication(mobileSessionTabs) const gridSizes = measureTerminalGroupGridSizes(state.settings?.terminalFontSize ?? 14) const projectSectionCatalog = getProjectSectionCatalogFromState(state) + // Switch point: main opens files in the group the window shows, so it hears of each change. + const activeGroupByWorkspace = ORCA_LAYOUT_OWNER_IS_MAIN + ? changedActiveGroups(state.activeGroupIdByWorktree, graphState.publishedActiveGroups) + : undefined const graph: RuntimeRendererSyncWindowGraph = { tabs: [], leaves: [], @@ -74,7 +80,8 @@ export async function syncRuntimeGraph(): Promise { : {}), ...(projectSectionCatalog && projectSectionCatalog !== graphState.publishedProjectSectionCatalog ? { projectSectionCatalog } - : {}) + : {}), + ...(activeGroupByWorkspace ? { activeGroupByWorkspace } : {}) } for (const [registrationKey, registeredTab] of mountedTerminalTabEntries()) { @@ -205,6 +212,9 @@ export async function syncRuntimeGraph(): Promise { graphState.publishedTerminalGroupGridSizesKey = JSON.stringify(gridSizes ?? null) graphState.publishedProjectSectionCatalog = projectSectionCatalog ?? graphState.publishedProjectSectionCatalog + if (activeGroupByWorkspace) { + graphState.publishedActiveGroups = state.activeGroupIdByWorktree + } commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) const currentState = graphState.getStoreState() currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts index 2e370f156cc..b2e2c964107 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -36,6 +36,8 @@ function noPublishedProjectSectionCatalog(): MobileProjectSectionCatalog | null return null } +const NO_PUBLISHED_ACTIVE_GROUPS: Readonly> = {} + export const graphState = { syncScheduled: false, syncInFlight: false, @@ -63,7 +65,9 @@ export const graphState = { /** Last group grid sizes main accepted, so a refit republishes only when one changed. */ publishedTerminalGroupGridSizesKey: '', /** Last sidebar Project catalog main accepted, so the graph carries it only when it changed. */ - publishedProjectSectionCatalog: noPublishedProjectSectionCatalog() + publishedProjectSectionCatalog: noPublishedProjectSectionCatalog(), + /** Each workspace's active group as main last accepted it (layout-owner builds). */ + publishedActiveGroups: NO_PUBLISHED_ACTIVE_GROUPS } /** IDs occurring more than once cannot address the legacy tab-keyed runtime maps safely. */ diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 7d96db7eadb..339027211a7 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -278,6 +278,7 @@ export function createWebUiApi(): NonNullable['ui']> { respondSessionTabClose: () => {}, onMoveSessionTab: () => noopUnsubscribe, onOpenFileFromMobile: () => noopUnsubscribe, + onRevealEditorTabFromRuntime: () => noopUnsubscribe, onOpenDiffFromMobile: () => noopUnsubscribe, onMobileMarkdownRequest: () => noopUnsubscribe, respondMobileMarkdownRequest: () => {}, diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/shared/editor-language/editor-language-detect.test.ts similarity index 99% rename from src/renderer/src/lib/language-detect.test.ts rename to src/shared/editor-language/editor-language-detect.test.ts index ec8a79ce80f..7100a17b23c 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/shared/editor-language/editor-language-detect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { detectLanguage } from './language-detect' +import { detectLanguage } from './editor-language-detect' describe('detectLanguage', () => { it('maps .vue files to the custom vue language id', () => { diff --git a/src/shared/editor-language/editor-language-detect.ts b/src/shared/editor-language/editor-language-detect.ts new file mode 100644 index 00000000000..a58c0e8e5c7 --- /dev/null +++ b/src/shared/editor-language/editor-language-detect.ts @@ -0,0 +1,173 @@ +import { detectMonacoFilenameLanguage } from './monaco-filename-language' + +function extname(filePath: string): string { + const lastDot = filePath.lastIndexOf('.') + const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + if (lastDot <= lastSep) { + return '' + } + return filePath.slice(lastDot) +} + +const EXT_TO_LANGUAGE: Record = { + // Why: Monaco's built-in language registry maps .tsx/.cts/.mts onto the + // 'typescript' language id and .jsx/.mjs/.cjs onto 'javascript' — there is + // no separate 'typescriptreact'/'javascriptreact' id. Returning the base id + // is what gives .tsx/.jsx files syntax highlighting in the editor. + '.ts': 'typescript', + '.tsx': 'typescript', + '.cts': 'typescript', + '.mts': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.jsonc': 'json', + // Why: JSONL is one JSON value per line; a dedicated 'jsonl' language gives + // JSON-style color without attaching JSON whole-document diagnostics that + // would flag every record after line one as trailing content. + '.jsonl': 'jsonl', + '.ipynb': 'notebook', + '.md': 'markdown', + '.mdx': 'markdown', + // Why: Quarto/R Markdown are Markdown plus a YAML header and executable + // cells; the 'markdown' grammar leaves ```{r}/```{python} cells uncolored. + '.qmd': 'quarto', + '.rmd': 'quarto', + '.rmarkdown': 'quarto', + '.mmd': 'mermaid', + '.mermaid': 'mermaid', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.html': 'html', + '.htm': 'html', + // Why: stopgap until a real JSP grammar — 'html' colors the markup; <% %> and ${} stay plain. + '.jsp': 'html', + '.jspf': 'html', + // Why: Monaco declares Liquid as both '.liquid' and '.html.liquid'; the final-extension + // lookup below covers the compound form, so the single entry is enough. + '.liquid': 'liquid', + '.twig': 'twig', + '.xml': 'xml', + '.svg': 'xml', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.kt': 'kotlin', + '.kts': 'kotlin', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.cxx': 'cpp', + '.hpp': 'cpp', + '.cs': 'csharp', + // Why: Monaco's apex grammar claims only '.cls'; triggers and anonymous-Apex scripts share it. + '.cls': 'apex', + '.trigger': 'apex', + '.apex': 'apex', + '.rb': 'ruby', + '.php': 'php', + '.swift': 'swift', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.fish': 'shell', + '.bat': 'bat', + '.cmd': 'bat', + '.ps1': 'powershell', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'ini', + '.ini': 'ini', + '.cfg': 'ini', + '.conf': 'ini', + '.sql': 'sql', + '.graphql': 'graphql', + '.gql': 'graphql', + '.dockerfile': 'dockerfile', + '.proto': 'proto', + '.lua': 'lua', + '.r': 'r', + '.scala': 'scala', + '.dart': 'dart', + '.ex': 'elixir', + '.exs': 'elixir', + '.erl': 'erlang', + '.hrl': 'erlang', + '.hs': 'haskell', + '.clj': 'clojure', + // Why: Monaco registers Solidity under the id 'sol'; 'solidity' is only an alias. + '.sol': 'sol', + '.vue': 'vue', + '.svelte': 'svelte', + '.astro': 'astro', + '.sv': 'systemverilog', + '.svh': 'systemverilog', + '.v': 'verilog', + '.vh': 'verilog', + '.nim': 'nim', + '.nims': 'nim', + '.nimble': 'nim', + '.typ': 'typst', + '.tf': 'hcl', + '.hcl': 'hcl', + '.abap': 'abap', + '.prisma': 'graphql', + '.csv': 'csv', + '.tsv': 'tsv' +} + +const FILENAME_TO_LANGUAGE: Record = { + Dockerfile: 'dockerfile', + Makefile: 'makefile', + 'CMakeLists.txt': 'cmake', + '.gitignore': 'ini', + '.gitattributes': 'ini', + '.editorconfig': 'ini', + '.env': 'ini', + '.env.local': 'ini', + '.env.development': 'ini', + '.env.production': 'ini', + '.bashrc': 'shell', + '.bash_profile': 'shell', + '.bash_login': 'shell', + '.bash_logout': 'shell', + '.profile': 'shell', + '.zshrc': 'shell', + '.zshenv': 'shell', + '.zprofile': 'shell', + '.zlogin': 'shell', + '.zlogout': 'shell' +} + +// Exact match wins; lowercase map covers case-insensitive filesystems. +const FILENAME_LOWER_TO_LANGUAGE: Record = Object.fromEntries( + Object.entries(FILENAME_TO_LANGUAGE).map(([name, language]) => [name.toLowerCase(), language]) +) + +export function detectLanguage(filePath: string): string { + // Check exact filename first + const parts = filePath.split(/[\\/]/) + const filename = parts.at(-1)! + if (Object.hasOwn(FILENAME_TO_LANGUAGE, filename)) { + return FILENAME_TO_LANGUAGE[filename] + } + const lowerFilename = filename.toLowerCase() + if (Object.hasOwn(FILENAME_LOWER_TO_LANGUAGE, lowerFilename)) { + return FILENAME_LOWER_TO_LANGUAGE[lowerFilename] + } + + // Check extension + const ext = extname(filename).toLowerCase() + const lowerName = filename.toLowerCase() + // Scoped dotenv names fall back to INI only when no specific extension matches. + return ( + EXT_TO_LANGUAGE[ext] ?? + detectMonacoFilenameLanguage(filename) ?? + (lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext') + ) +} diff --git a/src/renderer/src/lib/monaco-filename-language.test.ts b/src/shared/editor-language/monaco-filename-language.test.ts similarity index 98% rename from src/renderer/src/lib/monaco-filename-language.test.ts rename to src/shared/editor-language/monaco-filename-language.test.ts index 94caf1b91cc..1f8c4e708b5 100644 --- a/src/renderer/src/lib/monaco-filename-language.test.ts +++ b/src/shared/editor-language/monaco-filename-language.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { detectLanguage } from './language-detect' +import { detectLanguage } from './editor-language-detect' import { detectMonacoFilenameLanguage } from './monaco-filename-language' import associations from './monaco-language-associations.json' diff --git a/src/renderer/src/lib/monaco-filename-language.ts b/src/shared/editor-language/monaco-filename-language.ts similarity index 100% rename from src/renderer/src/lib/monaco-filename-language.ts rename to src/shared/editor-language/monaco-filename-language.ts diff --git a/src/renderer/src/lib/monaco-language-associations.json b/src/shared/editor-language/monaco-language-associations.json similarity index 100% rename from src/renderer/src/lib/monaco-language-associations.json rename to src/shared/editor-language/monaco-language-associations.json diff --git a/src/shared/runtime-file-contracts.ts b/src/shared/runtime-file-contracts.ts index 0ff214b66ef..a4cfcbc61b9 100644 --- a/src/shared/runtime-file-contracts.ts +++ b/src/shared/runtime-file-contracts.ts @@ -1,4 +1,5 @@ import type { RasterImageDimensions } from './raster-image-dimensions' +import type { RuntimeNavigationTarget } from './runtime-navigation' export type RuntimeFileListEntry = { relativePath: string @@ -27,6 +28,19 @@ export type RuntimeFileOpenPosition = { column?: number } +/** A file main opened itself, which a window shows: layout-owner builds, local window only. */ +export type RuntimeEditorFileOpen = { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string | null + navigation?: RuntimeNavigationTarget + position?: RuntimeFileOpenPosition +} + +/** `frameSeq`: the layout stream position whose frame draws the tab. */ +export type RuntimeEditorTabReveal = RuntimeEditorFileOpen & { tabId: string; frameSeq?: number } + export type RuntimeFileReadResult = { worktree: string relativePath: string diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 2948996c0da..f85254325fc 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -168,6 +168,9 @@ export type RuntimeSyncWindowGraph = { terminalGroupGridSizes?: Record /** The grid a workspace with a single tab group gets, for one the window is not showing. */ singleGroupWorkspaceGridSize?: { cols: number; rows: number } + /** Layout-owner builds: the tab group each workspace shows, sent only when it changes. Main merges + * each entry and keeps the last; a send without the field changes nothing. null: none shown. */ + activeGroupByWorkspace?: Record /** The sidebar's Project grouping inputs for paired phones; sent only when they changed. */ projectSectionCatalog?: MobileProjectSectionCatalog } diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 65dd5ae9352..70a0bc46c8f 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -115,8 +115,9 @@ export type LayoutCommand = type: 'openEditorTab' /** The window's id for a tab it drew first; minted when absent. */ tabId?: string - /** The editor's file id: the tab's entity. */ - fileId: string + /** The editor's file id: the tab's entity. Absent (main's own opens only; the wire still + * requires it): main picks it for `file`. */ + fileId?: string contentType: Exclude /** Present for edit-mode files, which are restored after restart. */ file?: LayoutEditorFile diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index 77a83d19d49..24840923fe7 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 { editorOpenFileId } from './workspace-layout-editor-open-file-id' import { sameEditorFile } from './workspace-layout-editor-tab-file' import { admitsInitialSurface } from './workspace-layout-initial-surface' import type { @@ -41,8 +42,16 @@ export function openEditorTab( ): Applied { const key = command.workspace const workspace = model.workspaces[key] + const fileId = + command.fileId ?? + (command.file + ? editorOpenFileId(workspace ?? { worktreeId: key, tabs: [] }, command.file) + : undefined) + if (!fileId) { + return refuse('invalid_params') + } const sameFile = (tab: LayoutTab): boolean => - tab.entityId === command.fileId && tab.kind === command.contentType + tab.entityId === fileId && tab.kind === command.contentType const drawn = drawnTabOpen(model, key, command.tabId, sameFile, true) if (drawn) { return drawn.ok ? applied(withEditorFile(drawn.model, key, command.file), drawn.result) : drawn @@ -78,7 +87,7 @@ export function openEditorTab( ...(command.tabId ? { id: command.tabId } : {}), ...(command.createdAt !== undefined ? { createdAt: command.createdAt } : {}), kind: command.contentType, - entityId: command.fileId, + entityId: fileId, ...(command.preview ? { isPreview: true } : {}) }) const placed = placeContentTab(target, key, tab, command, context) diff --git a/src/shared/workspace-layout/workspace-layout-editor-open-file-id.test.ts b/src/shared/workspace-layout/workspace-layout-editor-open-file-id.test.ts new file mode 100644 index 00000000000..cff2a8f7859 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-editor-open-file-id.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../constants' +import { ownedEditorFileId } from '../workspace-session-pane-ownership' +import { build, emptyModel, testContext, WS } from './workspace-layout-command.test-fixture' +import { applyLayoutCommand } from './workspace-layout-commands' +import { editorOpenFileId } from './workspace-layout-editor-open-file-id' +import type { LayoutEditorFile, WorkspaceLayout } from './workspace-layout-model' + +vi.mock('../layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const WT = 'repo-1::/repo' + +function file(filePath: string, runtimeEnvironmentId?: string): LayoutEditorFile { + return { + filePath, + relativePath: filePath.slice(1), + language: 'typescript', + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) + } +} + +function workspace( + worktreeId: string, + editorFiles: LayoutEditorFile[], + entityIds: string[] = [] +): WorkspaceLayout { + return { + worktreeId, + tabs: entityIds.map((entityId, index) => ({ + id: `tab-${index}`, + kind: 'editor', + entityId, + createdAt: 1, + customTitle: null, + color: null + })), + groups: [{ id: 'g1', tabOrder: entityIds.map((_, index) => `tab-${index}`) }], + editorFiles + } +} + +describe('the file id main gives a file it opens', () => { + it('is the owned id of a file no tab shows, never the bare path another host may hold', () => { + expect(editorOpenFileId(workspace(WT, []), file('/repo/a.ts'))).toBe( + ownedEditorFileId('/repo/a.ts', WT, null) + ) + }) + + it("is the id a tab already shows the same owner's file under, the bare path included", () => { + expect( + editorOpenFileId(workspace(WT, [file('/repo/a.ts')], ['/repo/a.ts']), file('/repo/a.ts')) + ).toBe('/repo/a.ts') + }) + + it("is not the id another owner's tab shows for the same path", () => { + const held = workspace(WT, [file('/repo/a.ts', 'env-1')], ['/repo/a.ts']) + expect(editorOpenFileId(held, file('/repo/a.ts'))).toBe( + ownedEditorFileId('/repo/a.ts', WT, null) + ) + }) + + it('is an owned id in the floating workspace', () => { + expect( + editorOpenFileId(workspace(FLOATING_TERMINAL_WORKTREE_ID, []), file('/tmp/notes.md')) + ).toBe(ownedEditorFileId('/tmp/notes.md', FLOATING_TERMINAL_WORKTREE_ID, null)) + }) +}) + +describe('an open main makes itself, naming the file but not its id', () => { + const opened = file('/repo/a.ts') + + it('opens the file under its owned id', () => { + const { model, results } = build(testContext(), [ + { type: 'openEditorTab', workspace: WS, contentType: 'editor', file: opened } + ]) + const tab = model.workspaces[WS]!.tabs.find((entry) => entry.id === results[0]!.tabId) + expect(tab?.entityId).toBe(ownedEditorFileId('/repo/a.ts', WS, null)) + expect(model.workspaces[WS]!.editorFiles).toEqual([opened]) + }) + + it('shows the tab it already has for the file in that group', () => { + const { results } = build(testContext(), [ + { type: 'openEditorTab', workspace: WS, contentType: 'editor', file: opened }, + { type: 'openEditorTab', workspace: WS, contentType: 'editor', file: opened } + ]) + expect(results[1]!.tabId).toBe(results[0]!.tabId) + }) + + it('is refused naming neither the file nor its id', () => { + const applied = applyLayoutCommand( + emptyModel(), + { type: 'openEditorTab', workspace: WS, contentType: 'editor' }, + testContext() + ) + expect(applied).toEqual({ ok: false, code: 'invalid_params' }) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-editor-open-file-id.ts b/src/shared/workspace-layout/workspace-layout-editor-open-file-id.ts new file mode 100644 index 00000000000..29c375b3ad9 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-editor-open-file-id.ts @@ -0,0 +1,25 @@ +// The file id main gives a file it opens itself (CLI, phone, a host with no window). + +import { ownedEditorFileId } from '../workspace-session-pane-ownership' +import { editorTabFile, sameEditorFile } from './workspace-layout-editor-tab-file' +import type { LayoutEditorFile, WorkspaceLayout } from './workspace-layout-model' + +type OpenedFile = Pick + +/** + * The id an editor tab of the file already shows; else its owned id. Why never the bare path: the + * window gives a path to one owner across every host, and main sees only this host's workspaces. + */ +export function editorOpenFileId( + workspace: Pick, + file: OpenedFile +): string { + const shown = workspace.tabs.find((tab) => { + const record = tab.kind === 'editor' ? editorTabFile(workspace, tab.entityId) : undefined + return record !== undefined && sameEditorFile(record, file) + }) + return ( + shown?.entityId ?? + ownedEditorFileId(file.filePath, workspace.worktreeId, file.runtimeEnvironmentId) + ) +}