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 type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import {
|
import {
|
||||||
listPending,
|
listPending,
|
||||||
@@ -6,7 +7,14 @@ import {
|
|||||||
applyAll,
|
applyAll,
|
||||||
rejectOne,
|
rejectOne,
|
||||||
rewindOne,
|
rewindOne,
|
||||||
|
queueCreate,
|
||||||
} from '../services/pending_changes.js';
|
} 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.
|
* 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
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/pending/apply',
|
'/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
|
// a right-side drawer toggled by the header's FolderTree button (via
|
||||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||||
// own internal open/close state.
|
// own internal open/close state.
|
||||||
return <RightRail projectId={projectId} />;
|
return <RightRail projectId={projectId} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileBackdrop() {
|
function MobileBackdrop() {
|
||||||
|
|||||||
@@ -346,6 +346,23 @@ export const api = {
|
|||||||
user_message: userMessage,
|
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: {
|
agents: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import type { FileEntry } from '@/api/types';
|
import type { FileEntry } from '@/api/types';
|
||||||
import { inferLanguage } from '@/lib/attachments';
|
import { inferLanguage } from '@/lib/attachments';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||||
import { Input } from '@/components/ui/input';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'boocode.rightrail';
|
const STORAGE_KEY = 'boocode.rightrail';
|
||||||
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
|
|||||||
return `${parent}/${name}`;
|
return `${parent}/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRail({ projectId }: Props) {
|
export function RightRail({ projectId, sessionId }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
|
|||||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: 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
|
// Combined open state: on mobile use the global drawer state (toggled by
|
||||||
// the Session header's FolderTree button); on desktop use the persistent
|
// the Session header's FolderTree button); on desktop use the persistent
|
||||||
// internal state.
|
// internal state.
|
||||||
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
|
|||||||
<aside className={asideCls}>
|
<aside className={asideCls}>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
<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>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeRail}
|
onClick={closeRail}
|
||||||
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
|
|||||||
onNavigate={(path) => void openFile(path)}
|
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