feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "@/hooks/useI18n";
interface DirectoryEntry {
name: string;
path: string;
}
interface BrowseResponse {
path?: string;
parentPath?: string | null;
directories?: DirectoryEntry[];
error?: string;
}
async function loadDirectories(directory?: string): Promise<BrowseResponse> {
const query = directory ? `?path=${encodeURIComponent(directory)}` : "";
const response = await fetch(`/api/cwd/browse${query}`);
const data = await response.json() as BrowseResponse;
if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`);
return data;
}
function FolderIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
<path d="M1.5 3h4l1.5 2h7.5v7.5h-13z" />
</svg>
);
}
interface Props {
onCancel: () => void;
onSelect: (path: string) => void;
busy?: boolean;
error?: string | null;
}
export function DirectoryPicker({ onCancel, onSelect, busy = false, error }: Props) {
const { t } = useI18n();
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
const [currentPath, setCurrentPath] = useState("");
const [parentDirectory, setParentDirectory] = useState<string | null>(null);
const [pathInput, setPathInput] = useState("");
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const navigateTo = useCallback(async (directory?: string) => {
setLoading(true);
setLoadError(null);
try {
const data = await loadDirectories(directory);
const nextPath = data.path ?? directory ?? "/";
setCurrentPath(nextPath);
setParentDirectory(data.parentPath ?? null);
setPathInput(nextPath);
setDirectories(data.directories ?? []);
} catch (cause) {
setLoadError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setPortalTarget(document.body);
void navigateTo();
}, [navigateTo]);
const handlePathSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const candidate = pathInput.trim();
if (candidate) void navigateTo(candidate);
};
const hasUncommittedPath = pathInput.trim() !== currentPath;
const canSelect = Boolean(currentPath) && !hasUncommittedPath && !busy;
if (!portalTarget) return null;
return createPortal(
<div
className="directory-picker-backdrop"
role="dialog"
aria-modal="true"
aria-label={t("directoryPicker.selectDirectory")}
onClick={(event) => {
if (event.target === event.currentTarget && !busy) onCancel();
}}
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) onCancel();
}}
style={{ position: "fixed", inset: 0, zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.35)" }}
>
<div className="directory-picker-panel" style={{ width: 520, maxWidth: "calc(100vw - 16px)", height: "min(620px, calc(100dvh - 16px))", maxHeight: "calc(100dvh - 16px)", display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, boxShadow: "0 8px 32px rgba(0,0,0,0.18)" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ color: "var(--text)", fontWeight: 700, fontSize: 15 }}>{t("directoryPicker.selectDirectory")}</div>
</div>
<button
type="button"
onClick={onCancel}
disabled={busy}
title={t("i18n.close")}
aria-label={t("i18n.close")}
style={{ padding: "2px 6px", border: 0, background: "none", color: "var(--text-muted)", fontSize: 20, lineHeight: 1, cursor: busy ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}
>
×
</button>
</div>
<form onSubmit={handlePathSubmit} style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0, padding: "10px 14px", borderBottom: "1px solid var(--border)" }}>
<button className="directory-picker-back" type="button" onClick={() => parentDirectory && void navigateTo(parentDirectory)} disabled={loading || !parentDirectory} title={t("directoryPicker.goToParent")} aria-label={t("directoryPicker.goToParent")} style={{ width: 36, height: 36, padding: 0, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-hover)", color: "var(--text-muted)", cursor: parentDirectory ? "pointer" : "default", opacity: parentDirectory ? 1 : 0.45 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
<label htmlFor="directory-path" style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: 0 }}>
{t("directoryPicker.directoryPath")}
</label>
<input
className="directory-picker-path"
id="directory-path"
type="text"
value={pathInput}
placeholder="/path/to/project or ~/project"
autoFocus
autoComplete="off"
spellCheck={false}
onChange={(event) => {
setPathInput(event.target.value);
setLoadError(null);
}}
style={{ minWidth: 0, flex: 1, height: 36, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 6, outline: "none", background: "var(--bg-panel)", color: "var(--text)", fontFamily: "var(--font-mono)", fontSize: 12 }}
/>
<button
className="directory-picker-action"
type="submit"
disabled={loading || !pathInput.trim()}
title={t("directoryPicker.goToDirectory")}
style={{ minWidth: 58, height: 36, padding: "0 12px", border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-hover)", color: "var(--text-muted)", cursor: loading || !pathInput.trim() ? "default" : "pointer", opacity: loading || !pathInput.trim() ? 0.6 : 1 }}
>
{t("directoryPicker.go")}
</button>
</form>
<div className="directory-picker-list" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px 10px" }}>
{loading ? (
<div style={{ padding: 8, color: "var(--text-dim)", fontSize: 11 }}>{t("directoryPicker.loadingDirectories")}</div>
) : directories.length > 0 ? (
directories.map((entry) => (
<button
key={entry.path}
className="directory-picker-entry"
type="button"
onClick={() => void navigateTo(entry.path)}
title={entry.path}
style={{ width: "100%", minHeight: 30, display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", border: 0, borderRadius: 5, background: "none", color: "var(--text-muted)", cursor: "pointer", textAlign: "left", fontFamily: "var(--font-mono)", fontSize: 11 }}
>
<FolderIcon />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{entry.name}</span>
</button>
))
) : (
<div style={{ padding: 8, color: "var(--text-dim)", fontSize: 11 }}>{t("directoryPicker.noSubdirectories")}</div>
)}
{(loadError || error) && <div style={{ padding: "8px", color: "#dc2626", fontSize: 11 }}>{loadError ?? error}</div>}
</div>
<div className="directory-picker-footer" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 10, flexShrink: 0, padding: "10px 18px", borderTop: "1px solid var(--border)" }}>
<button className="directory-picker-action" type="button" onClick={onCancel} disabled={busy} style={{ padding: "6px 14px", border: "1px solid var(--border)", borderRadius: 6, background: "none", color: "var(--text-muted)", cursor: busy ? "default" : "pointer", fontSize: 13 }}>{t("i18n.cancel")}</button>
<button
className="directory-picker-action"
type="button"
onClick={() => onSelect(currentPath)}
disabled={!canSelect}
title={hasUncommittedPath ? t("directoryPicker.openBeforeSelecting") : t("directoryPicker.selectCurrentDirectory")}
style={{ padding: "6px 16px", border: 0, borderRadius: 6, background: "var(--accent)", color: "#fff", fontSize: 13, fontWeight: 600, opacity: canSelect ? 1 : 0.6, cursor: canSelect ? "pointer" : "default" }}
>
{busy ? t("i18n.checking") : t("directoryPicker.selectThisFolder")}
</button>
</div>
</div>
</div>,
portalTarget,
);
}