mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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 <e2e@test.local>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<LayoutApplyReply>
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyReply>
|
||||
/** 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<void> {
|
||||
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<RuntimeNotifier, 'openFile'> | 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] : [])
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -43,7 +43,7 @@ export type RuntimeFileCommandHost = {
|
||||
runtimeEnvironmentId?: string | null,
|
||||
navigation?: RuntimeNavigationTarget,
|
||||
position?: RuntimeFileOpenPosition
|
||||
): void
|
||||
): void | Promise<void>
|
||||
openDiff(
|
||||
worktreeId: string,
|
||||
filePath: string,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HasTerminalsForWorktreeMethods['beginGraphReload']>
|
||||
): ReturnType<HasTerminalsForWorktreeMethods['beginGraphReload']>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | null>()
|
||||
|
||||
/** A window's report: each named workspace's entry replaces the last; others are kept. */
|
||||
merge(report: Readonly<Record<string, string | null>> | 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PreloadApi['ui']>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof createTestStore> | null } = { current: null }
|
||||
return { storeRef: ref }
|
||||
})
|
||||
|
||||
function testStore(): ReturnType<typeof createTestStore> {
|
||||
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<ReturnType<typeof createTestStore>['subscribe']>) =>
|
||||
testStore().subscribe(...args),
|
||||
setState: (...args: Parameters<ReturnType<typeof createTestStore>['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<typeof HostPersistence>()),
|
||||
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<typeof createTestStore>
|
||||
} {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof createRuntimeFilePositionQueue>
|
||||
): 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 }) => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
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<string, string> = 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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string>>,
|
||||
published: Readonly<Record<string, string>>
|
||||
): Record<string, string | null> | undefined {
|
||||
const changed: Record<string, string | null> = {}
|
||||
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
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (!graphState.syncEnabled || !graphState.getStoreState) {
|
||||
@@ -62,6 +64,10 @@ export async function syncRuntimeGraph(): Promise<void> {
|
||||
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<void> {
|
||||
: {}),
|
||||
...(projectSectionCatalog && projectSectionCatalog !== graphState.publishedProjectSectionCatalog
|
||||
? { projectSectionCatalog }
|
||||
: {})
|
||||
: {}),
|
||||
...(activeGroupByWorkspace ? { activeGroupByWorkspace } : {})
|
||||
}
|
||||
|
||||
for (const [registrationKey, registeredTab] of mountedTerminalTabEntries()) {
|
||||
@@ -205,6 +212,9 @@ export async function syncRuntimeGraph(): Promise<void> {
|
||||
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 ?? {})
|
||||
|
||||
@@ -36,6 +36,8 @@ function noPublishedProjectSectionCatalog(): MobileProjectSectionCatalog | null
|
||||
return null
|
||||
}
|
||||
|
||||
const NO_PUBLISHED_ACTIVE_GROUPS: Readonly<Record<string, string>> = {}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -278,6 +278,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
respondSessionTabClose: () => {},
|
||||
onMoveSessionTab: () => noopUnsubscribe,
|
||||
onOpenFileFromMobile: () => noopUnsubscribe,
|
||||
onRevealEditorTabFromRuntime: () => noopUnsubscribe,
|
||||
onOpenDiffFromMobile: () => noopUnsubscribe,
|
||||
onMobileMarkdownRequest: () => noopUnsubscribe,
|
||||
respondMobileMarkdownRequest: () => {},
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
@@ -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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
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<string, string> = 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')
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,9 @@ export type RuntimeSyncWindowGraph = {
|
||||
terminalGroupGridSizes?: Record<string, { cols: number; rows: number }>
|
||||
/** 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<string, string | null>
|
||||
/** The sidebar's Project grouping inputs for paired phones; sent only when they changed. */
|
||||
projectSectionCatalog?: MobileProjectSectionCatalog
|
||||
}
|
||||
|
||||
@@ -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<TabContentType, 'terminal' | 'browser' | 'agent-session' | 'simulator'>
|
||||
/** Present for edit-mode files, which are restored after restart. */
|
||||
file?: LayoutEditorFile
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutEditorFile, 'filePath' | 'runtimeEnvironmentId'>
|
||||
|
||||
/**
|
||||
* 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<WorkspaceLayout, 'worktreeId' | 'tabs' | 'editorFiles'>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user