feat: webUI 文本文件编辑/保存 + 相关改动

- FileViewer 新增文本编辑模式(textarea + 保存/取消 + Ctrl+S)
- 后端 /api/files POST 新增 type=write 原子写入
- i18n 补充编辑相关文案(en/zh-CN)
- searxng-search: 加强 web_search 自动搜索条件
- worktree/next.config: 路径比较与 allowedDevOrigins 优化
This commit is contained in:
luckyyzh
2026-08-01 02:58:01 +08:00
parent 551c59413e
commit a70798e874
8 changed files with 429 additions and 12 deletions
+92 -1
View File
@@ -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<string>(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;
+1
View File
@@ -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),
+292 -6
View File
@@ -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 <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
}
@@ -793,10 +804,10 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti
if (isDocumentPreviewPath(filePath)) {
return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
}
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
}
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<FileData | null>(null);
@@ -810,7 +821,13 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
const esRef = useRef<EventSource | null>(null);
const gitDiffRequestRef = useRef(0);
const contentRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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
<button
key={mode}
type="button"
onClick={() => setDisplayMode(mode)}
onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
title={mode === "diff" ? t("i18n.compareHead") : undefined}
aria-pressed={active}
className="file-viewer-mode-button"
@@ -1086,7 +1185,65 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
)}
<div className="file-viewer-actions">
{effectiveDisplayMode === "source" && (
{effectiveDisplayMode === "source" && !isEditing && editable && (
<button
type="button"
onClick={startEditing}
title={t("i18n.editFile")}
aria-label={t("i18n.editFile")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditing && (
<>
<button
type="button"
onClick={() => void saveFile()}
disabled={saving || !dirty}
title={t("i18n.saveShortcut")}
aria-label={t("i18n.saveFile")}
className="file-viewer-icon-button"
style={{
color: dirty ? "#22c55e" : "var(--text-muted)",
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8" />
<path d="M7 3v5h8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
if (dirty) setConfirmDiscard(true);
else stopEditing();
}}
title={t("i18n.stopEditing")}
aria-label={t("i18n.stopEditing")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</>
)}
{effectiveDisplayMode === "source" && !isEditing && (
<>
<button
type="button"
@@ -1128,7 +1285,88 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
{/* Content area */}
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
{effectiveDisplayMode === "diff" && hasGitDiff ? (
{isEditing ? (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
{saveError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
fontSize: 11,
color: "#f87171",
background: "color-mix(in srgb, #ef4444 8%, var(--bg))",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("i18n.saveError", { error: saveError })}
</span>
<button
type="button"
onClick={() => setSaveError(null)}
title={t("i18n.close")}
style={{ marginLeft: "auto", background: "none", border: "none", color: "var(--text-dim)", cursor: "pointer", padding: 2, display: "flex" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="m6 6 12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
</div>
)}
<textarea
ref={editorRef}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
aria-label={t("i18n.editFile")}
style={{
flex: 1,
width: "100%",
minHeight: 0,
resize: "none",
border: "none",
outline: "none",
background: "var(--bg)",
color: "var(--text)",
padding: "14px 16px",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.6,
whiteSpace: "pre",
overflow: "auto",
tabSize: 2,
}}
onKeyDown={(e) => {
// Tab inserts spaces instead of moving focus.
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const indent = " ";
const next = draft.slice(0, start) + indent + draft.slice(end);
setDraft(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + indent.length;
});
}
}}
/>
</div>
) : effectiveDisplayMode === "diff" && hasGitDiff ? (
<DiffView patch={gitDiff.patch!} />
) : isHtml && effectiveDisplayMode === "preview" ? (
<iframe
@@ -1237,6 +1475,54 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
</SyntaxHighlighter>
)}
</div>
{confirmDiscard && (
<div
role="alertdialog"
aria-modal="true"
aria-label={t("i18n.discardChanges")}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.32)",
}}
>
<div
style={{
width: "min(360px, calc(100% - 48px))",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: "16px 16px 12px",
boxShadow: "0 12px 32px rgba(0,0,0,0.28)",
}}
>
<div style={{ fontSize: 13, lineHeight: 1.45, color: "var(--text)" }}>
{t("i18n.closeWithoutSaving")}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
<button
type="button"
onClick={() => setConfirmDiscard(false)}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "var(--bg)", color: "var(--text)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.cancel")}
</button>
<button
type="button"
onClick={stopEditing}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.discardChanges")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -304,6 +304,16 @@ export const enLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "Mention selected lines",
"i18n.disableWrap": "Disable word wrap",
"i18n.enableWrap": "Enable word wrap",
"i18n.editFile": "Edit file",
"i18n.stopEditing": "Stop editing",
"i18n.saveFile": "Save file",
"i18n.saveShortcut": "Save (Ctrl/Cmd+S)",
"i18n.discardChanges": "Discard unsaved changes",
"i18n.saveError": "Could not save file: {error}",
"i18n.closeWithoutSaving": "You have unsaved changes. Discard them and exit editing?",
"i18n.editing": "Editing",
"i18n.unsaved": "unsaved changes",
"i18n.readOnly": "Read-only",
"i18n.htmlPreview": "HTML preview",
"i18n.previewFile": "Preview {file}",
"i18n.invalidMermaid": "Invalid Mermaid diagram",
+10
View File
@@ -304,6 +304,16 @@ export const zhCNLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "提及所选行",
"i18n.disableWrap": "禁用自动换行",
"i18n.enableWrap": "启用自动换行",
"i18n.editFile": "编辑文件",
"i18n.stopEditing": "停止编辑",
"i18n.saveFile": "保存文件",
"i18n.saveShortcut": "保存(Ctrl/Cmd+S)",
"i18n.discardChanges": "放弃未保存的更改",
"i18n.saveError": "无法保存文件:{error}",
"i18n.closeWithoutSaving": "您有未保存的更改。放弃并退出编辑?",
"i18n.editing": "编辑中",
"i18n.unsaved": "未保存的更改",
"i18n.readOnly": "只读",
"i18n.htmlPreview": "HTML 预览",
"i18n.previewFile": "预览 {file}",
"i18n.invalidMermaid": "Mermaid 图表无效",
+11 -2
View File
@@ -2,7 +2,7 @@ import { execFile } from "child_process";
import { existsSync, mkdirSync, realpathSync } from "fs";
import { basename, dirname, join, resolve } from "path";
import { promisify } from "util";
import { allowFileRoot } from "./allowed-roots";
import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
const execFileAsync = promisify(execFile);
@@ -48,6 +48,15 @@ export function invalidateProjectCache(): void {
globalThis.__piProjectCache?.clear();
}
/** Path equality that tolerates Windows quirks: git prints forward slashes
* while realpathSync uses backslashes, and drive letters may differ in case. */
function samePath(a: string, b: string): boolean {
if (process.platform === "win32") {
return normalizeSlashes(a).toLowerCase() === normalizeSlashes(b).toLowerCase();
}
return a === b;
}
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
timeout: 10_000,
@@ -99,7 +108,7 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
// cwd is a subdirectory of a repo keeps its own project identity —
// grouping subdirs under the repo root would change where new sessions
// are created for existing users.
const isTopLevel = toplevel === realCwd;
const isTopLevel = samePath(toplevel, realCwd);
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
info = {
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
+6 -1
View File
@@ -9,6 +9,11 @@ try {
piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version;
} catch { /* package not found, use default */ }
const allowedDevOrigins = [
"192.168.*.*",
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
].map((origin) => origin.trim()).filter(Boolean);
const nextConfig: NextConfig = {
devIndicators: false,
serverExternalPackages: [
@@ -18,7 +23,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-ai",
"@earendil-works/pi-tui",
],
allowedDevOrigins: ['192.168.*.*'],
allowedDevOrigins,
async headers() {
return [
{