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
6 changes: 6 additions & 0 deletions apps/launcher/src/repository-protocol-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ function worktreeStatus(source: PublishedWorktreeSnapshot) {
: 'The Worktree status is unavailable.',
};
}
if (source.status.inProgressOperation !== undefined) {
return {
kind: 'in_progress' as const,
operation: source.status.inProgressOperation,
};
}
if (source.status.clean) return { kind: 'clean' as const };
return {
kind: 'changed' as const,
Expand Down
5 changes: 5 additions & 0 deletions apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export async function startStandaloneRuntime(
repositorySession === undefined || options.projectPath === undefined
? undefined
: {
branchSearch: (request) =>
repositorySession!.searchBranches(request),
commands: (request) => repositorySession!.dispatch(request),
operationRecovery: (operationId) =>
repositorySession!.recoverOperation(operationId),
snapshot: async () =>
toProtocolRepositorySnapshot(
await repositorySession!.requestRefresh(),
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const loadingStore = createRepositoryStore({
subscribe: () => () => undefined,
requestRefresh: () => undefined,
requestFetch: () => undefined,
searchBranches: async () => ({ refsRevision: 0, candidates: [] }),
switchBranch: async () => {
throw new Error('Branch switching is unavailable while loading.');
},
});

export function App({
Expand Down
129 changes: 129 additions & 0 deletions apps/ui/src/RepositoryOverview.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { operationIdSchema, refIdSchema } from '@codex-git/protocol';

import { App } from './overview.js';
import { createOverviewFixture } from './overview-fixtures.js';
Expand Down Expand Up @@ -381,6 +382,134 @@ describe('Repository overview interactions', () => {
);
});

it('separates Branch groups, disables occupied Local Branches, and navigates to their Worktree', async () => {
const fixture = createOverviewFixture('many-worktrees');
const fixtureState = fixture.source.getSnapshot();
const occupiedWorktree =
fixtureState.kind === 'repository'
? fixtureState.snapshot.worktrees.find(
({ displayName }) => displayName === 'agent-alpha',
)
: undefined;
if (occupiedWorktree === undefined) throw new Error('Missing Worktree');
const source = {
...fixture.source,
async searchBranches() {
return {
refsRevision: 2,
candidates: [
{
refId: refIdSchema.parse('ref_0123456789abcdef0123456789abcdef'),
kind: 'local' as const,
displayName: 'available',
occupiedBy: null,
},
{
refId: refIdSchema.parse('ref_1123456789abcdef0123456789abcdef'),
kind: 'local' as const,
displayName: 'feat/agent-alpha',
occupiedBy: occupiedWorktree.worktreeId,
},
{
refId: refIdSchema.parse('ref_2123456789abcdef0123456789abcdef'),
kind: 'remote_tracking' as const,
displayName: 'origin/review-ready',
occupiedBy: null,
},
],
};
},
};
const store = createRepositoryStore(source);
act(() => root.render(<App store={store} />));

await act(async () => button('Switch Branch for codex-git').click());

expect(container.textContent).toContain('Local Branches');
expect(container.textContent).toContain('Remote-tracking Branches');
expect(button('Switch codex-git to feat/agent-alpha').disabled).toBe(true);
act(() => button('Go to Worktree occupying feat/agent-alpha').click());
expect(container.querySelector('#worktree-title')?.textContent).toBe(
'agent-alpha',
);
});

it('submits an exact Branch target and clears the picker after reconciled success', async () => {
const fixture = createOverviewFixture('one-worktree');
const targetRefId = refIdSchema.parse(
'ref_3123456789abcdef0123456789abcdef',
);
const switchBranch = vi.fn(async () => {
const current = fixture.source.getSnapshot();
if (current.kind !== 'repository') throw new Error('Expected Repository');
fixture.publish({
kind: 'repository',
snapshot: {
...current.snapshot,
repositoryRevision: current.snapshot.repositoryRevision + 1,
refsRevision: current.snapshot.refsRevision + 1,
worktrees: current.snapshot.worktrees.map((worktree) => ({
...worktree,
worktreeRevision: worktree.worktreeRevision + 1,
head: {
kind: 'local_branch' as const,
displayName: 'review-ready',
objectId: '1123456789abcdef0123456789abcdef01234567',
},
})),
},
});
return {
kind: 'succeeded' as const,
operationId: operationIdSchema.parse(
'operation_0123456789abcdef0123456789abcdef',
),
result: {
kind: 'branch_switch' as const,
displayName: 'review-ready',
},
};
});
const source = {
...fixture.source,
async searchBranches() {
return {
refsRevision: 1,
candidates: [
{
refId: targetRefId,
kind: 'local' as const,
displayName: 'review-ready',
occupiedBy: null,
},
],
};
},
switchBranch,
};
const store = createRepositoryStore(source);
act(() => root.render(<App store={store} />));
await act(async () => button('Switch Branch for codex-git').click());

await act(async () => button('Switch codex-git to review-ready').click());

expect(switchBranch).toHaveBeenCalledWith(
expect.objectContaining({
expectedRefsRevision: 1,
expectedWorktreeRevision: 1,
refId: targetRefId,
}),
);
expect(container.querySelector('#worktree-title')?.textContent).toBe(
'codex-git',
);
expect(container.textContent).toContain('Local Branch review-ready');
expect(container.textContent).not.toContain('Search cached Branches');
expect(container.querySelector('[role="status"]')?.textContent).toContain(
'Branch or HEAD changed',
);
});

it('does not dispose a caller-owned store when the overview unmounts', () => {
const fixture = createOverviewFixture('one-worktree');
const store = createRepositoryStore(fixture.source);
Expand Down
123 changes: 122 additions & 1 deletion apps/ui/src/RepositoryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function RepositoryOverview({
store.getSnapshot,
store.getSnapshot,
);
const branchPicker = state.branchPicker;
const worktreeButtons = useRef(new Map<string, HTMLButtonElement>());
const searchInput = useRef<HTMLInputElement>(null);
const worktreeTitle = useRef<HTMLHeadingElement>(null);
Expand Down Expand Up @@ -321,7 +322,8 @@ export function RepositoryOverview({
<button
aria-label={`Switch Branch for ${selected.displayName}`}
type="button"
disabled
disabled={!branchSwitchAllowed(selected, snapshot.operations)}
onClick={() => store.openBranchPicker()}
>
Switch Branch
</button>
Expand All @@ -333,6 +335,109 @@ export function RepositoryOverview({
Upstream actions
</button>
</div>
{branchPicker.kind === 'closed' ? null : (
<section aria-label={`Switch Branch for ${selected.displayName}`}>
<h3>Switch Branch</h3>
<label>
Search cached Branches
<input
type="search"
value={branchPicker.query}
onChange={(event) =>
store.setBranchQuery(event.currentTarget.value)
}
/>
</label>
<button type="button" onClick={() => store.closeBranchPicker()}>
Close
</button>
{branchPicker.kind === 'loading' ? (
<p role="status">Loading cached Branches…</p>
) : branchPicker.kind === 'failed' ? (
<p role="alert">{branchPicker.message}</p>
) : (
<>
{branchPicker.message === null ? null : (
<p role="alert">{branchPicker.message}</p>
)}
{(['local', 'remote_tracking'] as const).map((kind) => {
const branches = branchPicker.candidates.filter(
(candidate) => candidate.kind === kind,
);
return (
<section key={kind}>
<h4>
{kind === 'local'
? 'Local Branches'
: 'Remote-tracking Branches'}
</h4>
{branches.length === 0 ? (
<p>No matching Branches.</p>
) : (
<ul>
{branches.map((branch) => {
const occupiedElsewhere =
branch.occupiedBy !== null &&
branch.occupiedBy !== selected.worktreeId;
const occupyingWorktree =
branch.occupiedBy === null
? undefined
: snapshot.worktrees.find(
({ worktreeId }) =>
worktreeId === branch.occupiedBy,
);
return (
<li key={branch.refId}>
<button
aria-label={`Switch ${selected.displayName} to ${branch.displayName}`}
disabled={
occupiedElsewhere ||
branchPicker.switchingRefId !== null
}
type="button"
onClick={() =>
store.switchBranch(branch.refId)
}
>
{branch.displayName}
</button>
{branch.warning == null ? null : (
<span role="note">{branch.warning}</span>
)}
{!occupiedElsewhere ? null : (
<>
<span>
Occupied by{' '}
{occupyingWorktree?.displayName ??
'another Worktree'}
</span>
<button
aria-label={`Go to Worktree occupying ${branch.displayName}`}
type="button"
onClick={() => {
if (branch.occupiedBy !== null) {
store.selectWorktree(
branch.occupiedBy,
);
}
}}
>
Go to Worktree
</button>
</>
)}
</li>
);
})}
</ul>
)}
</section>
);
})}
</>
)}
</section>
)}
<label>
Commit Draft for {selected.displayName}
<textarea
Expand Down Expand Up @@ -360,6 +465,22 @@ export function RepositoryOverview({
);
}

function branchSwitchAllowed(
worktree: WorktreeOverviewSnapshot,
operations: RepositoryOverviewSnapshot['operations'],
): boolean {
return (
(worktree.availability === undefined ||
worktree.availability.kind === 'available') &&
worktree.freshness.kind === 'current' &&
worktree.status.kind === 'clean' &&
!operations.some(
({ category, phase }) =>
category === 'branch_switch' && phase !== 'terminal',
)
);
}

function compareWorktrees(
left: WorktreeOverviewSnapshot,
right: WorktreeOverviewSnapshot,
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/overview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ function createMutableFixture(
requestFetch(remoteId) {
fetch.push(remoteId);
},
async searchBranches() {
return { refsRevision: 0, candidates: [] };
},
async switchBranch() {
throw new Error('Branch switching is not configured for this fixture.');
},
},
publish(nextState) {
state = nextState;
Expand Down
Loading