diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index ac98f36..beb3539 100644 --- a/apps/launcher/src/codex-runtime.ts +++ b/apps/launcher/src/codex-runtime.ts @@ -30,9 +30,12 @@ export interface CodexRuntime extends StandaloneRuntime { export async function startCodexRuntime( options: CodexRuntimeOptions, ): Promise { - const standalone = await startStandaloneRuntime(options); let instance: DedicatedCodexInstance | null = null; let connection: HostConnection | null = null; + const standalone = await startStandaloneRuntime({ + ...options, + nativeHostConnection: () => connection, + }); let host: 'codex' | 'standalone' = 'standalone'; let closing = false; let monitor = Promise.resolve(); diff --git a/apps/launcher/src/repository-protocol-adapter.ts b/apps/launcher/src/repository-protocol-adapter.ts index 2c1d86b..e94a89b 100644 --- a/apps/launcher/src/repository-protocol-adapter.ts +++ b/apps/launcher/src/repository-protocol-adapter.ts @@ -1,4 +1,4 @@ -import { basename } from 'node:path'; +import { basename, isAbsolute } from 'node:path'; import type { RepositoryOpenResult, @@ -10,9 +10,24 @@ import { type RepositorySnapshotResult, } from '@codex-git/protocol'; +export interface HostNavigationContext { + readonly canonicalProjectPath: string | null; + readonly openCodexContext: boolean; + readonly openFileInCodex: boolean; + readonly taskId: string | null; +} + +const noHostNavigation: HostNavigationContext = { + canonicalProjectPath: null, + openCodexContext: false, + openFileInCodex: false, + taskId: null, +}; + export function toProtocolRepositorySnapshot( result: RepositoryOpenResult, projectPath: string, + hostNavigation: HostNavigationContext = noHostNavigation, ): RepositorySnapshotResult { if (result.kind === 'not_repository') { return { @@ -56,6 +71,7 @@ export function toProtocolRepositorySnapshot( candidate.upstream.kind === 'tracking' ? (remoteFetches.get(candidate.upstream.remoteId) ?? null) : lastSuccessfulFetchAt(source.fetch), + hostNavigation, ), ), remotes: source.remotes.map(({ remoteId, displayName, host }) => ({ @@ -72,7 +88,11 @@ export function toProtocolRepositorySnapshot( }); } -function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { +function worktree( + source: PublishedWorktreeSnapshot, + fetchedAt: string | null, + hostNavigation: HostNavigationContext, +) { const path = source.canonicalPath ?? source.displayPath; return { worktreeId: source.worktreeId, @@ -101,6 +121,14 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { }, indexTree: null, status: worktreeStatus(source), + provenance: + source.provenance.kind === 'codex_task' + ? { + kind: source.provenance.kind, + title: source.provenance.task.title, + status: source.provenance.task.status, + } + : source.provenance, upstream: upstream(source, fetchedAt), changes: source.changes.map( ({ @@ -116,15 +144,20 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) { baseline, displayPath, previousDisplayPath, - nativeTargets: [nativeFileTarget(source, nativeTargetId, fileId)], + nativeTargets: [ + nativeFileTarget(source, nativeTargetId, fileId, hostNavigation), + ], }), ), - nativeTargets: [ - { - targetId: source.nativeTargetId, - actions: ['open_terminal'] as const, - }, - ], + nativeTargets: + source.nativeTargetId === null + ? [] + : [ + { + targetId: source.nativeTargetId, + actions: worktreeNativeActions(source, hostNavigation), + }, + ], }; } @@ -132,6 +165,7 @@ function nativeFileTarget( worktree: PublishedWorktreeSnapshot, targetId: PublishedWorktreeSnapshot['changes'][number]['nativeTargetId'], fileId: PublishedWorktreeSnapshot['changes'][number]['fileId'], + hostNavigation: HostNavigationContext, ) { const change = worktree.changes.find( (candidate) => candidate.fileId === fileId, @@ -144,13 +178,78 @@ function nativeFileTarget( } return { targetId, - actions: - change.workingFilePresent && pathIsUtf8 - ? (['open_default_app', 'copy_relative_path'] as const) - : (['copy_relative_path'] as const), + actions: fileNativeActions( + worktree, + pathIsUtf8, + change.workingFilePresent, + hostNavigation, + ), }; } +function worktreeNativeActions( + worktree: PublishedWorktreeSnapshot, + hostNavigation: HostNavigationContext, +) { + const path = worktree.canonicalPath ?? worktree.displayPath; + const copyActions = [ + ...(isAbsolute(path) ? (['copy_absolute_path'] as const) : []), + 'copy_branch_or_sha' as const, + ]; + const hostActions = + hostNavigation.openCodexContext && + hostContextMatches(worktree, hostNavigation) + ? (['open_codex_context'] as const) + : []; + return worktree.canonicalPath !== null && + worktree.availability.kind === 'available' + ? ([ + 'open_terminal', + 'reveal_in_finder', + ...hostActions, + ...copyActions, + ] as const) + : [...hostActions, ...copyActions]; +} + +function fileNativeActions( + worktree: PublishedWorktreeSnapshot, + pathIsUtf8: boolean, + workingFilePresent: boolean, + hostNavigation: HostNavigationContext, +) { + if (!pathIsUtf8) return ['copy_relative_path'] as const; + const copyActions = [ + 'copy_relative_path' as const, + ...(worktree.canonicalPath === null + ? [] + : (['copy_absolute_path'] as const)), + ]; + return workingFilePresent && worktree.canonicalPath !== null + ? ([ + 'open_default_app', + 'reveal_in_finder', + ...(hostNavigation.openFileInCodex && + hostContextMatches(worktree, hostNavigation) + ? (['open_file_in_codex'] as const) + : []), + ...copyActions, + ] as const) + : copyActions; +} + +function hostContextMatches( + worktree: PublishedWorktreeSnapshot, + hostNavigation: HostNavigationContext, +): boolean { + return ( + (worktree.provenance.kind === 'codex_task' && + worktree.provenance.task.id === hostNavigation.taskId) || + (worktree.canonicalPath !== null && + worktree.canonicalPath === hostNavigation.canonicalProjectPath) + ); +} + function refresh(source: RefreshState) { if (source.kind === 'fresh') return { kind: 'current' as const }; return { kind: source.kind, message: source.error.message } as const; diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 12273d2..ae53d3e 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; import type { HostConnection } from '@codex-git/host-adapter'; import { createRepositoryEngine, + type CodexMetadataAdapter, type RepositorySession, } from '@codex-git/repository-engine'; import type { @@ -32,6 +33,8 @@ const uiConfigPath = fileURLToPath( ); export interface StandaloneRuntimeOptions { + readonly metadata?: CodexMetadataAdapter; + readonly nativeHostConnection?: () => HostConnection | null; readonly projectPath?: string; readonly surfacePort?: number; } @@ -65,9 +68,9 @@ export async function startStandaloneRuntime( try { if (options.projectPath !== undefined) { - repositorySession = await createRepositoryEngine().open( - options.projectPath as AbsolutePath, - ); + repositorySession = await createRepositoryEngine({ + metadata: options.metadata, + }).open(options.projectPath as AbsolutePath); const opened = await repositorySession.requestRefresh(); if (opened.kind === 'repository') { openedRepositoryId = opened.repository.repositoryId; @@ -81,14 +84,23 @@ export async function startStandaloneRuntime( : { diff: ({ fileId }) => repositorySession!.diff(fileId), nativeActions: (request) => - performNativeAction(repositorySession!, request), + performNativeAction( + repositorySession!, + request, + options.nativeHostConnection, + ), branchSearch: (request) => repositorySession!.searchBranches(request), - snapshot: async () => - toProtocolRepositorySnapshot( - await repositorySession!.requestRefresh(), + snapshot: async () => { + const result = await repositorySession!.requestRefresh(); + return toProtocolRepositorySnapshot( + result, options.projectPath!, - ), + await hostNavigationContext( + options.nativeHostConnection?.() ?? null, + ), + ); + }, commands: (request) => dispatchRepositoryCommand(repositorySession!, request), operationRecovery: (operationId) => @@ -192,17 +204,14 @@ async function dispatchRepositoryCommand( async function performNativeAction( session: RepositorySession, request: NativeActionRequest, + nativeHostConnection?: () => HostConnection | null, ): Promise { if (request.kind === 'open_terminal') { try { const target = await session.resolveWorktreeNativeTarget( request.targetId, ); - const metadata = await lstat(target.worktreePath); - if (metadata.isSymbolicLink() || !metadata.isDirectory()) { - throw new Error('The Worktree target is not a directory.'); - } - const resolvedWorktree = await realpath(target.worktreePath); + const resolvedWorktree = await revalidateWorktreePath(target); await execFileAsync( '/usr/bin/open', ['-a', 'Terminal', '--', resolvedWorktree], @@ -220,21 +229,72 @@ async function performNativeAction( } } try { - const target = await session.resolveFileNativeTarget(request.targetId); if (request.kind === 'copy_relative_path') { + const target = await session.resolveFileNativeTarget(request.targetId); return { kind: 'copy_text', text: target.relativePath }; } - if (request.kind !== 'open_default_app') { - return { - kind: 'unavailable', - message: 'This file action is not available yet.', - }; + if (request.kind === 'copy_branch_or_sha') { + const target = await session.resolveWorktreeNativeTarget( + request.targetId, + ); + return { kind: 'copy_text', text: target.branchOrSha }; } - if (!target.canOpen || target.absolutePath === null) { - throw new Error('The file cannot be opened from this change state.'); + if (request.kind === 'copy_absolute_path') { + const target = await resolvePathTarget(session, request.targetId); + if (target.kind === 'worktree') { + await revalidateWorktreePath(target); + } + return { kind: 'copy_text', text: target.absolutePath }; + } + if ( + request.kind === 'open_codex_context' || + request.kind === 'open_file_in_codex' + ) { + const host = nativeHostConnection?.() ?? null; + if (host === null) throw new Error('The Codex host is unavailable.'); + const capabilities = host.capabilities(); + const target = + request.kind === 'open_codex_context' + ? await session.resolveWorktreeNativeTarget(request.targetId) + : await session.resolveFileNativeTarget(request.targetId); + if ( + (request.kind === 'open_codex_context' + ? !capabilities.openCodexContext + : !capabilities.openFileInCodex) || + !(await hostContextMatches( + host, + target.worktreePath, + target.provenance, + )) + ) { + throw new Error('The Codex host cannot prove the exact target.'); + } + const hostResult = await host.perform({ + kind: + request.kind === 'open_codex_context' + ? 'open-codex-context' + : 'open-file-in-codex', + targetId: request.targetId, + }); + if (hostResult.status !== 'succeeded') { + throw new Error('The Codex host rejected the exact target.'); + } + return { kind: 'performed' }; + } + const target = await resolvePathTarget(session, request.targetId); + if (!target.canLaunch) { + throw new Error('The target cannot be opened from its current state.'); + } + if (target.kind === 'worktree') { + const path = await revalidateWorktreePath(target); + await execFileAsync('/usr/bin/open', ['-R', '--', path], { + timeout: 10_000, + windowsHide: true, + }); + return { kind: 'performed' }; } const metadata = await lstat(target.absolutePath); - if (metadata.isSymbolicLink()) { + if (request.kind === 'open_default_app' && metadata.isSymbolicLink()) { throw new Error('Symbolic links cannot be opened from change review.'); } const [resolvedWorktree, resolvedFile] = await Promise.all([ @@ -252,17 +312,117 @@ async function performNativeAction( ) { throw new Error('The file resolves outside its Worktree.'); } - await execFileAsync('/usr/bin/open', ['--', resolvedFile], { - timeout: 10_000, - windowsHide: true, - }); + await execFileAsync( + '/usr/bin/open', + request.kind === 'reveal_in_finder' + ? ['-R', '--', target.absolutePath] + : ['--', resolvedFile], + { + timeout: 10_000, + windowsHide: true, + }, + ); return { kind: 'performed' }; } catch { return { kind: 'unavailable', - message: 'The file is no longer available. Refresh and try again.', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', + }; + } +} + +async function hostNavigationContext(host: HostConnection | null) { + if (host === null) { + return { + canonicalProjectPath: null, + openCodexContext: false, + openFileInCodex: false, + taskId: null, + } as const; + } + const context = host.currentContext(); + return { + ...host.capabilities(), + canonicalProjectPath: + context.projectPath === null + ? null + : await realpath(context.projectPath).catch(() => null), + taskId: context.task?.id ?? null, + }; +} + +async function hostContextMatches( + host: HostConnection, + worktreePath: string, + provenance: import('@codex-git/repository-engine').WorktreeProvenance, +): Promise { + const before = host.currentContext(); + const canonicalProjectPath = + before.projectPath === null + ? null + : await realpath(before.projectPath).catch(() => null); + const current = host.currentContext(); + if ( + current.projectPath !== before.projectPath || + current.task?.id !== before.task?.id + ) { + return false; + } + return ( + (provenance.kind === 'codex_task' && + provenance.task.id === current.task?.id) || + canonicalProjectPath === worktreePath + ); +} + +type ResolvedPathTarget = + | { + readonly kind: 'file'; + readonly absolutePath: string; + readonly canLaunch: boolean; + readonly worktreePath: string; + } + | { + readonly kind: 'worktree'; + readonly absolutePath: string; + readonly canLaunch: boolean; + readonly worktreePath: string; + }; + +async function resolvePathTarget( + session: RepositorySession, + targetId: NativeActionRequest['targetId'], +): Promise { + try { + const file = await session.resolveFileNativeTarget(targetId); + if (file.absolutePath === null) throw new Error('No file path.'); + return { + kind: 'file', + absolutePath: file.absolutePath, + canLaunch: file.canOpen, + worktreePath: file.worktreePath, }; + } catch { + const worktree = await session.resolveWorktreeNativeTarget(targetId); + return { + kind: 'worktree', + absolutePath: worktree.absolutePath, + canLaunch: worktree.canLaunch, + worktreePath: worktree.worktreePath, + }; + } +} + +async function revalidateWorktreePath( + target: Pick, +): Promise { + if (!target.canLaunch) throw new Error('The Worktree is unavailable.'); + const resolved = await realpath(target.worktreePath); + if (resolved !== target.worktreePath) { + throw new Error('The Worktree moved before navigation.'); } + return resolved; } async function forwardRepositoryInvalidations( diff --git a/apps/server/src/protocol-dispatch.test.ts b/apps/server/src/protocol-dispatch.test.ts index a5c3b31..2119427 100644 --- a/apps/server/src/protocol-dispatch.test.ts +++ b/apps/server/src/protocol-dispatch.test.ts @@ -65,6 +65,7 @@ describe('protocol HTTP dispatch', () => { kind: 'unavailable', reason: 'token=fixture-unavailable-secret', }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'not-applicable', reason: 'The branch has no configured Upstream.', @@ -839,6 +840,7 @@ function nativeSnapshot( head: { kind: 'initial' }, indexTree: null, status: { kind: 'clean' }, + provenance: { kind: 'unclassified' }, upstream: { kind: 'not-applicable', reason: 'The branch has no configured Upstream.', diff --git a/apps/ui/src/DiffReview.tsx b/apps/ui/src/DiffReview.tsx index b636110..c837362 100644 --- a/apps/ui/src/DiffReview.tsx +++ b/apps/ui/src/DiffReview.tsx @@ -9,6 +9,10 @@ import type { import type { WorktreeOverviewSnapshot } from './repository-overview-model.js'; import type { DiffLoadState } from './repository-store.js'; import { presentSideBySide } from './diff-presentation.js'; +import { + nativeActionLabel, + performPresentedNativeAction, +} from './native-action-presentation.js'; export function DiffReview({ worktree, @@ -96,15 +100,15 @@ export function DiffReview({ target.actions .filter( (kind) => - kind === 'open_default_app' || kind === 'copy_relative_path', + kind === 'open_default_app' || + kind === 'open_file_in_codex' || + kind === 'reveal_in_finder' || + kind === 'copy_relative_path' || + kind === 'copy_absolute_path', ) .map((kind) => ( )), )} @@ -135,25 +137,23 @@ async function performNativeAction( run: (request: NativeActionRequest) => Promise, publish: (message: string) => void, ): Promise { - try { - const result = await run(request); - if (result.kind === 'unavailable') { - publish(result.message); - return; - } - if (result.kind === 'performed') { - publish('Opened the current Changed File.'); - return; - } - if (globalThis.navigator.clipboard === undefined) { - publish(`Relative path: ${result.text}`); - return; - } - await globalThis.navigator.clipboard.writeText(result.text); - publish('Copied the relative path.'); - } catch { - publish('The file action could not be completed. Refresh and try again.'); - } + return performPresentedNativeAction(request, run, publish, { + performed: (current) => + current.kind === 'reveal_in_finder' + ? 'Revealed the current Changed File.' + : current.kind === 'open_file_in_codex' + ? 'Opened the current Changed File in Codex.' + : 'Opened the current Changed File.', + copyFallback: (current, text) => + current.kind === 'copy_absolute_path' + ? `Absolute path: ${text}` + : `Relative path: ${text}`, + copied: (current) => + current.kind === 'copy_absolute_path' + ? 'Copied the absolute path.' + : 'Copied the relative path.', + failed: 'The file action could not be completed. Refresh and try again.', + }); } function DiffContent({ diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index 89ca75a..936ba3e 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -209,10 +209,42 @@ describe('Repository overview interactions', () => { expect(container.textContent).toContain('Binary file · 4,096 bytes'); expect(container.querySelector('pre')).toBeNull(); expect(button('Open in Default App')).toBeDefined(); + expect(button('Reveal in Finder')).toBeDefined(); + expect(button('Copy Absolute Path')).toBeDefined(); await act(async () => button('Copy Relative Path').click()); expect(container.textContent).toContain('Relative path: README.md'); }); + it('reports exact Worktree copy results and safe navigation fallback', async () => { + const fixture = createOverviewFixture('one-worktree'); + const store = createRepositoryStore({ + ...fixture.source, + async requestNativeAction(request) { + return request.kind === 'copy_absolute_path' + ? { + kind: 'copy_text', + text: '/Users/leyoonafr/Projects/codex-git', + } + : { + kind: 'unavailable', + message: + 'The exact target is no longer available. Refresh or use a safe copy action.', + }; + }, + }); + act(() => root.render()); + + await act(async () => button('Copy Absolute Path for codex-git').click()); + expect(container.textContent).toContain( + 'Value: /Users/leyoonafr/Projects/codex-git', + ); + + await act(async () => button('Reveal codex-git in Finder').click()); + expect(container.textContent).toContain( + 'The exact target is no longer available. Refresh or use a safe copy action.', + ); + }); + it('shows Conflict index stages in the default side-by-side review', async () => { const fixture = createOverviewFixture('changed-worktree'); const store = createRepositoryStore({ diff --git a/apps/ui/src/RepositoryOverview.test.tsx b/apps/ui/src/RepositoryOverview.test.tsx index 5790165..942f950 100644 --- a/apps/ui/src/RepositoryOverview.test.tsx +++ b/apps/ui/src/RepositoryOverview.test.tsx @@ -21,11 +21,49 @@ describe('Repository overview', () => { expect(markup).toContain('
Upstream freshness
'); expect(markup).toContain('Cached from Fetch Aug 29, 2026, 2:03 PM'); expect(markup).toContain('Clean'); + expect(markup).toContain( + '
Provenance
Unclassified Worktree
', + ); + expect(markup).toContain('Open codex-git in Terminal'); + expect(markup).toContain('Reveal codex-git in Finder'); + expect(markup).toContain('Copy Absolute Path for codex-git'); + expect(markup).toContain('Copy Branch or SHA for codex-git'); expect(markup).toContain('Refresh codex-git locally'); expect(markup).toContain('Fetch origin for codex-git'); expect(markup).not.toContain('Search Worktrees'); }); + it.each([ + [ + { kind: 'codex_task', title: 'Exact task', status: 'active' } as const, + 'Codex Task Worktree — Exact task (active)', + ], + [{ kind: 'scheduled' } as const, 'Scheduled Worktree'], + [{ kind: 'permanent' } as const, 'Permanent Worktree'], + [{ kind: 'external' } as const, 'External Worktree'], + [{ kind: 'unclassified' } as const, 'Unclassified Worktree'], + ])('uses the canonical provenance term for %s', (provenance, label) => { + const fixture = createOverviewFixture('one-worktree'); + const source = fixture.source.getSnapshot(); + if (source.kind !== 'repository') throw new Error('Expected Repository'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...source.snapshot, + worktrees: source.snapshot.worktrees.map((worktree) => ({ + ...worktree, + provenance, + })), + }, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(`
Provenance
${label}
`); + }); + it('keeps Main first and the remaining Worktrees stable when status changes', () => { const fixture = createOverviewFixture('many-worktrees'); const store = createRepositoryStore(fixture.source); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 11a6f4a..c5d54b0 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -1,4 +1,9 @@ -import { useEffect, useRef, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import type { + NativeActionRequest, + NativeActionResult, +} from '@codex-git/protocol'; import type { RepositoryOverviewSnapshot, @@ -10,6 +15,11 @@ import type { } from './repository-store.js'; import { ChangeGroups } from './ChangeGroups.js'; import { DiffReview } from './DiffReview.js'; +import { + nativeActionLabel, + performPresentedNativeAction, + worktreeNativeActionLabel, +} from './native-action-presentation.js'; export function RepositoryOverview({ store, @@ -26,6 +36,10 @@ export function RepositoryOverview({ const searchInput = useRef(null); const worktreeTitle = useRef(null); const handledFocusRecovery = useRef(0); + const [nativeActionStatus, setNativeActionStatus] = useState<{ + readonly worktreeId: string; + readonly message: string; + } | null>(null); const orderedWorktrees = state.source.kind === 'repository' ? [...state.source.snapshot.worktrees].sort(compareWorktrees) @@ -348,6 +362,10 @@ export function RepositoryOverview({
Status
{statusLabel(selected.status)}
+
+
Provenance
+
{provenanceLabel(selected.provenance)}
+
Worktree observation
{refreshLabel(selected.freshness)}
@@ -362,6 +380,31 @@ export function RepositoryOverview({ )}
+ {selected.nativeTargets.flatMap((target) => + target.actions.map((kind) => ( + + )), + )}