Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/launcher/src/repository-protocol-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ function worktree(source: PublishedWorktreeSnapshot, fetchedAt: string | null) {
nativeTargets: [nativeFileTarget(source, nativeTargetId, fileId)],
}),
),
nativeTargets: [],
nativeTargets: [
{
targetId: source.nativeTargetId,
actions: ['open_terminal'] as const,
},
],
};
}

Expand Down
37 changes: 34 additions & 3 deletions apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function startStandaloneRuntime(
: {
diff: ({ fileId }) => repositorySession!.diff(fileId),
nativeActions: (request) =>
performFileNativeAction(repositorySession!, request),
performNativeAction(repositorySession!, request),
branchSearch: (request) =>
repositorySession!.searchBranches(request),
snapshot: async () =>
Expand Down Expand Up @@ -156,7 +156,12 @@ async function dispatchRepositoryCommand(
session: RepositorySession,
request: CommandEnvelope,
): Promise<OperationReceipt> {
if (request.command.kind === 'switch_branch') {
if (
request.command.kind === 'switch_branch' ||
request.command.kind === 'pull' ||
request.command.kind === 'push' ||
request.command.kind === 'publish'
) {
return session.dispatch(request);
}
if (
Expand Down Expand Up @@ -184,10 +189,36 @@ async function dispatchRepositoryCommand(
};
}

async function performFileNativeAction(
async function performNativeAction(
session: RepositorySession,
request: NativeActionRequest,
): Promise<NativeActionResult> {
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);
await execFileAsync(
'/usr/bin/open',
['-a', 'Terminal', '--', resolvedWorktree],
{
timeout: 10_000,
windowsHide: true,
},
);
return { kind: 'performed' };
} catch {
return {
kind: 'unavailable',
message: 'The Worktree is no longer available. Refresh and try again.',
};
}
}
try {
const target = await session.resolveFileNativeTarget(request.targetId);
if (request.kind === 'copy_relative_path') {
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const loadingStore = createRepositoryStore({
switchBranch: async () => {
throw new Error('Branch switching is unavailable while loading.');
},
requestRemoteOperation: async () => {
throw new Error('Remote operations are unavailable while loading.');
},
});

export function App({
Expand Down
86 changes: 86 additions & 0 deletions apps/ui/src/RepositoryOverview.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,92 @@ describe('Repository overview interactions', () => {
container.remove();
});

it('confirms the exact Remote and same-name target before Publish', async () => {
const fixture = createOverviewFixture('one-worktree');
const current = fixture.source.getSnapshot();
if (current.kind !== 'repository') throw new Error('Expected Repository');
fixture.publish({
kind: 'repository',
snapshot: {
...current.snapshot,
worktrees: current.snapshot.worktrees.map((worktree) => ({
...worktree,
upstream: {
kind: 'unpublished' as const,
remoteName: null,
fetchedAt: null,
},
})),
},
});
const requestRemoteOperation = vi.fn(async () => ({
kind: 'succeeded' as const,
operationId: operationIdSchema.parse(
'operation_00000000000000000000000000000002',
),
result: {
kind: 'remote' as const,
summary: 'Published main to origin.',
},
}));
const confirm = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
const store = createRepositoryStore({
...fixture.source,
requestRemoteOperation,
});
act(() => root.render(<App store={store} />));

await act(async () => button('Publish main to origin/main').click());

expect(confirm).toHaveBeenCalledWith(
'Publish Local Branch main to exact target origin/main?',
);
expect(requestRemoteOperation).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'publish',
remoteId: current.snapshot.remotes[0]!.remoteId,
}),
);
expect(container.textContent).toContain('Published main to origin.');
});

it('routes a diverged Upstream to the exact selected Worktree Terminal target', async () => {
const fixture = createOverviewFixture('one-worktree');
const current = fixture.source.getSnapshot();
if (current.kind !== 'repository') throw new Error('Expected Repository');
fixture.publish({
kind: 'repository',
snapshot: {
...current.snapshot,
worktrees: current.snapshot.worktrees.map((worktree) => ({
...worktree,
upstream:
worktree.upstream.kind === 'tracking'
? { ...worktree.upstream, ahead: 1, behind: 1 }
: worktree.upstream,
})),
},
});
const requestNativeAction = vi.fn(async () => ({
kind: 'performed' as const,
}));
const store = createRepositoryStore({
...fixture.source,
requestNativeAction,
});
act(() => root.render(<App store={store} />));

expect(container.textContent).toContain(
'Open the exact selected Worktree in Terminal to Merge or Rebase explicitly.',
);
await act(async () => button('Open codex-git in Terminal').click());

expect(requestNativeAction).toHaveBeenCalledWith({
kind: 'open_terminal',
targetId: 'native_00000000000000000000000000000010',
});
});

it('reviews Changed Files by group and navigates the current Worktree', async () => {
const fixture = createOverviewFixture('changed-worktree');
const store = createRepositoryStore(fixture.source);
Expand Down
188 changes: 180 additions & 8 deletions apps/ui/src/RepositoryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type {
RepositoryOverviewSnapshot,
WorktreeOverviewSnapshot,
} from './repository-overview-model.js';
import type { RepositoryStore } from './repository-store.js';
import type {
RemoteOperationState,
RepositoryStore,
} from './repository-store.js';
import { ChangeGroups } from './ChangeGroups.js';
import { DiffReview } from './DiffReview.js';

Expand Down Expand Up @@ -106,6 +109,11 @@ export function RepositoryOverview({
const selected = snapshot.worktrees.find(
(worktree) => worktree.worktreeId === state.selectedWorktreeId,
);
const selectedBranchName =
selected?.head.kind === 'local_branch' ? selected.head.displayName : null;
const selectedTerminalTarget = selected?.nativeTargets.find(({ actions }) =>
actions.includes('open_terminal'),
);
const unavailableCount = snapshot.worktrees.filter(
(worktree) =>
worktree.availability?.kind === 'unavailable' ||
Expand Down Expand Up @@ -362,14 +370,100 @@ export function RepositoryOverview({
>
Switch Branch
</button>
<button
aria-label={`Upstream actions for ${selected.displayName}`}
type="button"
disabled
>
Upstream actions
</button>
{selected.upstream.kind === 'tracking' ? (
<>
<button
aria-label={`Pull ${selected.upstream.displayName} into ${selected.displayName}`}
type="button"
disabled={
!pullAllowed(selected, snapshot.operations) ||
state.remoteOperation.kind === 'running'
}
onClick={() => store.pull()}
>
Pull {selected.upstream.displayName}
</button>
<button
aria-label={`Push ${selected.displayName} to ${selected.upstream.displayName}`}
type="button"
disabled={
!pushAllowed(selected, snapshot.operations) ||
state.remoteOperation.kind === 'running'
}
title={
selected.status.kind === 'changed'
? 'Only committed history is pushed; uncommitted content stays local.'
: undefined
}
onClick={() => store.push()}
>
Push {selected.upstream.displayName}
</button>
{selected.status.kind === 'changed' ? (
<small>
Uncommitted content stays local and is not included in
Push.
</small>
) : null}
</>
) : selected.upstream.kind === 'unpublished' &&
selectedBranchName !== null ? (
snapshot.remotes.map((remote) => {
const target = `${remote.displayName}/${selectedBranchName}`;
return (
<button
aria-label={`Publish ${selectedBranchName} to ${target}`}
type="button"
key={remote.remoteId}
disabled={
!publishAllowed(selected, snapshot.operations) ||
state.remoteOperation.kind === 'running'
}
onClick={() => {
if (
globalThis.confirm(
`Publish Local Branch ${selectedBranchName} to exact target ${target}?`,
)
) {
store.publish(remote.remoteId);
}
}}
>
Publish to {target}
</button>
);
})
) : null}
</div>
{selected.upstream.kind === 'tracking' &&
(selected.upstream.ahead ?? 0) > 0 &&
(selected.upstream.behind ?? 0) > 0 ? (
<section aria-label="Diverged Upstream guidance">
<p>
This Local Branch and its Upstream diverged. Open the exact
selected Worktree in Terminal to Merge or Rebase explicitly.
</p>
{selectedTerminalTarget === undefined ? null : (
<button
aria-label={`Open ${selected.displayName} in Terminal`}
type="button"
onClick={() => {
void store.requestNativeAction({
kind: 'open_terminal',
targetId: selectedTerminalTarget.targetId,
});
}}
>
Open {selected.displayName} in Terminal
</button>
)}
</section>
) : null}
{state.remoteOperation.kind === 'idle' ? null : (
<p aria-live="polite" role="status">
{remoteOperationLabel(state.remoteOperation)}
</p>
)}
{branchPicker.kind === 'closed' ? null : (
<section aria-label={`Switch Branch for ${selected.displayName}`}>
<h3>Switch Branch</h3>
Expand Down Expand Up @@ -550,6 +644,84 @@ function branchSwitchAllowed(
);
}

function pullAllowed(
worktree: WorktreeOverviewSnapshot,
operations: RepositoryOverviewSnapshot['operations'],
): boolean {
return (
worktree.head.kind === 'local_branch' &&
worktree.upstream.kind === 'tracking' &&
worktree.upstream.ahead === 0 &&
(worktree.upstream.behind ?? 0) > 0 &&
worktree.status.kind === 'clean' &&
worktree.freshness.kind === 'current' &&
!remoteOperationActive(operations)
);
}

function pushAllowed(
worktree: WorktreeOverviewSnapshot,
operations: RepositoryOverviewSnapshot['operations'],
): boolean {
return (
worktree.head.kind === 'local_branch' &&
worktree.upstream.kind === 'tracking' &&
worktree.upstream.behind === 0 &&
worktree.upstream.ahead !== null &&
worktree.freshness.kind === 'current' &&
statusAllowsRemoteWrite(worktree.status) &&
!remoteOperationActive(operations)
);
}

function publishAllowed(
worktree: WorktreeOverviewSnapshot,
operations: RepositoryOverviewSnapshot['operations'],
): boolean {
return (
worktree.head.kind === 'local_branch' &&
worktree.upstream.kind === 'unpublished' &&
worktree.freshness.kind === 'current' &&
statusAllowsRemoteWrite(worktree.status) &&
!remoteOperationActive(operations)
);
}

function statusAllowsRemoteWrite(status: WorktreeOverviewSnapshot['status']) {
return (
status.kind === 'clean' ||
(status.kind === 'changed' && status.conflictCount === 0)
);
}

function remoteOperationActive(
operations: RepositoryOverviewSnapshot['operations'],
) {
return operations.some(
({ category, phase }) =>
phase !== 'terminal' &&
(category === 'fetch' ||
category === 'pull' ||
category === 'push' ||
category === 'publish'),
);
}

function remoteOperationLabel(state: RemoteOperationState): string {
if (state.kind === 'idle') return '';
if (state.kind === 'running') {
return `${state.operation[0]!.toLocaleUpperCase()}${state.operation.slice(1)} in progress…`;
}
if (state.kind === 'failed') return state.message;
const result = state.result;
if (result.kind === 'succeeded') {
return result.result.kind === 'remote'
? result.result.summary
: 'The Remote is already up to date.';
}
return result.message;
}

function compareWorktrees(
left: WorktreeOverviewSnapshot,
right: WorktreeOverviewSnapshot,
Expand Down
Loading