diff --git a/pi-web/app/api/files/[...path]/route.ts b/pi-web/app/api/files/[...path]/route.ts index 7251e89..ed1356d 100644 --- a/pi-web/app/api/files/[...path]/route.ts +++ b/pi-web/app/api/files/[...path]/route.ts @@ -17,6 +17,9 @@ import { getDocumentMime, getFileExt, getImageMime, + isAudioPath, + isDocumentPreviewPath, + isImagePath, } from "@/lib/file-types"; import { resolveDirentIsDirectory } from "@/lib/file-dirent"; import { isFilePathReferencedBySession } from "@/lib/session-file-references"; @@ -39,6 +42,7 @@ const IGNORED_SUFFIXES = [".pyc"]; const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const; type FileRequestType = typeof FILE_REQUEST_TYPES[number]; const FILE_REQUEST_TYPE_SET = new Set(FILE_REQUEST_TYPES); +const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES; const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024; const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; // Multipart boundaries and headers are not file bytes, but must be bounded too. @@ -122,6 +126,46 @@ function parseUploadFileNames(value: unknown): string[] | null { return value; } +async function getWritableFile( + segments: string[], + sessionId: string | null, +): Promise<{ filePath: string } | { response: NextResponse }> { + const filePath = filePathFromSegments(segments); + const allowedRoots = await getAllowedFileRoots(); + const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); + const allowedBySessionReference = + !allowedByRoot && + await isFilePathReferencedBySession(filePath, sessionId); + if (!allowedByRoot && !allowedBySessionReference) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + // The target must already exist as a regular file that resolves inside an + // allowed root. Resolving before the write prevents a symlink (possibly + // swapped in by a race) from redirecting the payload outside the roots, and + // the non-existing-path check above excludes files the session or current + // roots no longer own. + if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { response: NextResponse.json({ error: "Not found" }, { status: 404 }) }; + } + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return { response: NextResponse.json({ error: "Not a writable file" }, { status: 403 }) }; + } + + return { filePath }; +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -132,10 +176,57 @@ export async function POST( try { const { path: segments } = await params; + const type = request.nextUrl.searchParams.get("type") ?? "upload"; + const sessionId = request.nextUrl.searchParams.get("sessionId"); + + if (type === "write") { + const writable = await getWritableFile(segments, sessionId); + if ("response" in writable) return writable.response; + const { filePath } = writable; + + const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath); + if (readonlyFile) { + return NextResponse.json({ error: "This file type cannot be edited as text" }, { status: 400 }); + } + + const body = await request.json().catch(() => null) as { content?: unknown } | null; + if (!body || typeof body.content !== "string") { + return NextResponse.json({ error: "content must be a string" }, { status: 400 }); + } + const content = body.content; + const contentBytes = Buffer.byteLength(content, "utf-8"); + if (contentBytes > MAX_WRITE_BODY_BYTES) { + return NextResponse.json( + { error: "File too large to save (>256KB)" }, + { status: 413 }, + ); + } + + // Write to a temp file in the same directory then rename, so a crash or + // an error mid-write never leaves a truncated file behind. + const directory = path.dirname(filePath); + const tmpPath = path.join( + directory, + `.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`, + ); + try { + fs.writeFileSync(tmpPath, content, "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (writeError) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + return NextResponse.json( + { error: writeError instanceof Error ? writeError.message : String(writeError) }, + { status: 500 }, + ); + } + + const stat = fs.statSync(filePath); + return NextResponse.json({ size: stat.size }); + } + const uploadDirectory = await getUploadDirectory(segments); if ("response" in uploadDirectory) return uploadDirectory.response; const { directory } = uploadDirectory; - const type = request.nextUrl.searchParams.get("type") ?? "upload"; if (type === "upload-check") { const body = await request.json().catch(() => null) as { fileNames?: unknown } | null; diff --git a/pi-web/components/AppShell.tsx b/pi-web/components/AppShell.tsx index dcc4ad8..1866ee7 100644 --- a/pi-web/components/AppShell.tsx +++ b/pi-web/components/AppShell.tsx @@ -1478,6 +1478,7 @@ export function AppShell() { gitRefreshKey={explorerRefreshKey} initialDisplayMode={activeFileTab.initialDisplayMode} onMentionLines={rightPanelOpen ? handleFileLineMention : undefined} + onEditorSaved={() => setExplorerRefreshKey((k) => k + 1)} onOpenFile={(filePath) => handleOpenFile( filePath, getFileName(filePath), diff --git a/pi-web/components/FileViewer.tsx b/pi-web/components/FileViewer.tsx index 117261e..ec265ac 100644 --- a/pi-web/components/FileViewer.tsx +++ b/pi-web/components/FileViewer.tsx @@ -31,6 +31,7 @@ interface Props { sourceSessionId?: string | null; onOpenFile?: (filePath: string) => void; onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void; + onEditorSaved?: (filePath: string) => void; gitRefreshKey?: number; initialDisplayMode?: DisplayMode; } @@ -207,6 +208,16 @@ function getFileApiUrl( return `/api/files/${encoded}?${searchParams.toString()}`; } +function getFileWriteApiUrl( + filePath: string, + sourceSessionId?: string | null, +): string { + const encoded = encodeFilePathForApi(filePath); + const searchParams = new URLSearchParams({ type: "write" }); + if (sourceSessionId) searchParams.set("sessionId", sourceSessionId); + return `/api/files/${encoded}?${searchParams.toString()}`; +} + function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) { const { t } = useI18n(); return ( @@ -783,7 +794,7 @@ function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) { ); } -export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) { +export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) { if (isImagePath(filePath)) { return ; } @@ -793,10 +804,10 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti if (isDocumentPreviewPath(filePath)) { return ; } - return ; + return ; } -function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) { +function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) { const { isDark } = useTheme(); const { t } = useI18n(); const [data, setData] = useState(null); @@ -810,7 +821,13 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL const esRef = useRef(null); const gitDiffRequestRef = useRef(0); const contentRef = useRef(null); + const editorRef = useRef(null); const [selectedLineRange, setSelectedLineRange] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [confirmDiscard, setConfirmDiscard] = useState(false); const fetchContent = useCallback((filePath: string) => { return fetch(getFileApiUrl(filePath, "read", sourceSessionId)) @@ -969,6 +986,88 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL mentionLineRange(selectedLineRange); }, [mentionLineRange, selectedLineRange]); + const isDeleted = isDeletedDiff; + // Every text file routed to this viewer (i.e. non-image/audio/document) can + // be edited; the backend independently rejects binary/read-only types. + const editable = !isDeleted && !!data; + const dirty = isEditing && draft !== (data?.content ?? ""); + + const stopEditing = useCallback(() => { + setConfirmDiscard(false); + setIsEditing(false); + setSaveError(null); + setDraft(""); + }, []); + + const exitEditingGuard = useCallback((handler: () => void) => { + if (dirty) { + setConfirmDiscard(true); + return; + } + stopEditing(); + handler(); + }, [dirty, stopEditing]); + + const startEditing = useCallback(() => { + if (!data) return; + setDraft(data.content); + setSaveError(null); + setConfirmDiscard(false); + setIsEditing(true); + requestAnimationFrame(() => editorRef.current?.focus()); + }, [data]); + + const saveFile = useCallback(async () => { + if (saving || !dirty) { + if (!dirty && isEditing) stopEditing(); + return; + } + setSaving(true); + setSaveError(null); + try { + const response = await fetch( + getFileWriteApiUrl(filePath, sourceSessionId), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: draft }), + }, + ); + const result = await response.json().catch(() => ({})) as { size?: number; error?: string }; + if (!response.ok) { + setSaveError(result.error ?? `HTTP ${response.status}`); + return; + } + setData((prev) => (prev ? { ...prev, content: draft, size: result.size ?? prev.size } : prev)); + stopEditing(); + void fetchGitDiff(filePath); + onEditorSaved?.(filePath); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }, [dirty, draft, filePath, fetchGitDiff, isEditing, onEditorSaved, saving, sourceSessionId, stopEditing]); + + // Reset edit state whenever the file path or content source changes. + useEffect(() => { + stopEditing(); + setSaveError(null); + setSaving(false); + }, [filePath, sourceSessionId, stopEditing]); + + // Ctrl/Cmd+S saves while editing. + useEffect(() => { + if (!isEditing) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat || event.key.toLowerCase() !== "s" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return; + event.preventDefault(); + void saveFile(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isEditing, saveFile]); + useEffect(() => { if (!onMentionLines || displayMode !== "source") return; @@ -1069,7 +1168,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL + )} + + {isEditing && ( + <> + + + + )} + + {effectiveDisplayMode === "source" && !isEditing && ( <> + + )} +