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
41 changes: 40 additions & 1 deletion apps/launcher/src/repository-protocol-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,50 @@ function worktree(source: PublishedWorktreeSnapshot) {
indexTree: null,
status: worktreeStatus(source),
upstream: upstream(source),
changes: [],
changes: source.changes.map(
({
fileId,
kind,
baseline,
displayPath,
previousDisplayPath,
nativeTargetId,
}) => ({
fileId,
kind,
baseline,
displayPath,
previousDisplayPath,
nativeTargets: [nativeFileTarget(source, nativeTargetId, fileId)],
}),
),
nativeTargets: [],
};
}

function nativeFileTarget(
worktree: PublishedWorktreeSnapshot,
targetId: PublishedWorktreeSnapshot['changes'][number]['nativeTargetId'],
fileId: PublishedWorktreeSnapshot['changes'][number]['fileId'],
) {
const change = worktree.changes.find(
(candidate) => candidate.fileId === fileId,
)!;
let pathIsUtf8 = true;
try {
new TextDecoder('utf-8', { fatal: true }).decode(change.pathBytes);
} catch {
pathIsUtf8 = false;
}
return {
targetId,
actions:
change.workingFilePresent && pathIsUtf8
? (['open_default_app', 'copy_relative_path'] as const)
: (['copy_relative_path'] as const),
};
}

function refresh(source: RefreshState) {
if (source.kind === 'fresh') return { kind: 'current' as const };
return { kind: source.kind, message: source.error.message } as const;
Expand Down
75 changes: 73 additions & 2 deletions apps/launcher/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { execFile } from 'node:child_process';
import { lstat, realpath } from 'node:fs/promises';
import type { Server } from 'node:http';
import { isAbsolute, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import type { HostConnection } from '@codex-git/host-adapter';
import {
createRepositoryEngine,
type RepositorySession,
} from '@codex-git/repository-engine';
import type { AbsolutePath, RepositoryId } from '@codex-git/protocol';
import type {
AbsolutePath,
NativeActionRequest,
NativeActionResult,
RepositoryId,
} from '@codex-git/protocol';
import { startLoopbackServer, type LoopbackServer } from '@codex-git/server';
import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone';
import { createServer as createViteServer, type ViteDevServer } from 'vite';
Expand All @@ -15,6 +24,7 @@ import { protocolBootstrapPlugin } from './protocol-bootstrap.js';
import { toProtocolRepositorySnapshot } from './repository-protocol-adapter.js';

const loopbackHost = '127.0.0.1';
const execFileAsync = promisify(execFile);
const uiConfigPath = fileURLToPath(
new URL('../../ui/vite.config.ts', import.meta.url),
);
Expand Down Expand Up @@ -45,7 +55,7 @@ export async function startStandaloneRuntime(
await Promise.all([
hostConnection?.close(),
repositorySession?.close(),
surfaceServer?.close(),
closeSurfaceServer(surfaceServer),
protocolServer?.close(),
]);
await invalidationPump;
Expand All @@ -67,6 +77,9 @@ export async function startStandaloneRuntime(
repositorySession === undefined || options.projectPath === undefined
? undefined
: {
diff: ({ fileId }) => repositorySession!.diff(fileId),
nativeActions: (request) =>
performFileNativeAction(repositorySession!, request),
branchSearch: (request) =>
repositorySession!.searchBranches(request),
commands: (request) => repositorySession!.dispatch(request),
Expand Down Expand Up @@ -128,6 +141,64 @@ export async function startStandaloneRuntime(
}
}

async function closeSurfaceServer(
server: ViteDevServer | undefined,
): Promise<void> {
if (server === undefined) return;
await server.environments.client?.waitForRequestsIdle();
await server.close();
}

async function performFileNativeAction(
session: RepositorySession,
request: NativeActionRequest,
): Promise<NativeActionResult> {
try {
const target = await session.resolveFileNativeTarget(request.targetId);
if (request.kind === 'copy_relative_path') {
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 (!target.canOpen || target.absolutePath === null) {
throw new Error('The file cannot be opened from this change state.');
}
const metadata = await lstat(target.absolutePath);
if (metadata.isSymbolicLink()) {
throw new Error('Symbolic links cannot be opened from change review.');
}
const [resolvedWorktree, resolvedFile] = await Promise.all([
realpath(target.worktreePath),
realpath(target.absolutePath),
]);
const relativeResolvedPath = relative(resolvedWorktree, resolvedFile);
if (
relativeResolvedPath === '' ||
relativeResolvedPath === '..' ||
relativeResolvedPath.startsWith(
`..${process.platform === 'win32' ? '\\' : '/'}`,
) ||
isAbsolute(relativeResolvedPath)
) {
throw new Error('The file resolves outside its Worktree.');
}
await execFileAsync('/usr/bin/open', ['--', resolvedFile], {
timeout: 10_000,
windowsHide: true,
});
return { kind: 'performed' };
} catch {
return {
kind: 'unavailable',
message: 'The file is no longer available. Refresh and try again.',
};
}
}

async function forwardRepositoryInvalidations(
session: RepositorySession,
server: Pick<LoopbackServer, 'publish'>,
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/protocol-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe('protocol HTTP dispatch', () => {
changes: Array.from({ length: 2_000 }, (_, index) => ({
baseline: 'index_to_working_tree',
displayPath: 'x'.repeat(4_096),
previousDisplayPath: null,
fileId: `file_${index.toString(16).padStart(32, '0')}`,
kind: 'change',
nativeTargets: [],
Expand Down Expand Up @@ -187,6 +188,38 @@ describe('protocol HTTP dispatch', () => {
expect(handleDiff).not.toHaveBeenCalled();
});

it('rejects a stale Changed File target without exposing handler details', async () => {
const server = await startLoopbackServer({
allowedOrigins: ['null'],
handlers: {
diff: async () => {
const error = new Error('private path /projects/secret.txt');
Object.assign(error, { code: 'stale_target' });
throw error;
},
},
});
servers.push(server);

const response = await fetch(endpointUrl(server, 'diff'), {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({
fileId: 'file_0123456789abcdef0123456789abcdef',
}),
});

expect({ status: response.status, body: await response.json() }).toEqual({
status: 409,
body: {
error: {
code: 'stale_target',
message: 'The Changed File target is stale or unavailable.',
},
},
});
});

it('rejects malformed UTF-8 JSON bytes before calling a handler', async () => {
const searchBranches = vi.fn(() =>
branchSearchResultSchema.parse({ refsRevision: 1, candidates: [] }),
Expand Down
31 changes: 27 additions & 4 deletions apps/server/src/protocol-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,15 @@ export function createProtocolDispatcher(
if (!request.ok) {
return { status: 400, value: { error: request.error } };
}
return diffResponse(
request.value.fileId,
await handlers.diff(request.value),
);
try {
return diffResponse(
request.value.fileId,
await handlers.diff(request.value),
);
} catch (error) {
if (isStaleTargetError(error)) return staleDiffTargetResponse();
throw error;
}
}
if (endpoint === 'branches' && handlers.branchSearch !== undefined) {
const input = parseJsonBody(body);
Expand Down Expand Up @@ -365,6 +370,24 @@ function staleNativeTargetResponse(): ProtocolDispatchResponse {
};
}

function staleDiffTargetResponse(): ProtocolDispatchResponse {
return {
status: 409,
value: {
error: {
code: 'stale_target',
message: 'The Changed File target is stale or unavailable.',
},
},
};
}

function isStaleTargetError(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && error.code === 'stale_target'
);
}

function operationRecoveryResponse(
operationId: OperationId,
value: unknown,
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const loadingStore = createRepositoryStore({
subscribe: () => () => undefined,
requestRefresh: () => undefined,
requestFetch: () => undefined,
requestDiff: () => Promise.reject(new Error('No Repository is loaded.')),
requestNativeAction: () =>
Promise.resolve({
kind: 'unavailable',
message: 'No Repository is loaded.',
}),
searchBranches: async () => ({ refsRevision: 0, candidates: [] }),
switchBranch: async () => {
throw new Error('Branch switching is unavailable while loading.');
Expand Down
58 changes: 58 additions & 0 deletions apps/ui/src/ChangeGroups.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { FileId } from '@codex-git/protocol';

import type { WorktreeOverviewSnapshot } from './repository-overview-model.js';

const groups = [
{ kind: 'conflict', label: 'Conflicts', action: 'conflict' },
{ kind: 'staged_change', label: 'Staged Changes', action: 'staged' },
{ kind: 'change', label: 'Changes', action: 'changed' },
{ kind: 'untracked', label: 'Untracked Files', action: 'untracked' },
] as const;

export function ChangeGroups({
worktree,
selectedFileId,
onSelect,
}: {
readonly worktree: WorktreeOverviewSnapshot;
readonly selectedFileId: FileId | null;
readonly onSelect: (fileId: FileId) => void;
}) {
if (worktree.changes.length === 0) {
return <p>No Changed Files in this Worktree.</p>;
}
return (
<div className="change-groups">
{groups.map((group) => {
const changes = worktree.changes.filter(
({ kind }) => kind === group.kind,
);
if (changes.length === 0) return null;
return (
<section key={group.kind}>
<h4>
{group.label} <span>{changes.length}</span>
</h4>
<ul>
{changes.map((change) => (
<li key={change.fileId}>
<button
aria-label={`Review ${group.action} ${change.displayPath}`}
aria-pressed={change.fileId === selectedFileId}
type="button"
onClick={() => onSelect(change.fileId)}
>
<span>{change.displayPath}</span>
{change.previousDisplayPath === null ? null : (
<small>renamed from {change.previousDisplayPath}</small>
)}
</button>
</li>
))}
</ul>
</section>
);
})}
</div>
);
}
Loading