From 00d49717c73bfabfb3976da2eabc421ef04cc1c5 Mon Sep 17 00:00:00 2001 From: ynlo Date: Sat, 22 Aug 2026 10:35:42 +0800 Subject: [PATCH 1/3] feat(files): edit text files and open folders in the system file manager --- app/api/files/[...path]/route.ts | 97 ++++++++- components/FileViewer.tsx | 143 ++++++++++++- components/SessionSidebar.tsx | 21 +- lib/i18n/messages/en.ts | 3 + lib/i18n/messages/zh-CN.ts | 3 + package-lock.json | 354 ++++++++++++++++++++++++++----- 6 files changed, 564 insertions(+), 57 deletions(-) diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 4cc3965dd..b422faeeb 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { spawn } from "child_process"; import { getAllowedFileRoots, isExistingFilePathAllowed, @@ -123,6 +124,68 @@ function parseUploadFileNames(value: unknown): string[] | null { return value; } +type WritableTarget = { ok: string; size: number }; + +/** + * Authorize writing to an existing file: the lexical path must sit inside the + * allowed roots and, after resolving symlinks, still resolve inside them so a + * link inside an allowed root cannot redirect the write outside it. + */ +async function getWritableFile(segments: string[]): Promise { + const filePath = filePathFromSegments(segments); + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(filePath, allowedRoots)) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return { response: NextResponse.json({ error: "File not found" }, { status: 404 }) }; + } + if (!stat.isFile()) { + return { response: NextResponse.json({ error: "Not a file" }, { status: 400 }) }; + } + + const realPath = fs.realpathSync(filePath); + const realRoots = new Set(); + for (const root of allowedRoots) { + try { + realRoots.add(fs.realpathSync(root)); + } catch { + // Ignore stale session roots that no longer exist. + } + } + if (!isFilePathAllowed(realPath, realRoots)) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + return { ok: realPath, size: stat.size }; +} + +/** Launch the platform's file manager on a directory, resolving once launched. */ +function openInSystemFileManager(directory: string): Promise { + return new Promise((resolve) => { + let command: string; + let args: string[]; + if (process.platform === "win32") { + command = "explorer"; + args = [directory]; + } else if (process.platform === "darwin") { + command = "open"; + args = [directory]; + } else { + command = "xdg-open"; + args = [directory]; + } + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => resolve(`Failed to launch file manager (${command})`)); + child.unref(); + resolve(null); + }); +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -133,10 +196,42 @@ export async function POST( try { const { path: segments } = await params; + const type = request.nextUrl.searchParams.get("type") ?? "upload"; + + if (type === "save") { + const writable = await getWritableFile(segments); + if ("response" in writable) return writable.response; + if (writable.size > TEXT_PREVIEW_MAX_BYTES) { + return NextResponse.json({ error: "File too large to edit (>256KB)" }, { status: 413 }); + } + const body = await request.json().catch(() => null) as { content?: unknown } | null; + if (typeof body?.content !== "string") { + return NextResponse.json({ error: "content must be a string" }, { status: 400 }); + } + try { + fs.writeFileSync(writable.ok, body.content, "utf-8"); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + return NextResponse.json({ ok: true, size: Buffer.byteLength(body.content, "utf-8") }); + } + + if (type === "open-directory") { + const uploadDirectory = await getUploadDirectory(segments); + if ("response" in uploadDirectory) return uploadDirectory.response; + const launchError = await openInSystemFileManager(uploadDirectory.directory); + if (launchError) { + return NextResponse.json({ error: launchError }, { status: 500 }); + } + return NextResponse.json({ ok: true }); + } + 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/components/FileViewer.tsx b/components/FileViewer.tsx index 341684045..4574d3b77 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -1003,6 +1003,10 @@ function TextFileViewer({ }); const onStateChangeRef = useRef(onStateChange); const [selectedLineRange, setSelectedLineRange] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editedContent, setEditedContent] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); onStateChangeRef.current = onStateChange; @@ -1019,6 +1023,37 @@ function TextFileViewer({ }); }, []); + const startEditing = useCallback(() => { + setEditedContent(data?.content ?? ""); + setSaveError(null); + setIsEditing(true); + }, [data?.content]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + setSaveError(null); + }, []); + + const saveFile = useCallback(async (nextContent: string) => { + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/api/files/${encodeFilePathForApi(filePath)}?type=save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: nextContent }), + }); + const body = await res.json().catch(() => ({})) as { error?: string; size?: number }; + if (!res.ok || body.error) throw new Error(body.error ?? `Save failed (HTTP ${res.status})`); + setData((prev) => (prev ? { ...prev, content: nextContent, size: body.size ?? prev.size } : prev)); + setIsEditing(false); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }, [filePath]); + useEffect(() => { const nextState: FileViewerState = { displayMode: requestedInitialDisplayMode, @@ -1102,6 +1137,8 @@ function TextFileViewer({ setGitDiff(null); setGitDiffResolved(false); setWatching(false); + setIsEditing(false); + setSaveError(null); fetchContent(filePath).finally(() => { if (active) setLoading(false); @@ -1387,8 +1424,55 @@ function TextFileViewer({ )} - {effectiveDisplayMode === "source" && ( + {isEditing ? ( + <> + + + + ) : effectiveDisplayMode === "source" ? ( <> + - )} + ) : null} {!isDeletedDiff && } + {saveError && ( +
+ {saveError} +
+ )} + {/* Content area */}
- {effectiveDisplayMode === "diff" && hasGitDiff ? ( + {isEditing ? ( +