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
3 changes: 3 additions & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const loadingStore = createRepositoryStore({
kind: 'unavailable',
message: 'No Repository is loaded.',
}),
mutateFiles: async () => {
throw new Error('File mutations are unavailable while loading.');
},
searchBranches: async () => ({ refsRevision: 0, candidates: [] }),
switchBranch: async () => {
throw new Error('Branch switching is unavailable while loading.');
Expand Down
33 changes: 33 additions & 0 deletions apps/ui/src/ChangeGroups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ export function ChangeGroups({
worktree,
selectedFileId,
onSelect,
onMutate,
}: {
readonly worktree: WorktreeOverviewSnapshot;
readonly selectedFileId: FileId | null;
readonly onSelect: (fileId: FileId) => void;
readonly onMutate: (
kind: 'stage' | 'unstage',
fileIds: readonly FileId[],
) => void;
}) {
if (worktree.changes.length === 0) {
return <p>No Changed Files in this Worktree.</p>;
Expand All @@ -33,6 +38,20 @@ export function ChangeGroups({
<h4>
{group.label} <span>{changes.length}</span>
</h4>
{group.kind === 'conflict' ? null : (
<button
aria-label={`${group.kind === 'staged_change' ? 'Unstage' : 'Stage'} all ${group.label}`}
type="button"
onClick={() =>
onMutate(
group.kind === 'staged_change' ? 'unstage' : 'stage',
changes.map(({ fileId }) => fileId),
)
}
>
{group.kind === 'staged_change' ? 'Unstage all' : 'Stage all'}
</button>
)}
<ul>
{changes.map((change) => (
<li key={change.fileId}>
Expand All @@ -47,6 +66,20 @@ export function ChangeGroups({
<small>renamed from {change.previousDisplayPath}</small>
)}
</button>
{group.kind === 'conflict' ? null : (
<button
aria-label={`${group.kind === 'staged_change' ? 'Unstage' : 'Stage'} ${change.displayPath}`}
type="button"
onClick={() =>
onMutate(
group.kind === 'staged_change' ? 'unstage' : 'stage',
[change.fileId],
)
}
>
{group.kind === 'staged_change' ? 'Unstage' : 'Stage'}
</button>
)}
</li>
))}
</ul>
Expand Down
42 changes: 42 additions & 0 deletions apps/ui/src/RepositoryOverview.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ describe('Repository overview interactions', () => {
expect(container.textContent).toContain('Unified');
});

it('offers file and group Stage and Unstage actions', async () => {
const fixture = createOverviewFixture('changed-worktree');
const mutations: unknown[] = [];
const store = createRepositoryStore({
...fixture.source,
async mutateFiles(request: unknown) {
mutations.push(request);
return {
kind: 'succeeded' as const,
operationId: operationIdSchema.parse(
'operation_00000000000000000000000000000002',
),
result: { kind: 'files' as const, affectedCount: 2 },
};
},
});
act(() => root.render(<App store={store} />));

expect(button('Unstage README.md')).toBeDefined();
expect(button('Stage src/app.ts')).toBeDefined();
expect(button('Stage notes.txt')).toBeDefined();
expect(button('Unstage all Staged Changes')).toBeDefined();
expect(button('Stage all Changes')).toBeDefined();
expect(button('Stage all Untracked Files')).toBeDefined();

await act(async () => button('Stage all Changes').click());

const source = fixture.source.getSnapshot();
if (source.kind !== 'repository') throw new Error('Expected Repository');
const worktree = source.snapshot.worktrees[0]!;
expect(mutations).toEqual([
{
kind: 'stage',
worktreeId: worktree.worktreeId,
expectedWorktreeRevision: worktree.worktreeRevision,
fileIds: worktree.changes
.filter(({ kind }) => kind === 'change')
.map(({ fileId }) => fileId),
},
]);
});

it('shows truthful metadata when Diff content cannot be rendered', async () => {
const fixture = createOverviewFixture('changed-worktree');
const store = createRepositoryStore({
Expand Down
24 changes: 24 additions & 0 deletions apps/ui/src/RepositoryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,31 @@ export function RepositoryOverview({
worktree={selected}
selectedFileId={state.selectedFileId}
onSelect={(fileId) => store.selectFile(fileId)}
onMutate={(kind, fileIds) => store.mutateFiles(kind, fileIds)}
/>
{state.fileMutationResult === null ? null : (
<section aria-live="polite" role="status">
<h4>File operation result</h4>
{'message' in state.fileMutationResult ? (
<p>{state.fileMutationResult.message}</p>
) : (
<p>Changed Files updated.</p>
)}
{'effects' in state.fileMutationResult &&
state.fileMutationResult.effects !== undefined ? (
<ul>
{state.fileMutationResult.effects.map((effect) => (
<li key={effect.label}>
{effect.label} —{' '}
{effect.kind === 'succeeded'
? 'Succeeded'
: `Failed: ${effect.message}`}
</li>
))}
</ul>
) : null}
</section>
)}
</section>
<section>
<h3>Diff</h3>
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/overview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ function createMutableFixture(
message: 'Native actions are not installed in this fixture.',
};
},
async mutateFiles() {
throw new Error('File mutations are not configured for this fixture.');
},
async searchBranches() {
return { refsRevision: 0, candidates: [] };
},
Expand Down
21 changes: 21 additions & 0 deletions apps/ui/src/protocol-repository-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,27 @@ export function createProtocolRepositorySource(
if (!response.ok) throw new Error('Native action request failed.');
return nativeActionResultSchema.parse(await response.json());
},
async mutateFiles(request) {
const command = { ...request } satisfies ProductCommand;
const receipt = await submitCommand(
fetcher,
options.sessionUrl,
commandEnvelopeSchema.parse({
clientCommandId: createClientCommandId(),
command,
}),
);
if (!operationRecoveryAvailable) {
throw new Error('File mutation recovery is unavailable.');
}
const result = await recoverOperation(
fetcher,
options.sessionUrl,
receipt.operationId,
);
await requestSnapshot();
return result;
},
async searchBranches(worktreeId, query) {
const response = await protocolPost(
fetcher,
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/repository-overview-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ export interface RepositoryOverviewSource {
requestNativeAction(
request: NativeActionRequest,
): Promise<NativeActionResult>;
mutateFiles(request: {
readonly kind: 'stage' | 'unstage';
readonly worktreeId: ProtocolWorktree['worktreeId'];
readonly expectedWorktreeRevision: number;
readonly fileIds: readonly FileId[];
}): Promise<OperationResult>;
searchBranches(
worktreeId: ProtocolWorktree['worktreeId'],
query: string,
Expand Down
54 changes: 54 additions & 0 deletions apps/ui/src/repository-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fileIdSchema, operationIdSchema } from '@codex-git/protocol';
import { describe, expect, it, vi } from 'vitest';

import { createOverviewFixture } from './overview-fixtures.js';
Expand Down Expand Up @@ -79,4 +80,57 @@ describe('RepositoryStore lifecycle', () => {
);
}
});

it('follows a successful file mutation into its new Change Group', async () => {
const fixture = createOverviewFixture('changed-worktree');
const before = fixture.source.getSnapshot();
if (before.kind !== 'repository') throw new Error('Expected Repository');
const worktree = before.snapshot.worktrees[0]!;
const changed = worktree.changes.find(({ kind }) => kind === 'change')!;
const stagedFileId = fileIdSchema.parse(
'file_0000000000000000000000000000000a',
);
const source: RepositoryOverviewSource = {
...fixture.source,
async mutateFiles() {
fixture.publish({
kind: 'repository',
snapshot: {
...before.snapshot,
repositoryRevision: before.snapshot.repositoryRevision + 1,
worktrees: [
{
...worktree,
worktreeRevision: worktree.worktreeRevision + 1,
changes: worktree.changes.map((change) =>
change.fileId === changed.fileId
? {
...change,
fileId: stagedFileId,
kind: 'staged_change' as const,
baseline: 'head_to_index' as const,
}
: change,
),
},
],
},
});
return {
kind: 'succeeded',
operationId: operationIdSchema.parse(
'operation_00000000000000000000000000000003',
),
result: { kind: 'files', affectedCount: 1 },
};
},
};
const store = createRepositoryStore(source);
store.selectFile(changed.fileId);

store.mutateFiles('stage', [changed.fileId]);
await Promise.resolve();

expect(store.getSnapshot().selectedFileId).toBe(stagedFileId);
});
});
60 changes: 57 additions & 3 deletions apps/ui/src/repository-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
FileId,
NativeActionRequest,
NativeActionResult,
OperationResult,
RefId,
WorktreeId,
} from '@codex-git/protocol';
Expand Down Expand Up @@ -52,6 +53,7 @@ export interface RepositoryStoreSnapshot {
readonly selectionNotice: string | null;
readonly focusRecoveryRevision: number;
readonly branchPicker: BranchPickerState;
readonly fileMutationResult: OperationResult | null;
}

export interface RepositoryStore {
Expand All @@ -70,6 +72,7 @@ export interface RepositoryStore {
requestNativeAction(
request: NativeActionRequest,
): Promise<NativeActionResult>;
mutateFiles(kind: 'stage' | 'unstage', fileIds: readonly FileId[]): void;
openBranchPicker(): void;
closeBranchPicker(): void;
setBranchQuery(query: string): void;
Expand All @@ -94,6 +97,10 @@ export function createRepositoryStore(
let focusRecoveryRevision = 0;
let branchPicker: BranchPickerState = { kind: 'closed' };
let branchRequestGeneration = 0;
let fileMutationResult: OperationResult | null = null;
let fileFollow:
| { readonly displayPath: string; readonly kind: 'stage' | 'unstage' }
| undefined;
let storeSnapshot = buildSnapshot();
let disposed = false;

Expand Down Expand Up @@ -134,10 +141,22 @@ export function createRepositoryStore(
selectedFileId !== null &&
!selected.changes.some(({ fileId }) => fileId === selectedFileId)
) {
selectedFileId = null;
const followed =
fileFollow === undefined
? undefined
: selected.changes.find(
({ displayPath, kind }) =>
displayPath === fileFollow?.displayPath &&
(fileFollow.kind === 'stage'
? kind === 'staged_change'
: kind === 'change' || kind === 'untracked'),
);
selectedFileId = followed?.fileId ?? null;
clearDiff();
selectionNotice =
'Changed Files were refreshed; the previous file selection was cleared.';
selectionNotice = followed
? `${followed.displayPath} moved to its new Change Group.`
: 'Changed Files were refreshed; the previous file selection was cleared.';
fileFollow = undefined;
} else {
selectionNotice = null;
}
Expand Down Expand Up @@ -239,6 +258,40 @@ export function createRepositoryStore(
message: 'The Repository view is no longer active.',
})
: source.requestNativeAction(request),
mutateFiles(kind, fileIds) {
if (disposed || fileIds.length === 0 || selectedWorktreeId === null) {
return;
}
const worktree = findWorktree(sourceState, selectedWorktreeId);
if (worktree === null) return;
const selectedChange = worktree.changes.find(
({ fileId }) => fileId === selectedFileId && fileIds.includes(fileId),
);
fileFollow =
selectedChange === undefined
? undefined
: { displayPath: selectedChange.displayPath, kind };
void source
.mutateFiles({
kind,
worktreeId: worktree.worktreeId,
expectedWorktreeRevision: worktree.worktreeRevision,
fileIds,
})
.then((result) => {
if (disposed) return;
fileMutationResult = result;
emit();
})
.catch(() => {
if (disposed) return;
selectionNotice = 'The file mutation could not be submitted.';
emit();
})
.finally(() => {
fileFollow = undefined;
});
},
openBranchPicker() {
if (disposed || selectedWorktreeId === null) return;
void loadBranches('');
Expand Down Expand Up @@ -365,6 +418,7 @@ export function createRepositoryStore(
selectionNotice,
focusRecoveryRevision,
branchPicker,
fileMutationResult,
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/protocol/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export const operationRecoveryRequestSchema = z.strictObject({
const operationFailureEffectSchema = z.strictObject({
label: z.string().min(1).max(256),
kind: z.literal('failed_known'),
code: operationFailureCodeSchema,
code: z.union([
operationFailureCodeSchema,
z.enum(['index_locked', 'precondition_failed', 'stale']),
]),
message: messageSchema,
});

Expand Down
Loading