From 03e9a03e9e2afaa7ee85425deb62bbb42ca06dfa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:48:53 -0400 Subject: [PATCH] feat(layout-owner): main opens CLI and phone files itself, in the window's live group (dark) (#31123) Behind ORCA_LAYOUT_OWNER_IS_MAIN, a CLI or phone file open is applied by main as an openEditorTab layout command, so a host with no window can open files. The window reports each workspace's active group as an optional view fact on syncWindowGraph, and shows main's tab once the frame that draws it arrives. Editor language detection moves to shared so main can name a file's language. Co-authored-by: E2E Test --- .../scripts/generate-monaco-associations.mjs | 2 +- config/tsconfig.cli.json | 1 + .../runtime-file-command-state.ts | 35 +++- .../runtime/editor-open-active-group.test.ts | 74 ++++++++ .../editor-open-through-layout-owner.test.ts | 76 ++++++++ .../files/editor-open-through-layout-owner.ts | 81 ++++++++ src/main/runtime/files/file-access.ts | 2 +- src/main/runtime/files/file-command-host.ts | 2 +- src/main/runtime/runtime-notifier-contract.ts | 7 +- .../session-graph/session-graph-promotion.ts | 13 ++ .../session-graph/session-window-graph.ts | 1 + .../active-group-view-facts.test.ts | 27 +++ .../view-facts/active-group-view-facts.ts | 21 +++ src/main/window/runtime-window-lifecycle.ts | 1 + .../ui-bridge-terminal-and-session-tabs.ts | 13 +- src/preload/api/ui-command-event-api.ts | 7 +- ...e-terminal-close-ipc-bridge-reveal.test.ts | 174 +++++++++++++++++ .../mobile-terminal-close-ipc-bridge.ts | 70 +++++++ src/renderer/src/lib/language-detect.ts | 175 +----------------- .../active-group-publication.test.ts | 16 ++ .../active-group-publication.ts | 20 ++ .../sync-runtime-graph/graph-publication.ts | 12 +- .../runtime/sync-runtime-graph/graph-state.ts | 6 +- .../src/web/preload-api/web-ui-api.ts | 1 + .../editor-language-detect.test.ts} | 2 +- .../editor-language/editor-language-detect.ts | 173 +++++++++++++++++ .../monaco-filename-language.test.ts | 2 +- .../monaco-filename-language.ts | 0 .../monaco-language-associations.json | 0 src/shared/runtime-file-contracts.ts | 14 ++ src/shared/runtime-session-contracts.ts | 3 + .../workspace-layout-command-types.ts | 5 +- .../workspace-layout-content-commands.ts | 13 +- ...rkspace-layout-editor-open-file-id.test.ts | 97 ++++++++++ .../workspace-layout-editor-open-file-id.ts | 25 +++ 35 files changed, 978 insertions(+), 193 deletions(-) create mode 100644 src/main/runtime/editor-open-active-group.test.ts create mode 100644 src/main/runtime/files/editor-open-through-layout-owner.test.ts create mode 100644 src/main/runtime/files/editor-open-through-layout-owner.ts create mode 100644 src/main/runtime/view-facts/active-group-view-facts.test.ts create mode 100644 src/main/runtime/view-facts/active-group-view-facts.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge-reveal.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/active-group-publication.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/active-group-publication.ts rename src/{renderer/src/lib/language-detect.test.ts => shared/editor-language/editor-language-detect.test.ts} (99%) create mode 100644 src/shared/editor-language/editor-language-detect.ts rename src/{renderer/src/lib => shared/editor-language}/monaco-filename-language.test.ts (98%) rename src/{renderer/src/lib => shared/editor-language}/monaco-filename-language.ts (100%) rename src/{renderer/src/lib => shared/editor-language}/monaco-language-associations.json (100%) create mode 100644 src/shared/workspace-layout/workspace-layout-editor-open-file-id.test.ts create mode 100644 src/shared/workspace-layout/workspace-layout-editor-open-file-id.ts 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) + ) +}