diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 1e966c6217c..6d2f0792856 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -60,7 +60,7 @@ For tracked multi-agent work, use [Orchestration](/docs/cli/orchestration) inste ## File commands -Open files and diffs in the active Orca worktree from a shell: +Open files and diffs as tabs in their Orca worktree from a shell. They open without changing what you are looking at; add `--focus` to be brought to the file: ``` orca file open src/App.tsx diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 5c0afa52b98..d4cb4346d65 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -158,7 +158,7 @@ orca file diff src/App.tsx --staged --worktree active --json orca file open-changed --mode both --worktree active --json ``` -Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes. +Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes. Tabs open without changing what you are looking at; pass `--focus` to be brought to them. ## Built-in browser diff --git a/src/cli/command-scoped-flag-help.ts b/src/cli/command-scoped-flag-help.ts index b9238b3d6ff..9e4a6f00527 100644 --- a/src/cli/command-scoped-flag-help.ts +++ b/src/cli/command-scoped-flag-help.ts @@ -1,3 +1,7 @@ +// Why: the shared --focus line describes terminal create's terminal session. +const FILE_OPEN_FOCUS_HELP = + "--focus Bring the user to the file (switches Orca's window to its worktree)" + /** Per-command flag help, kept out of the shared help chain it would crowd. */ const COMMAND_SCOPED_FLAG_HELP: Record> = { 'skills get': { @@ -5,6 +9,15 @@ const COMMAND_SCOPED_FLAG_HELP: Record> = { reference: '--reference Print one bundled reference by name', references: '--references List the bundled reference names for a topic' }, + 'file open': { + focus: FILE_OPEN_FOCUS_HELP + }, + 'file diff': { + focus: FILE_OPEN_FOCUS_HELP + }, + 'file open-changed': { + focus: FILE_OPEN_FOCUS_HELP + }, 'skills install': { agent: '--agent Comma-separated install targets; default is detected agents' }, diff --git a/src/cli/handlers/file-absolute-paths.test.ts b/src/cli/handlers/file-absolute-paths.test.ts index 267d014762c..f354e8151a6 100644 --- a/src/cli/handlers/file-absolute-paths.test.ts +++ b/src/cli/handlers/file-absolute-paths.test.ts @@ -81,7 +81,8 @@ describe('absolute file CLI paths', () => { }) expect(callMock).toHaveBeenNthCalledWith(3, 'files.open', { worktree: 'id:repo::/root/orca/workspaces/xxx', - relativePath: 'xxx/xxx.ts' + relativePath: 'xxx/xxx.ts', + navigation: 'caller' }) }) @@ -105,7 +106,8 @@ describe('absolute file CLI paths', () => { expect(process.exitCode).toBeUndefined() expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath + relativePath, + navigation: 'caller' }) } @@ -267,7 +269,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) }) @@ -287,7 +290,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock).toHaveBeenCalledWith('files.open', { worktree: 'id:wt-1', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) }) @@ -308,7 +312,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath: absolutePath + relativePath: absolutePath, + navigation: 'caller' }) }) diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index 4e36ddbfff2..d9458712530 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -82,7 +82,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:repo::/tmp/repo', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.') }) @@ -107,7 +108,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenCalledWith('files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) }) @@ -127,7 +129,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenCalledWith('files.openDiff', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', - staged: false + staged: false, + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe( 'Did not open diff for assets/logo.png: binary file.' @@ -186,17 +189,20 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'src/App.tsx', - staged: false + staged: false, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'package.json', - staged: true + staged: true, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'docs/new.md', - staged: false + staged: false, + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.') }) @@ -263,7 +269,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) const output = vi.mocked(console.log).mock.calls[0][0] expect(output).toContain('Opened 1 changed file targets.') @@ -317,28 +324,163 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' }) expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: false + staged: false, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'docs/old.md', - staged: false + staged: false, + navigation: 'caller' }) const output = vi.mocked(console.log).mock.calls[0][0] expect(output).toContain('Opened 4 changed file targets.') expect(output).toContain('docs/old.md: deleted file has no edit target') }) + it('asks the host to move its view only when --focus is passed', async () => { + const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true } + queueFixtures( + callMock, + okFixture('req_open', opened), + okFixture('req_open_focus', opened), + okFixture('req_diff', opened), + okFixture('req_diff_focus', opened) + ) + + await main(['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere') + await main( + ['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + await main(['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere') + await main( + ['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'all' + }) + }) + + it('sends caller to a paired remote server, and all with --focus', async () => { + const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true } + queueFixtures(callMock, okFixture('req_open', opened), okFixture('req_open_focus', opened)) + const remote = ['--worktree', 'id:wt-1', '--pairing-code', 'remote-runtime'] + + await main(['file', 'open', 'src/App.tsx', ...remote], '/tmp/elsewhere') + await main(['file', 'open', 'src/App.tsx', ...remote, '--focus'], '/tmp/elsewhere') + + expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'all' + }) + }) + + it('moves the view once for open-changed --focus, on the first tab that opens', async () => { + queueFixtures( + callMock, + okFixture('req_status', { + entries: [ + { path: 'assets/logo.png', status: 'modified', area: 'unstaged' }, + { path: 'src/App.tsx', status: 'modified', area: 'unstaged' } + ], + conflictOperation: 'unknown' + }), + okFixture('req_open_binary', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: false + }), + okFixture('req_diff_1', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: true + }), + okFixture('req_open', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_2', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }) + ) + + await main( + ['file', 'open-changed', '--mode', 'both', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + + // Why: a binary edit open returns opened:false before reaching the host, so focus carries to the next open. + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + staged: false, + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'caller' + }) + }) + it('requires an explicit worktree for remote file commands', async () => { const priorExitCode = process.exitCode diff --git a/src/cli/handlers/file.ts b/src/cli/handlers/file.ts index 85e91fedeb1..83be828c040 100644 --- a/src/cli/handlers/file.ts +++ b/src/cli/handlers/file.ts @@ -1,5 +1,6 @@ import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types' import type { RuntimeFileOpenResult, RuntimeWorktreeRecord } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { isRuntimePathAbsolute, relativePathInsideRoot } from '../../shared/cross-platform-path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import type { CommandHandler, HandlerContext } from '../dispatch' @@ -116,6 +117,13 @@ function getOpenChangedMode(flags: Map): OpenChangedMo throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.') } +// Why: the CLI has no view of its own, so 'caller' moves nothing. --focus sends 'all', but unlike +// worktree create --activate a file open moves only the host's own window (intended; paired +// clients are not navigated). Hosts treat a missing field as the legacy switch. +function getFileOpenNavigation(flags: Map): RuntimeNavigationTarget { + return flags.get('focus') === true ? 'all' : 'caller' +} + function canOpenEntryForEdit(entry: GitStatusEntry): string | null { if (entry.status === 'deleted') { return 'deleted file has no edit target' @@ -129,11 +137,13 @@ function canOpenEntryForEdit(entry: GitStatusEntry): string | null { async function openFileEdit( ctx: HandlerContext, worktree: string, - path: string + path: string, + navigation: RuntimeNavigationTarget ): Promise { const result = await ctx.client.call('files.open', { worktree, - relativePath: path + relativePath: path, + navigation }) return { path, @@ -148,12 +158,14 @@ async function openFileDiff( ctx: HandlerContext, worktree: string, path: string, - staged: boolean + staged: boolean, + navigation: RuntimeNavigationTarget ): Promise { const result = await ctx.client.call('files.openDiff', { worktree, relativePath: path, - staged + staged, + navigation }) return { path, @@ -198,7 +210,8 @@ export const FILE_HANDLERS: Record = { const relativePath = await resolveFilePath(ctx, worktree, path) const result = await ctx.client.call('files.open', { worktree, - relativePath + relativePath, + navigation: getFileOpenNavigation(ctx.flags) }) printResult(result, ctx.json, formatFileOpen) }, @@ -210,7 +223,8 @@ export const FILE_HANDLERS: Record = { const result = await ctx.client.call('files.openDiff', { worktree, relativePath, - staged + staged, + navigation: getFileOpenNavigation(ctx.flags) }) printResult(result, ctx.json, formatFileDiff) }, @@ -221,6 +235,17 @@ export const FILE_HANDLERS: Record = { const opened: FileOpenRecord[] = [] const skipped: FileOpenRecord[] = [] const openedEditPaths = new Set() + // Why: switch once — the first tab that actually opens carries --focus; later ones join the now-viewed worktree. + let navigation = getFileOpenNavigation(ctx.flags) + const openWithFocusOnce = async ( + open: () => Promise + ): Promise => { + const record = await open() + if (record.opened) { + navigation = 'caller' + } + return record + } for (const entry of status.result.entries) { if (mode === 'edit' || mode === 'both') { @@ -235,7 +260,9 @@ export const FILE_HANDLERS: Record = { }) } else if (!openedEditPaths.has(entry.path)) { openedEditPaths.add(entry.path) - const record = await openFileEdit(ctx, worktree, entry.path) + const record = await openWithFocusOnce(() => + openFileEdit(ctx, worktree, entry.path, navigation) + ) const records = record.opened ? opened : skipped records.push(record) } @@ -253,7 +280,9 @@ export const FILE_HANDLERS: Record = { reason: 'unresolved conflict may not have a single diff target' }) } else { - const record = await openFileDiff(ctx, worktree, entry.path, staged) + const record = await openWithFocusOnce(() => + openFileDiff(ctx, worktree, entry.path, staged, navigation) + ) const records = record.opened ? opened : skipped records.push(record) } diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 2d4561f3098..6d4b9e6c189 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -76,9 +76,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' worktree ps Show a compact orchestration summary across worktrees', '', 'Files:', - ' file open Open a workspace file in the Orca editor', - ' file diff Open a workspace file diff in the Orca editor', - ' file open-changed Open all git-changed files for a workspace', + ' file open Open a file as a tab, view stays put (--focus brings you to it)', + ' file diff Open a file diff as a tab, view stays put (--focus brings you to it)', + ' file open-changed Open changed files as tabs, view stays put (--focus brings you there)', '', 'Terminals:', ' terminal list List live Orca-managed terminals', diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 8602e35c49e..34eeb71cf0f 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -56,9 +56,9 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', ' orca worktree rm --worktree [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]', ' orca worktree ps [--limit ] [--json]', - ' orca file open [--worktree ] [--json]', - ' orca file diff [--staged] [--worktree ] [--json]', - ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + ' orca file open [--worktree ] [--focus] [--json]', + ' orca file diff [--staged] [--worktree ] [--focus] [--json]', + ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--focus] [--json]', ' orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json]', ' orca terminal show [--terminal ] [--json]', ' orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', diff --git a/src/cli/specs/file.test.ts b/src/cli/specs/file.test.ts new file mode 100644 index 00000000000..9ef94f20614 --- /dev/null +++ b/src/cli/specs/file.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { findCommandSpec } from '../args' +import { formatCommandHelp } from '../help' +import { COMMAND_SPECS } from './index' + +function optionLine(path: string[], flag: string): string | undefined { + const spec = findCommandSpec(COMMAND_SPECS, path) + if (!spec) { + throw new Error(`no spec for ${path.join(' ')}`) + } + return formatCommandHelp(spec) + .split('\n') + .find((line) => line.trimStart().startsWith(`--${flag} `)) +} + +describe('orca file command help', () => { + it.each([['open'], ['diff'], ['open-changed']])( + 'file %s describes --focus as bringing the user to the file', + (command) => { + expect(optionLine(['file', command], 'focus')).toBe( + " --focus Bring the user to the file (switches Orca's window to its worktree)" + ) + } + ) + + it('leaves terminal create --focus describing its terminal session', () => { + expect(optionLine(['terminal', 'create'], 'focus')).toBe( + ' --focus Reveal the created terminal session in Orca' + ) + }) +}) diff --git a/src/cli/specs/file.ts b/src/cli/specs/file.ts index 863a4fdcf65..572671c49fb 100644 --- a/src/cli/specs/file.ts +++ b/src/cli/specs/file.ts @@ -4,25 +4,29 @@ import { GLOBAL_FLAGS } from '../args' export const FILE_COMMAND_SPECS: CommandSpec[] = [ { path: ['file', 'open'], - summary: 'Open a workspace file in the Orca editor', - usage: 'orca file open [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree'], + summary: + 'Open a file as a tab in its worktree without changing your view; --focus brings you to it', + usage: 'orca file open [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree', 'focus'], positionalArgs: ['path'], notes: [ + 'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.', 'The path may be relative to the selected worktree or an absolute path inside that worktree. When --worktree is omitted, local CLI calls infer the current Orca worktree from cwd.' ], examples: [ 'orca file open src/App.tsx', - 'orca file open --path docs/readme.md --worktree active' + 'orca file open --path docs/readme.md --worktree active --focus' ] }, { path: ['file', 'diff'], - summary: 'Open a workspace file diff in the Orca editor', - usage: 'orca file diff [--staged] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree'], + summary: + 'Open a file diff as a tab in its worktree without changing your view; --focus brings you to it', + usage: 'orca file diff [--staged] [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree', 'focus'], positionalArgs: ['path'], notes: [ + 'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.', 'Diffs default to unstaged changes. Pass --staged to open the staged source-control diff.', 'The path may be relative to the selected worktree or an absolute path inside that worktree.' ], @@ -33,10 +37,13 @@ export const FILE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['file', 'open-changed'], - summary: 'Open all git-changed files for a workspace', - usage: 'orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree'], + summary: + 'Open all git-changed files as tabs without changing your view; --focus brings you to them', + usage: + 'orca file open-changed [--mode edit|diff|both] [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree', 'focus'], notes: [ + 'The files open as tabs in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to them; agents should pass --focus only when the user asked to see the files.', 'For v1, changed files come from git status for the selected worktree.', 'The default mode is diff. Edit mode skips deleted files because there is no file to open.' ], diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index 56721971132..b504d6198f7 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -44,17 +44,24 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC absolutePath }), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), - openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { + openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation) => { if (!this.notifier?.openFile) { throw new Error('renderer_unavailable') } - this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId) + this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation) }, - openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => { + openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation) => { if (!this.notifier?.openDiff) { throw new Error('renderer_unavailable') } - this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) + this.notifier.openDiff( + worktreeId, + filePath, + relativePath, + staged, + runtimeEnvironmentId, + navigation + ) } }) diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index fb136c783e7..a1086ae8580 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -56,6 +56,7 @@ describe('RuntimeFileCommands', () => { '/repo/docs/readme.md', 'docs/readme.md', true, + undefined, undefined ) expect(result).toEqual({ @@ -78,6 +79,7 @@ describe('RuntimeFileCommands', () => { 'wt-1', '/repo/docs/readme.md', 'docs/readme.md', + undefined, undefined ) expect(result).toEqual({ @@ -100,6 +102,7 @@ describe('RuntimeFileCommands', () => { 'wt-1', '/repo/assets/logo.png', 'assets/logo.png', + undefined, undefined ) expect(result).toEqual({ @@ -110,6 +113,33 @@ describe('RuntimeFileCommands', () => { }) }) + it('passes the caller navigation target to the renderer host', async () => { + const openFile = vi.fn() + const openDiff = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile, openDiff }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md') + statMock.mockResolvedValue({ isDirectory: () => false }) + + await commands.openMobileFile('id:wt-1', 'docs/readme.md', 'all') + await commands.openMobileDiff('id:wt-1', 'docs/readme.md', false, 'host') + + expect(openFile).toHaveBeenCalledWith( + 'wt-1', + '/repo/docs/readme.md', + 'docs/readme.md', + undefined, + 'all' + ) + expect(openDiff).toHaveBeenCalledWith( + 'wt-1', + '/repo/docs/readme.md', + 'docs/readme.md', + false, + undefined, + 'host' + ) + }) + it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) diff --git a/src/main/runtime/rpc/methods/files-open-navigation.test.ts b/src/main/runtime/rpc/methods/files-open-navigation.test.ts new file mode 100644 index 00000000000..0d3ee7b1c0d --- /dev/null +++ b/src/main/runtime/rpc/methods/files-open-navigation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +function createDispatcher(): { + runtime: Pick + dispatcher: RpcDispatcher +} { + const opened = { worktree: 'wt-1', relativePath: 'a.ts', kind: 'text', opened: true } + const runtime = { + getRuntimeId: () => 'test-runtime', + openMobileFile: vi.fn().mockResolvedValue(opened), + openMobileDiff: vi.fn().mockResolvedValue(opened) + } + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FILE_METHODS open handlers only read the stubbed members. + runtime: runtime as unknown as OrcaRuntimeService, + methods: FILE_METHODS + }) + return { runtime, dispatcher } +} + +describe('file open RPC navigation', () => { + it('passes an explicit navigation target through file and diff opens', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts', navigation: 'all' }) + ) + await dispatcher.dispatch( + makeRequest('files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'a.ts', + navigation: 'host' + }) + ) + + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', 'all') + expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', false, 'host') + }) + + it('leaves navigation absent for callers that send none (phones, older CLIs)', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts' }) + ) + await dispatcher.dispatch( + makeRequest('files.openDiff', { worktree: 'id:wt-1', relativePath: 'a.ts', staged: true }) + ) + + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', undefined) + expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', true, undefined) + }) + + it('rejects an unknown navigation target on file opens', async () => { + const { runtime, dispatcher } = createDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('files.open', { + worktree: 'id:wt-1', + relativePath: 'a.ts', + navigation: 'everyone' + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.openMobileFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 114d4e75df8..2b29e9b65b6 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -51,7 +51,7 @@ describe('file RPC methods', () => { makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'docs/readme.md' }) ) - expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md') + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', undefined) expect(response).toMatchObject({ ok: true, result: { kind: 'markdown', opened: true } @@ -78,7 +78,12 @@ describe('file RPC methods', () => { }) ) - expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) + expect(runtime.openMobileDiff).toHaveBeenCalledWith( + 'id:wt-1', + 'docs/readme.md', + true, + undefined + ) expect(response).toMatchObject({ ok: true, result: { kind: 'markdown', opened: true } diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 29e2f7b071f..15ddd47b335 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -11,6 +11,7 @@ import { DocPreviewFileRead, FileListAll, FileOpenDiff, + FileOpenTab, FilePathSearch, FileReadChunk, FileSearch, @@ -56,15 +57,20 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.open', - params: FileOpen, + params: FileOpenTab, handler: async (params, { runtime }) => - runtime.openMobileFile(params.worktree, params.relativePath) + runtime.openMobileFile(params.worktree, params.relativePath, params.navigation) }), defineMethod({ name: 'files.openDiff', params: FileOpenDiff, handler: async (params, { runtime }) => - runtime.openMobileDiff(params.worktree, params.relativePath, params.staged === true) + runtime.openMobileDiff( + params.worktree, + params.relativePath, + params.staged === true, + params.navigation + ) }), defineMethod({ name: 'files.read', diff --git a/src/main/runtime/runtime-file-command-host.ts b/src/main/runtime/runtime-file-command-host.ts index 8f6763f09e5..3fb74c71632 100644 --- a/src/main/runtime/runtime-file-command-host.ts +++ b/src/main/runtime/runtime-file-command-host.ts @@ -6,6 +6,7 @@ import type { } from './runtime-file-command-target' import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { @@ -52,14 +53,16 @@ export type RuntimeFileCommandHost = { worktreeId: string, filePath: string, relativePath: string, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void openDiff( worktreeId: string, filePath: string, relativePath: string, staged: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void } diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-commands-constructor.ts index 1af54400c42..ecd21b43994 100644 --- a/src/main/runtime/runtime-file-commands-constructor.ts +++ b/src/main/runtime/runtime-file-commands-constructor.ts @@ -11,6 +11,7 @@ import { } from './runtime-file-command-host' import { basenameFromRelativePath } from './runtime-file-paths' import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { MOBILE_FILE_LIST_LIMIT, @@ -155,7 +156,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA async openMobileFile( worktreeSelector: string, - relativePath: string + relativePath: string, + navigation?: RuntimeNavigationTarget ): Promise { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree } = target @@ -177,7 +179,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA // Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab. await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target)) // Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId. - this.host.openFile(worktree.id, filePath, relativePath, undefined) + this.host.openFile(worktree.id, filePath, relativePath, undefined, navigation) return { worktree: worktree.id, relativePath, kind, opened: true } } @@ -203,7 +205,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA async openMobileDiff( worktreeSelector: string, relativePath: string, - staged: boolean + staged: boolean, + navigation?: RuntimeNavigationTarget ): Promise { const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { @@ -216,7 +219,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA : 'text' const filePath = joinWorktreeRelativePath(worktree.path, relativePath) // Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId. - this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined) + this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined, navigation) return { worktree: worktree.id, relativePath, kind, opened: true } } } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index fa87d839cd5..c5b279e41db 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -2,6 +2,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { WorktreeBaseStatusEvent, @@ -97,18 +98,24 @@ export type RuntimeNotifier = { focusEditorTab?(tabId: string, worktreeId: string): void closeSessionTab?(tabId: string, worktreeId: string): void | Promise moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void + /** + * Acts only on the host's own window: 'host'/'all' move it, 'caller'/'clients' open without moving + * it, absent keeps the original switch. Paired clients are never navigated (intended; 'all' == 'host'). + */ openFile?( worktreeId: string, filePath: string, relativePath: string, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void openDiff?( worktreeId: string, filePath: string, relativePath: string, staged: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise saveMobileMarkdownTab?( diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index 88f4aefb186..1d033036d53 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -635,7 +635,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(mocks.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(mocks.abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') expect(mocks.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) - expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) + expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true, undefined) // A mobile WebSocket client is transport-capped; a local caller gets undefined here. expect(mocks.getRuntimeGitDiff).toHaveBeenCalledWith( 'id:wt-1', diff --git a/src/main/window/runtime-window-lifecycle-file-open.test.ts b/src/main/window/runtime-window-lifecycle-file-open.test.ts new file mode 100644 index 00000000000..f901c6be83c --- /dev/null +++ b/src/main/window/runtime-window-lifecycle-file-open.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() })) +vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() })) +vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() })) +vi.mock('./session-tab-close-request-relay', () => ({ + requestSessionTabCloseFromRenderer: vi.fn() +})) +vi.mock('./terminal-tab-close-request-relay', () => ({ + requestTerminalTabCloseFromRenderer: vi.fn() +})) + +import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle' + +function attachNotifier(): { notifier: RuntimeNotifier; send: ReturnType } { + const send = vi.fn() + const attached: { notifier: RuntimeNotifier | null } = { notifier: null } + const mainWindow = { + id: 1, + isDestroyed: () => false, + on: vi.fn(), + webContents: { isDestroyed: () => false, send, on: vi.fn() } + } + const runtime = { + attachWindow: vi.fn(), + markGraphReloadFailed: vi.fn(), + setNotifier: (next: RuntimeNotifier | null) => { + attached.notifier = next + } + } + registerRuntimeWindowLifecycle( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only reads id, isDestroyed, on and webContents. + mainWindow as unknown as BrowserWindow, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only calls the stubbed runtime members. + runtime as unknown as OrcaRuntimeService + ) + if (!attached.notifier) { + throw new Error('runtime notifier was not attached') + } + return { notifier: attached.notifier, send } +} + +describe('runtime window file-open notifications', () => { + it('omits navigation when the caller sent none (legacy switch)', () => { + const { notifier, send } = attachNotifier() + + notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined) + notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', true, undefined) + + expect(send).toHaveBeenCalledWith('ui:openFileFromMobile', { + worktreeId: 'wt-1', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + runtimeEnvironmentId: undefined + }) + expect(send).toHaveBeenCalledWith('ui:openDiffFromMobile', { + worktreeId: 'wt-1', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + staged: true, + runtimeEnvironmentId: undefined + }) + for (const [, payload] of send.mock.calls) { + expect(payload).not.toHaveProperty('navigation') + } + }) + + it('forwards an explicit navigation target to the renderer', () => { + const { notifier, send } = attachNotifier() + + notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined, 'all') + notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', false, undefined, 'caller') + + expect(send).toHaveBeenCalledWith( + 'ui:openFileFromMobile', + expect.objectContaining({ navigation: 'all' }) + ) + expect(send).toHaveBeenCalledWith( + 'ui:openDiffFromMobile', + expect.objectContaining({ navigation: 'caller' }) + ) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 142389099c0..0322a816d70 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -169,20 +169,22 @@ export function registerRuntimeWindowLifecycle( requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) => send('ui:moveSessionTab', { worktreeId, ...move }), - openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?) => + openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?) => send('ui:openFileFromMobile', { worktreeId, filePath, relativePath, - runtimeEnvironmentId + runtimeEnvironmentId, + ...(navigation ? { navigation } : {}) }), - openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?) => + openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) => send('ui:openDiffFromMobile', { worktreeId, filePath, relativePath, staged, - runtimeEnvironmentId + runtimeEnvironmentId, + ...(navigation ? { navigation } : {}) }), readMobileMarkdownTab: (worktreeId, tabId) => requestMobileMarkdownFromRenderer(mainWindow, { diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 456adfef27a..51957a5eec5 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -10,6 +10,7 @@ import { type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' @@ -24,6 +25,7 @@ export const uiClipboardAndWindowControlsApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ): (() => void) => { const listener = ( @@ -34,6 +36,7 @@ export const uiClipboardAndWindowControlsApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget } ) => callback(data) ipcRenderer.on('ui:openDiffFromMobile', listener) 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 a331ab61219..f49e7e65e49 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -6,6 +6,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabMove, RuntimeTerminalCreateRequestPayload, @@ -203,6 +204,7 @@ export const uiTerminalAndSessionTabsApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ): (() => void) => { const listener = ( @@ -212,6 +214,7 @@ export const uiTerminalAndSessionTabsApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget } ) => callback(data) ipcRenderer.on('ui:openFileFromMobile', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index f6bb247bf91..217e5c027e5 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,6 +1,7 @@ import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, @@ -224,6 +225,7 @@ export type UiCommandEventApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ) => () => void onOpenDiffFromMobile: ( @@ -233,6 +235,7 @@ export type UiCommandEventApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ) => () => void onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.test.ts new file mode 100644 index 00000000000..f24a809d9f5 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.test.ts @@ -0,0 +1,404 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import { createTestStore, makeWorktree, seedStore } from '../../store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '../../store/slices/store-session-test-harness' +import { buildMobileSessionTabSnapshots } from '@/runtime/sync-runtime-graph/mobile-session-snapshots' + +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(), + 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', () => ({ + persistWorkspaceSessionByHost: vi.fn() +})) + +import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' +import { handleSwitchRecentTab } from '../ipc-tab-switch' + +type OpenFilePayload = { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget +} +type OpenDiffPayload = OpenFilePayload & { staged: boolean } +type TestStore = ReturnType + +const VIEWED = 'repo1::/repo1/viewed' +const BACKGROUND = 'repo1::/repo1/background' +const VIEWED_FILE = '/repo1/viewed/notes.md' +const APP_TS = { filePath: '/repo1/background/src/app.ts', relativePath: 'src/app.ts' } +const VIEWED_APP_TS = { filePath: '/repo1/viewed/src/app.ts', relativePath: 'src/app.ts' } + +function setup(viewedSurface: 'editor' | 'terminal'): { + openFile: (payload: OpenFilePayload) => void + openDiff: (payload: OpenDiffPayload) => void + store: TestStore +} { + const mockApi = createStoreSessionMockApi() + const listeners: { + openFile?: (payload: OpenFilePayload) => void + openDiff?: (payload: OpenDiffPayload) => void + } = {} + vi.stubGlobal('window', { + api: { + ...mockApi, + ui: { + onOpenFileFromMobile: (cb: (payload: OpenFilePayload) => void) => { + listeners.openFile = cb + return () => {} + }, + onOpenDiffFromMobile: (cb: (payload: OpenDiffPayload) => void) => { + listeners.openDiff = cb + return () => {} + }, + onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {} + } + } + }) + const store = createTestStore() + storeRef.current = store + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: VIEWED, repoId: 'repo1', path: '/repo1/viewed' }), + makeWorktree({ id: BACKGROUND, repoId: 'repo1', path: '/repo1/background' }) + ] + } + }) + store.getState().setActiveWorktree(VIEWED) + store.getState().openFile({ + filePath: VIEWED_FILE, + relativePath: 'notes.md', + worktreeId: VIEWED, + language: 'markdown', + runtimeEnvironmentId: null, + mode: 'edit' + }) + if (viewedSurface === 'terminal') { + store.getState().createTab(VIEWED) + store.getState().setActiveTabType('terminal', VIEWED) + } + store.setState({ activeView: 'terminal', pendingRevealWorktree: null }) + registerMobileAndTerminalCloseIpcBridge([], vi.fn()) + const { openFile, openDiff } = listeners + if (!openFile || !openDiff) { + throw new Error('file-open listeners were not registered') + } + return { openFile, openDiff, store } +} + +/** Everything the user can see or type into on the desktop. */ +function screenState(store: TestStore): unknown { + const s = store.getState() + return { + activeWorktreeId: s.activeWorktreeId, + activeView: s.activeView, + activeTabType: s.activeTabType, + activeFileId: s.activeFileId, + activeTabId: s.activeTabId, + viewedActiveFile: s.activeFileIdByWorktree[VIEWED], + viewedActiveTabType: s.activeTabTypeByWorktree[VIEWED], + viewedActiveGroup: s.activeGroupIdByWorktree[VIEWED], + viewedGroups: s.groupsByWorktree[VIEWED]?.map((group) => ({ + id: group.id, + activeTabId: group.activeTabId + })), + pendingEditorFocusRequest: s.pendingEditorFocusRequest, + pendingRevealWorktree: s.pendingRevealWorktree, + backgroundVisitedAt: s.lastVisitedAtByWorktreeId[BACKGROUND] + } +} + +function tabEntityIds(store: TestStore, worktreeId: string): string[] { + return (store.getState().unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.entityId) +} + +function activeEditorEntityId(store: TestStore, worktreeId: string): string | undefined { + const state = store.getState() + const group = state.groupsByWorktree[worktreeId]?.find( + (candidate) => candidate.id === state.activeGroupIdByWorktree[worktreeId] + ) + return state.unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.id === group?.activeTabId) + ?.entityId +} + +describe('runtime file opens on the host desktop', () => { + beforeEach(() => { + vi.unstubAllGlobals() + }) + + it('CLI caller open adds the tab to the viewed worktree without leaving the focused terminal', () => { + const { openFile, store } = setup('terminal') + const before = screenState(store) + expect(store.getState().activeTabType).toBe('terminal') + + openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + expect(tabEntityIds(store, VIEWED)).toContain(VIEWED_APP_TS.filePath) + }) + + it('CLI caller diff is added to the viewed worktree without replacing the visible editor', () => { + const { openDiff, store } = setup('editor') + const before = screenState(store) + expect(store.getState().activeFileId).toBe(VIEWED_FILE) + + openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + expect( + store.getState().openFiles.some((file) => file.mode === 'diff' && file.worktreeId === VIEWED) + ).toBe(true) + }) + + it('CLI caller open in a background worktree leaves the viewed editor alone', () => { + const { openFile, store } = setup('editor') + const before = screenState(store) + + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + // Why: the tab is that worktree's selection, so it is what the user sees on going there. + expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath) + expect(store.getState().activeFileIdByWorktree[BACKGROUND]).toBe(APP_TS.filePath) + store.getState().setActiveWorktree(BACKGROUND) + expect(store.getState().activeFileId).toBe(APP_TS.filePath) + expect(store.getState().activeTabType).toBe('editor') + }) + + it('CLI caller diff in a background worktree leaves the viewed editor alone', () => { + const { openDiff, store } = setup('editor') + const before = screenState(store) + + openDiff({ worktreeId: BACKGROUND, ...APP_TS, staged: true, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + const opened = store + .getState() + .openFiles.find((file) => file.mode === 'diff' && file.worktreeId === BACKGROUND) + expect(opened?.diffSource).toBe('staged') + expect(activeEditorEntityId(store, BACKGROUND)).toBe(opened?.id) + }) + + it('CLI caller reopen of an already-open file keeps the focused terminal', () => { + const { openFile, store } = setup('terminal') + const before = screenState(store) + const tabCount = tabEntityIds(store, VIEWED).length + + openFile({ + worktreeId: VIEWED, + filePath: VIEWED_FILE, + relativePath: 'notes.md', + navigation: 'caller' + }) + openFile({ + worktreeId: VIEWED, + filePath: VIEWED_FILE, + relativePath: 'notes.md', + navigation: 'caller' + }) + + expect(screenState(store)).toEqual(before) + expect(tabEntityIds(store, VIEWED)).toHaveLength(tabCount) + }) + + it('CLI caller reopen of an already-open diff keeps the focused terminal', () => { + const { openDiff, store } = setup('terminal') + const before = screenState(store) + + openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' }) + const tabCount = tabEntityIds(store, VIEWED).length + openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + expect(tabEntityIds(store, VIEWED)).toHaveLength(tabCount) + }) + + it('selects a background CLI tab without stamping a visit, keeping its tab history', () => { + const { openFile, store } = setup('editor') + const terminal = store.getState().createTab(BACKGROUND) + const tabId = (entityId: string): string | undefined => + store.getState().unifiedTabsByWorktree[BACKGROUND]?.find((tab) => tab.entityId === entityId) + ?.id + const focusTimes = (): [string, number | undefined][] => + (store.getState().unifiedTabsByWorktree[BACKGROUND] ?? []).map((tab) => [ + tab.entityId, + tab.lastFocusedAt + ]) + const history = (): string[] | undefined => + store.getState().groupsByWorktree[BACKGROUND]?.[0]?.recentTabIds + const timesBefore = focusTimes() + + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' }) + + expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath) + // Why: the jump palette's recent rows sort by lastFocusedAt; the user never looked at this tab. + expect(focusTimes()).toEqual([...timesBefore, [APP_TS.filePath, undefined]]) + // Why: the group history must still end on the active tab, or Ctrl+Tab has nowhere to go back to. + expect(history()?.at(-1)).toBe(tabId(APP_TS.filePath)) + + store.getState().activateTab(tabId(terminal.id) ?? '') + const timesBeforeReopen = focusTimes() + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' }) + + expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath) + expect(focusTimes()).toEqual(timesBeforeReopen) + expect(history()?.at(-1)).toBe(tabId(APP_TS.filePath)) + }) + + it.each([ + ['a new tab', false], + ['a reopened tab already in the history', true] + ] as const)('Ctrl+Tab works after a background CLI open of %s', (_label, reopen) => { + const { openFile, store } = setup('editor') + store.getState().setActiveWorktree(BACKGROUND) + const terminal = store.getState().createTab(BACKGROUND) + store.getState().setActiveTabType('terminal', BACKGROUND) + store.getState().setActiveWorktree(VIEWED) + const terminalTabId = store + .getState() + .unifiedTabsByWorktree[BACKGROUND]?.find((tab) => tab.entityId === terminal.id)?.id + if (reopen) { + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' }) + // Why: the user went back to the terminal, so the file is in the history but not last. + store.getState().activateTab(terminalTabId ?? '') + } + + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation: 'caller' }) + store.getState().setActiveWorktree(BACKGROUND) + store.getState().setActiveTabType('editor', BACKGROUND) + + expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath) + expect(handleSwitchRecentTab()).toBe(true) + expect(activeEditorEntityId(store, BACKGROUND)).toBe(terminal.id) + }) + + it('switches the desktop for a phone open (no navigation field), as before', () => { + const { openFile, store } = setup('terminal') + + openFile({ worktreeId: BACKGROUND, ...APP_TS }) + + const state = store.getState() + expect(state.activeWorktreeId).toBe(BACKGROUND) + expect(state.activeView).toBe('terminal') + expect(state.activeFileId).toBe(APP_TS.filePath) + expect(state.activeTabType).toBe('editor') + expect(activeEditorEntityId(store, BACKGROUND)).toBe(APP_TS.filePath) + expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND) + expect(state.lastVisitedAtByWorktreeId[BACKGROUND]).toBeDefined() + }) + + it('selects a phone-opened diff in the viewed worktree so "Open in session" lands on it', () => { + const { openDiff, store } = setup('terminal') + + openDiff({ worktreeId: VIEWED, ...VIEWED_APP_TS, staged: false }) + + const state = store.getState() + const diff = state.openFiles.find((file) => file.mode === 'diff' && file.worktreeId === VIEWED) + expect(state.activeWorktreeId).toBe(VIEWED) + expect(state.activeTabType).toBe('editor') + expect(state.activeFileId).toBe(diff?.id) + expect(activeEditorEntityId(store, VIEWED)).toBe(diff?.id) + const snapshot = buildMobileSessionTabSnapshots(state, false).find( + (candidate) => candidate.worktree === VIEWED + ) + const activeTab = snapshot?.tabs.find((tab) => tab.id === snapshot.activeTabId) + expect(activeTab && 'relativePath' in activeTab ? activeTab.relativePath : null).toBe( + VIEWED_APP_TS.relativePath + ) + }) + + it('publishes a CLI caller tab to the phone tab list without moving the desktop', () => { + const { openFile, store } = setup('terminal') + const before = screenState(store) + + openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'caller' }) + + expect(screenState(store)).toEqual(before) + const snapshot = buildMobileSessionTabSnapshots(store.getState(), false).find( + (candidate) => candidate.worktree === VIEWED + ) + expect( + snapshot?.tabs.some( + (tab) => 'relativePath' in tab && tab.relativePath === VIEWED_APP_TS.relativePath + ) + ).toBe(true) + }) + + it.each(['caller', 'clients'] as const)( + 'keeps the desktop still when navigation %s does not target the host', + (navigation) => { + const { openFile, store } = setup('terminal') + const before = screenState(store) + + openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation }) + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation }) + + expect(screenState(store)).toEqual(before) + } + ) + + it.each(['all', 'host'] as const)( + 'brings the user to the file with navigation %s', + (navigation) => { + const { openFile, store } = setup('terminal') + + openFile({ worktreeId: BACKGROUND, ...APP_TS, navigation }) + + const state = store.getState() + expect(state.activeWorktreeId).toBe(BACKGROUND) + expect(state.activeView).toBe('terminal') + expect(state.activeFileId).toBe(APP_TS.filePath) + expect(state.activeTabType).toBe('editor') + expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND) + expect(state.lastVisitedAtByWorktreeId[BACKGROUND]).toBeDefined() + } + ) + + it('selects the tab over a focused terminal in the viewed worktree with navigation all', () => { + const { openFile, store } = setup('terminal') + + openFile({ worktreeId: VIEWED, ...VIEWED_APP_TS, navigation: 'all' }) + + expect(store.getState().activeTabType).toBe('editor') + expect(store.getState().activeFileId).toBe(VIEWED_APP_TS.filePath) + expect(activeEditorEntityId(store, VIEWED)).toBe(VIEWED_APP_TS.filePath) + }) + + it('brings the user to a diff with navigation all', () => { + const { openDiff, store } = setup('editor') + + openDiff({ worktreeId: BACKGROUND, ...APP_TS, staged: false, navigation: 'all' }) + + const state = store.getState() + expect(state.activeWorktreeId).toBe(BACKGROUND) + expect(state.openFiles.find((file) => file.id === state.activeFileId)?.mode).toBe('diff') + expect(state.pendingRevealWorktree?.worktreeId).toBe(BACKGROUND) + }) +}) 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 43a7e590558..02c020d0905 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 @@ -5,6 +5,33 @@ import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' +import type { EditorTabSelection } from '../../store/slices/editor/types/open-file' +import { + navigationTargetsHost, + type RuntimeNavigationTarget +} from '../../../../shared/runtime-navigation' + +// Why: a caller that names a non-host target (CLI without --focus) must not change anything on screen; +// the tab is selected only inside a worktree the user is not viewing, without counting as a visit. +// No target (phones, older CLIs) keeps the original switch, which the phone's "Open in session" relies on. +function openRuntimeEditorTab( + worktreeId: string, + navigation: RuntimeNavigationTarget | undefined, + open: (store: AppState, selection: EditorTabSelection) => void +): void { + const store = useAppStore.getState() + if (navigation !== undefined && !navigationTargetsHost(navigation)) { + open(store, worktreeId === store.activeWorktreeId ? 'none' : 'background') + return + } + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + open(store, 'focus') + store.setActiveTabType('editor', worktreeId) + store.revealWorktreeInSidebar(worktreeId) +} export function registerMobileAndTerminalCloseIpcBridge( unsubs: (() => void)[], @@ -12,41 +39,36 @@ export function registerMobileAndTerminalCloseIpcBridge( ): void { unsubs.push( window.api.ui.onOpenFileFromMobile( - ({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => { - const store = useAppStore.getState() + ({ worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation }) => { const basename = relativePath.split(/[\\/]/).pop() || relativePath - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. - store.openFile({ - filePath, - relativePath, - worktreeId, - language: detectLanguage(basename), - runtimeEnvironmentId, - mode: 'edit' - }) - store.setActiveTabType('editor', worktreeId) - store.revealWorktreeInSidebar(worktreeId) + openRuntimeEditorTab(worktreeId, navigation, (store, selection) => + // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. + store.openFile( + { + filePath, + relativePath, + worktreeId, + language: detectLanguage(basename), + runtimeEnvironmentId, + mode: 'edit' + }, + { selection } + ) + ) } ) ) unsubs.push( window.api.ui.onOpenDiffFromMobile( - ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => { - const store = useAppStore.getState() - const language = detectLanguage(relativePath) - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. - store.openDiff(worktreeId, filePath, relativePath, language, staged, { - runtimeEnvironmentId - }) - store.setActiveTabType('editor', worktreeId) - store.revealWorktreeInSidebar(worktreeId) + ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation }) => { + openRuntimeEditorTab(worktreeId, navigation, (store, selection) => + // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. + store.openDiff(worktreeId, filePath, relativePath, detectLanguage(relativePath), staged, { + runtimeEnvironmentId, + selection + }) + ) } ) ) diff --git a/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts b/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts index 54690fd67b0..1a6168bd5c8 100644 --- a/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts @@ -265,6 +265,8 @@ describe('createEditorSlice recently closed editor tabs', () => { const liveWt1Id = openShared('wt-1') expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null)) store.getState().closeFile(wt2Id) + // Why: closing wt-1's last tab above left the landing state; reopen runs for the on-screen worktree. + store.setState({ activeWorktreeId: 'wt-1' }) expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) diff --git a/src/renderer/src/store/slices/editor/actions/open-file-action.ts b/src/renderer/src/store/slices/editor/actions/open-file-action.ts index 7f488c4831f..216dedb257b 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-action.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-action.ts @@ -36,7 +36,8 @@ export function createOpenFileAction( editorItemLabel, editorItemContentType, scratch.editorItemIsPreview, - scratch.editorItemTargetGroupId + scratch.editorItemTargetGroupId, + options?.selection ) if (options?.focusEditor) { set({ diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index 930851fd240..f8c2f7b7fe9 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -5,6 +5,7 @@ import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../../r import type { AppState } from '../../../types' import { type ClosedEditorTabSnapshot, + type EditorTabSelection, MAX_RECENT_CLOSED_EDITOR_TABS, type OpenFile } from '../types/open-file' @@ -45,6 +46,7 @@ export function applyOpenFileToState( forceContentReload?: boolean focusEditor?: boolean reopenId?: string + selection?: EditorTabSelection } | undefined, scratch: OpenFileApplyScratch @@ -99,7 +101,8 @@ export function applyOpenFileToState( const targetGroupId = resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined scratch.editorItemTargetGroupId = targetGroupId - const activeResult = buildEditorActiveResult(s, worktreeId, id) + const activeResult = + options?.selection === 'none' ? {} : buildEditorActiveResult(s, worktreeId, id) if (existing) { // If opening as non-preview, also pin the existing tab const updatedPreview = isPreview ? existing.isPreview : false diff --git a/src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts b/src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts index 8c65ec62006..16878e28b48 100644 --- a/src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts +++ b/src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts @@ -3,7 +3,10 @@ import type { EditorSlice } from '../types/editor-slice' import type { DiffSource, OpenFile } from '../types/open-file' import { buildDiffEditorFileId, withDiffContentReloadRequest } from '../file-ids/editor-file-ids' import { resolveDiffRuntimeEnvironmentId } from '../git/diff-runtime-owner' -import { resolveEditorOpenTargetGroupId } from '../tabs/editor-open-target-group' +import { + buildEditorActiveResult, + resolveEditorOpenTargetGroupId +} from '../tabs/editor-open-target-group' import { resolveEditorPreviewIntent } from '../tabs/editor-preview-tab-setting' import { getReplaceablePreviewFileId, @@ -18,6 +21,7 @@ export function createOpenUnstagedDiff( return { openDiff: (worktreeId, filePath, relativePath, language, staged, options) => { const isPreview = resolveEditorPreviewIntent(get(), options?.preview) + const selection = options?.selection let editorItemTargetGroupId = options?.targetGroupId let editorItemFileId = '' set((s) => { @@ -32,6 +36,7 @@ export function createOpenUnstagedDiff( const targetGroupId = resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined editorItemTargetGroupId = targetGroupId + const activeResult = selection === 'none' ? {} : buildEditorActiveResult(s, worktreeId, id) const existing = s.openFiles.find((f) => f.id === id) if (existing) { const updatedPreview = isPreview ? existing.isPreview : false @@ -47,10 +52,7 @@ export function createOpenUnstagedDiff( }) return { openFiles: s.openFiles.map((f) => (f.id === id ? reopenedDiff : f)), - activeFileId: id, - activeTabType: 'editor', - activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + ...activeResult } } const newFile: OpenFile = { @@ -79,19 +81,13 @@ export function createOpenUnstagedDiff( index === replaceablePreviewIndex ? newFile : file ), ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), - activeFileId: id, - activeTabType: 'editor', - activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + ...activeResult } } } return { openFiles: [...s.openFiles, newFile], - activeFileId: id, - activeTabType: 'editor', - activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, - activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + ...activeResult } }) void openWorkspaceEditorItem( @@ -101,7 +97,8 @@ export function createOpenUnstagedDiff( relativePath, 'diff', isPreview, - editorItemTargetGroupId + editorItemTargetGroupId, + selection ) } } diff --git a/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts b/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts index 6ea5398b440..60775d6c122 100644 --- a/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts +++ b/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts @@ -1,7 +1,6 @@ import type { AppState } from '../../../types' import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' -import type { EditorSlice } from '../types/editor-slice' import { isEditorTabContentType } from './editor-tab-content-type' export function getGroupActiveTab(group: TabGroup, tabsById: Map): Tab | null { @@ -81,7 +80,7 @@ export function resolveEditorOpenTargetGroupId( } export function buildEditorActiveResult( - state: Pick, + state: Pick, worktreeId: string, fileId: string ): { @@ -91,8 +90,8 @@ export function buildEditorActiveResult( activeTabTypeByWorktree: Record } { return { - // Why: floating markdown tabs must not become the worktree's active editor, so update only the per-worktree maps. - ...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID + // Why: only the on-screen worktree owns the main editor; a background or floating open updates only the per-worktree maps. + ...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID || worktreeId !== state.activeWorktreeId ? {} : { activeFileId: fileId, activeTabType: 'editor' as const }), activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: fileId }, diff --git a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts index ef7d091215e..e26a2526975 100644 --- a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts +++ b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts @@ -1,6 +1,6 @@ import type { AppState } from '../../../types' import type { EditorSlice } from '../types/editor-slice' -import type { OpenFile } from '../types/open-file' +import type { EditorTabSelection, OpenFile } from '../types/open-file' import { resolveEditorOpenTargetGroupId } from './editor-open-target-group' import { areEditorPreviewTabsEnabled } from './editor-preview-tab-setting' import { isEditorTabContentType } from './editor-tab-content-type' @@ -12,7 +12,8 @@ export function openWorkspaceEditorItem( label: string, contentType: 'editor' | 'diff' | 'conflict-review' | 'check-details', isPreview?: boolean, - targetGroupId?: string + targetGroupId?: string, + selection: EditorTabSelection = 'focus' ): string { const resolvedGroupId = resolveEditorOpenTargetGroupId(state, worktreeId, targetGroupId) if (resolvedGroupId) { @@ -23,8 +24,13 @@ export function openWorkspaceEditorItem( contentType ) if (existing) { - // Why: sidebar preview reopens focus the tab without promoting it; explicit activation still promotes previews by default. - state.activateTab?.(existing.id, { preservePreview: isPreview }) + if (selection !== 'none') { + // Why: sidebar preview reopens focus the tab without promoting it; explicit activation still promotes previews by default. + state.activateTab?.(existing.id, { + preservePreview: isPreview, + ...(selection === 'background' ? { recordFocus: false } : {}) + }) + } return existing.id } } @@ -32,7 +38,9 @@ export function openWorkspaceEditorItem( entityId: fileId, label, isPreview, - ...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}) + ...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}), + ...(selection === 'none' ? { activate: false } : {}), + ...(selection === 'background' ? { recordFocus: false } : {}) }) return created?.id ?? fileId } diff --git a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts index 930aca60d47..3d0565f7b67 100644 --- a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts +++ b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts @@ -18,6 +18,7 @@ import type { ConflictReviewEntry, ConflictReviewState, EditorOpenTargetOptions, + EditorTabSelection, OpenFile } from './open-file' import type { OpenFilePathRekey, RekeyOpenFilesResult } from './open-file-path-rekey' @@ -44,6 +45,7 @@ export type EditorFilesSlice = { forceContentReload?: boolean focusEditor?: boolean reopenId?: string + selection?: EditorTabSelection } ) => string openNewMarkdownInActiveWorkspace: (groupId: string) => Promise @@ -101,7 +103,7 @@ export type EditorFilesSlice = { relativePath: string, language: string, staged: boolean, - options?: EditorOpenTargetOptions + options?: EditorOpenTargetOptions & { selection?: EditorTabSelection } ) => void openBranchDiff: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/editor/types/open-file.ts b/src/renderer/src/store/slices/editor/types/open-file.ts index 8ad085cd933..b9c43a33bf8 100644 --- a/src/renderer/src/store/slices/editor/types/open-file.ts +++ b/src/renderer/src/store/slices/editor/types/open-file.ts @@ -166,6 +166,12 @@ export type ClosedEditorTabSnapshot = Omit< export const MAX_RECENT_CLOSED_EDITOR_TABS = 10 +/** + * How an editor open selects its tab. 'focus' (default) selects it as a user action; 'background' + * selects it inside its own worktree without stamping its focus time; 'none' only adds it. + */ +export type EditorTabSelection = 'focus' | 'background' | 'none' + export type EditorOpenTargetOptions = { targetGroupId?: string preview?: boolean diff --git a/src/renderer/src/store/slices/new-markdown.test.ts b/src/renderer/src/store/slices/new-markdown.test.ts index 4c1ced68617..44385991660 100644 --- a/src/renderer/src/store/slices/new-markdown.test.ts +++ b/src/renderer/src/store/slices/new-markdown.test.ts @@ -401,6 +401,8 @@ describe('New Markdown — rename flow store operations', () => { expect(store.getState().activeFileId).toBe('/repo/untitled.md') store.getState().closeFile('/repo/untitled.md') + // Why: closing the only tab left the landing state; the renamed file opens back on the on-screen worktree. + store.setState({ activeWorktreeId: 'wt-1' }) store.getState().openFile({ filePath: '/repo/notes.md', relativePath: 'notes.md', diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index 2149b84cbc1..70db09f8214 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -66,6 +66,7 @@ export function createTabsCreateActions( } const shouldActivate = init?.activate ?? true + const recordsFocus = shouldActivate && init?.recordFocus !== false const createdAt = Date.now() const executionHostId = init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) @@ -100,7 +101,7 @@ export function createTabsCreateActions( sortOrder: insertedIndex, createdAt, // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. - ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), + ...(recordsFocus ? { lastFocusedAt: createdAt } : {}), isPreview: init?.isPreview, isPinned: init?.isPinned } diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index 6e4134c6a1b..5ac12da5cb3 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -77,7 +77,7 @@ export function createTabsFocusActions( ? { ...item, isPreview: opts?.preservePreview ? item.isPreview : false, - lastFocusedAt: Date.now() + ...(opts?.recordFocus === false ? {} : { lastFocusedAt: Date.now() }) } : item ) diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index efb2da8adda..63dfab374cd 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -38,6 +38,8 @@ export type TabsSlice = { /** Client-local unified tab id to insert after; an explicit targetGroupId still wins. */ afterTabId: string activate: boolean + /** false selects an activated tab without stamping its focus time (the group history still updates). */ + recordFocus: boolean recordInteraction: boolean } > @@ -77,7 +79,11 @@ export type TabsSlice = { entityId: string, contentType?: TabContentType ) => Tab | null - activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void + activateTab: ( + tabId: string, + /** recordFocus false skips the focus-time stamp (not a user visit); the group history still updates. */ + opts?: { preservePreview?: boolean; worktreeId?: string; recordFocus?: boolean } + ) => void closeUnifiedTab: ( tabId: string, opts?: { diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts index 80fdba2a1ac..20dd54d27a8 100644 --- a/src/shared/rpc-contract/files-params.ts +++ b/src/shared/rpc-contract/files-params.ts @@ -1,6 +1,7 @@ import { PATH_EXISTENCE_BATCH_MAX } from '../path-existence-batch' import { z } from 'zod' import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' import { FileOpen, WorktreeSelector } from './files-target-params' export const FilePathsExist = WorktreeSelector.extend({ @@ -41,7 +42,12 @@ export const ResolveTerminalPath = WorktreeSelector.extend({ .optional() }) -export const FileOpenDiff = FileOpen.extend({ +// Why: absent `navigation` (phones, older CLIs) keeps the original host switch; 'caller'/'clients' open without moving it. +export const FileOpenTab = FileOpen.extend({ + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() +}) + +export const FileOpenDiff = FileOpenTab.extend({ staged: z.boolean().optional() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 45946679ab7..87eeae77024 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -171,6 +171,7 @@ import { DocPreviewFileRead, FileListAll, FileOpenDiff, + FileOpenTab, FilePathSearch, FilePathsExist, FileReadChunk, @@ -762,7 +763,7 @@ export const RPC_PARAMS_BY_METHOD = { 'files.list': WorktreeSelector, 'files.listAll': FileListAll, 'files.listMarkdownDocuments': WorktreeSelector, - 'files.open': FileOpen, + 'files.open': FileOpenTab, 'files.openDiff': FileOpenDiff, 'files.pathsExist': FilePathsExist, 'files.read': FileOpen,