coder(pending): new-file-from-RightRail create endpoint + modal
POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import {
|
||||
listPending,
|
||||
@@ -6,7 +7,14 @@ import {
|
||||
applyAll,
|
||||
rejectOne,
|
||||
rewindOne,
|
||||
queueCreate,
|
||||
} from '../services/pending_changes.js';
|
||||
import { WriteGuardError } from '../services/write_guard.js';
|
||||
|
||||
const CreateBody = z.object({
|
||||
file_path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve project root from a session's project path.
|
||||
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
|
||||
// (manual create from the RightRail file browser; no inference involved).
|
||||
// queueCreate runs resolveWritePath internally, so a path that escapes the
|
||||
// project root or hits a secret file throws WriteGuardError → 422 with the
|
||||
// guard message. Mirrors the { error } 404 shape used by the other routes
|
||||
// and the 422 status used by apply/rewind on failure.
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending/create',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
const parsed = CreateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||
if (!projectRoot) {
|
||||
reply.code(404);
|
||||
return { error: 'session or project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const change = await queueCreate(
|
||||
sql,
|
||||
sessionId,
|
||||
null,
|
||||
parsed.data.file_path,
|
||||
parsed.data.content,
|
||||
projectRoot,
|
||||
);
|
||||
return change;
|
||||
} catch (err) {
|
||||
if (err instanceof WriteGuardError) {
|
||||
reply.code(422);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending/apply',
|
||||
|
||||
@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
||||
// a right-side drawer toggled by the header's FolderTree button (via
|
||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||
// own internal open/close state.
|
||||
return <RightRail projectId={projectId} />;
|
||||
return <RightRail projectId={projectId} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
function MobileBackdrop() {
|
||||
|
||||
@@ -346,6 +346,23 @@ export const api = {
|
||||
user_message: userMessage,
|
||||
}),
|
||||
}),
|
||||
// Queue a new-file create from the RightRail browser → BooCoder
|
||||
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
|
||||
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
|
||||
// body ApiError exposes as .message for inline display.
|
||||
createPendingFile: (sessionId: string, file_path: string, content: string) =>
|
||||
request<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string | null;
|
||||
file_path: string;
|
||||
operation: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_path, content }),
|
||||
}),
|
||||
},
|
||||
|
||||
agents: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import type { FileEntry } from '@/api/types';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'boocode.rightrail';
|
||||
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
|
||||
return `${parent}/${name}`;
|
||||
}
|
||||
|
||||
export function RightRail({ projectId }: Props) {
|
||||
export function RightRail({ projectId, sessionId }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||
const [open, setOpen] = useState(() => {
|
||||
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||
|
||||
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
||||
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
||||
const [newFileOpen, setNewFileOpen] = useState(false);
|
||||
const [newFilePath, setNewFilePath] = useState('');
|
||||
const [newFileContent, setNewFileContent] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const openNewFile = useCallback(() => {
|
||||
setNewFilePath('');
|
||||
setNewFileContent('');
|
||||
setCreateError(null);
|
||||
setNewFileOpen(true);
|
||||
}, []);
|
||||
|
||||
const submitNewFile = useCallback(async () => {
|
||||
const path = newFilePath.trim();
|
||||
if (!path || creating) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await api.coder.createPendingFile(sessionId, path, newFileContent);
|
||||
setNewFileOpen(false);
|
||||
setNewFilePath('');
|
||||
setNewFileContent('');
|
||||
} catch (err) {
|
||||
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
|
||||
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [sessionId, newFilePath, newFileContent, creating]);
|
||||
|
||||
// Combined open state: on mobile use the global drawer state (toggled by
|
||||
// the Session header's FolderTree button); on desktop use the persistent
|
||||
// internal state.
|
||||
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
|
||||
<aside className={asideCls}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-medium flex-1">Files</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeRail}
|
||||
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
|
||||
onNavigate={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New file from pasted text</DialogTitle>
|
||||
<DialogDescription>
|
||||
Queues a new file as a pending change. Review and apply it from the Coder pane.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
|
||||
<Input
|
||||
id="new-file-path"
|
||||
value={newFilePath}
|
||||
onChange={(e) => setNewFilePath(e.target.value)}
|
||||
placeholder="src/example.ts"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
|
||||
<Textarea
|
||||
id="new-file-content"
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="Paste file contents here…"
|
||||
autoFocus
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user