Loading panel/src/i18n/resources/en-US/files.json +5 −1 Changes for panel/src/i18n/resources/en-US/files.json: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -25,5 +25,9 @@ "conflict_reload": "Load latest", "conflict_reload_hint": "Discard your edits and show the file as it is now", "conflict_overwrite": "Overwrite anyway", "conflict_overwrite_hint": "Replace their change with your version" "conflict_overwrite_hint": "Replace their change with your version", "discard_prompt": "You have unsaved changes. Discard them?", "discard": "Discard", "keep_editing": "Keep editing", "status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}" } panel/src/i18n/resources/zh-CN/files.json +5 −1 Changes for panel/src/i18n/resources/zh-CN/files.json: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -25,5 +25,9 @@ "conflict_reload": "载入最新内容", "conflict_reload_hint": "放弃你的修改,改看现在的文件", "conflict_overwrite": "仍然覆盖", "conflict_overwrite_hint": "用你的版本替换对方的修改" "conflict_overwrite_hint": "用你的版本替换对方的修改", "discard_prompt": "有未保存的修改,要放弃吗?", "discard": "放弃修改", "keep_editing": "继续编辑", "status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}" } panel/src/lib/hooks.ts +16 −0 Changes for panel/src/lib/hooks.ts: 16 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -65,3 +65,19 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt return { data, error, loading, reload }; } /** useUnsavedGuard asks the browser to confirm leaving the page (reload, tab * close, typing another URL) while `dirty` holds, so unsaved edits are not * dropped without a prompt. */ export function useUnsavedGuard(dirty: boolean): void { useEffect(() => { if (!dirty) return; const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); // Older Chromium and Safari need returnValue set to show the prompt. e.returnValue = ""; }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [dirty]); } panel/src/pages/ServerFiles.tsx +50 −5 Changes for panel/src/pages/ServerFiles.tsx: 50 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { MessageLine } from "@/components/MessageLine"; import { InlineConfirm } from "@/components/InlineConfirm"; import { Dialog, DialogContent, Loading @@ -28,7 +29,7 @@ import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative } from "@/lib/format"; Loading Loading @@ -157,10 +158,30 @@ export function ServerFiles() { const [saving, setSaving] = useState(false); const [reloading, setReloading] = useState(false); const [stopping, setStopping] = useState(false); // Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks // first; leaving the page asks through the browser. const [confirmDiscard, setConfirmDiscard] = useState(false); const dirty = open !== null && open.editable && open.text !== open.original; useUnsavedGuard(dirty); function requestClose() { if (saving || reloading) return; if (dirty) { setConfirmDiscard(true); return; } setOpen(null); } function discardEdits() { setConfirmDiscard(false); setOpen(null); } async function readInto(p: string) { const r = await api.readServerFile(name, p); const text = decodeText(base64ToBytes(r.content ?? "")); setConfirmDiscard(false); setOpen({ path: p, text: text ?? "", Loading Loading @@ -213,6 +234,7 @@ export function ServerFiles() { overwrite ? undefined : open.sha256 || undefined, ); setMsg({ kind: "success", text: t("saved", { path: open.path }) }); setConfirmDiscard(false); setOpen(null); void load(dir); } catch (e) { Loading Loading @@ -249,7 +271,9 @@ export function ServerFiles() { </> ); } if (statusQ.error) { // Only a failed first load replaces the page: a later poll that fails keeps // the page (and an open editor) mounted and says so above the listing. if (statusQ.error && !statusQ.data) { return ( <> {back} Loading @@ -261,7 +285,6 @@ export function ServerFiles() { const now = Date.now(); const segments = dir === "" ? [] : dir.split("/"); const dirty = open !== null && open.text !== open.original; const dirtyBytes = open ? new TextEncoder().encode(open.text).length : 0; const tooLarge = dirtyBytes > MAX_WRITE_BYTES; Loading @@ -288,6 +311,12 @@ export function ServerFiles() { ) : ( <div className="space-y-4"> {msg && <MessageLine kind={msg.kind} message={msg.text} />} {statusQ.error != null && ( <MessageLine kind="error" message={t("status_refresh_failed", { reason: humanizeError(statusQ.error) })} /> )} {!stopped ? ( <Card> <CardContent className="flex flex-col items-center gap-3 py-14 text-center"> Loading Loading @@ -431,7 +460,7 @@ export function ServerFiles() { )} {/* File editor dialog */} <Dialog open={open !== null} onOpenChange={(v) => !v && !saving && !reloading && setOpen(null)}> <Dialog open={open !== null} onOpenChange={(v) => !v && requestClose()}> <DialogContent className="max-w-3xl"> <DialogHeader> <DialogTitle className="break-all font-mono text-sm"> Loading Loading @@ -501,10 +530,25 @@ export function ServerFiles() { ? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) }) : formatBytes(dirtyBytes)} </span> {confirmDiscard ? ( <div role="alert" className="flex flex-wrap items-center justify-end gap-2"> <span className="text-sm font-medium">{t("discard_prompt")}</span> <InlineConfirm open confirming={false} onConfirm={discardEdits} onCancel={() => setConfirmDiscard(false)} confirmLabel={t("discard")} cancelLabel={t("keep_editing")} size="default" className="flex items-center gap-2" /> </div> ) : ( <div className="flex items-center gap-2"> <Button variant="outline" onClick={() => setOpen(null)} onClick={requestClose} disabled={saving || reloading} > {t("common:cancel")} Loading @@ -521,6 +565,7 @@ export function ServerFiles() { {saving ? t("saving") : t("save")} </Button> </div> )} </DialogFooter> </> )} Loading Loading
panel/src/i18n/resources/en-US/files.json +5 −1 Changes for panel/src/i18n/resources/en-US/files.json: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -25,5 +25,9 @@ "conflict_reload": "Load latest", "conflict_reload_hint": "Discard your edits and show the file as it is now", "conflict_overwrite": "Overwrite anyway", "conflict_overwrite_hint": "Replace their change with your version" "conflict_overwrite_hint": "Replace their change with your version", "discard_prompt": "You have unsaved changes. Discard them?", "discard": "Discard", "keep_editing": "Keep editing", "status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}" }
panel/src/i18n/resources/zh-CN/files.json +5 −1 Changes for panel/src/i18n/resources/zh-CN/files.json: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -25,5 +25,9 @@ "conflict_reload": "载入最新内容", "conflict_reload_hint": "放弃你的修改,改看现在的文件", "conflict_overwrite": "仍然覆盖", "conflict_overwrite_hint": "用你的版本替换对方的修改" "conflict_overwrite_hint": "用你的版本替换对方的修改", "discard_prompt": "有未保存的修改,要放弃吗?", "discard": "放弃修改", "keep_editing": "继续编辑", "status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}" }
panel/src/lib/hooks.ts +16 −0 Changes for panel/src/lib/hooks.ts: 16 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -65,3 +65,19 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt return { data, error, loading, reload }; } /** useUnsavedGuard asks the browser to confirm leaving the page (reload, tab * close, typing another URL) while `dirty` holds, so unsaved edits are not * dropped without a prompt. */ export function useUnsavedGuard(dirty: boolean): void { useEffect(() => { if (!dirty) return; const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); // Older Chromium and Safari need returnValue set to show the prompt. e.returnValue = ""; }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [dirty]); }
panel/src/pages/ServerFiles.tsx +50 −5 Changes for panel/src/pages/ServerFiles.tsx: 50 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ import { BackLink } from "@/components/BackLink"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { MessageLine } from "@/components/MessageLine"; import { InlineConfirm } from "@/components/InlineConfirm"; import { Dialog, DialogContent, Loading @@ -28,7 +29,7 @@ import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative } from "@/lib/format"; Loading Loading @@ -157,10 +158,30 @@ export function ServerFiles() { const [saving, setSaving] = useState(false); const [reloading, setReloading] = useState(false); const [stopping, setStopping] = useState(false); // Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks // first; leaving the page asks through the browser. const [confirmDiscard, setConfirmDiscard] = useState(false); const dirty = open !== null && open.editable && open.text !== open.original; useUnsavedGuard(dirty); function requestClose() { if (saving || reloading) return; if (dirty) { setConfirmDiscard(true); return; } setOpen(null); } function discardEdits() { setConfirmDiscard(false); setOpen(null); } async function readInto(p: string) { const r = await api.readServerFile(name, p); const text = decodeText(base64ToBytes(r.content ?? "")); setConfirmDiscard(false); setOpen({ path: p, text: text ?? "", Loading Loading @@ -213,6 +234,7 @@ export function ServerFiles() { overwrite ? undefined : open.sha256 || undefined, ); setMsg({ kind: "success", text: t("saved", { path: open.path }) }); setConfirmDiscard(false); setOpen(null); void load(dir); } catch (e) { Loading Loading @@ -249,7 +271,9 @@ export function ServerFiles() { </> ); } if (statusQ.error) { // Only a failed first load replaces the page: a later poll that fails keeps // the page (and an open editor) mounted and says so above the listing. if (statusQ.error && !statusQ.data) { return ( <> {back} Loading @@ -261,7 +285,6 @@ export function ServerFiles() { const now = Date.now(); const segments = dir === "" ? [] : dir.split("/"); const dirty = open !== null && open.text !== open.original; const dirtyBytes = open ? new TextEncoder().encode(open.text).length : 0; const tooLarge = dirtyBytes > MAX_WRITE_BYTES; Loading @@ -288,6 +311,12 @@ export function ServerFiles() { ) : ( <div className="space-y-4"> {msg && <MessageLine kind={msg.kind} message={msg.text} />} {statusQ.error != null && ( <MessageLine kind="error" message={t("status_refresh_failed", { reason: humanizeError(statusQ.error) })} /> )} {!stopped ? ( <Card> <CardContent className="flex flex-col items-center gap-3 py-14 text-center"> Loading Loading @@ -431,7 +460,7 @@ export function ServerFiles() { )} {/* File editor dialog */} <Dialog open={open !== null} onOpenChange={(v) => !v && !saving && !reloading && setOpen(null)}> <Dialog open={open !== null} onOpenChange={(v) => !v && requestClose()}> <DialogContent className="max-w-3xl"> <DialogHeader> <DialogTitle className="break-all font-mono text-sm"> Loading Loading @@ -501,10 +530,25 @@ export function ServerFiles() { ? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) }) : formatBytes(dirtyBytes)} </span> {confirmDiscard ? ( <div role="alert" className="flex flex-wrap items-center justify-end gap-2"> <span className="text-sm font-medium">{t("discard_prompt")}</span> <InlineConfirm open confirming={false} onConfirm={discardEdits} onCancel={() => setConfirmDiscard(false)} confirmLabel={t("discard")} cancelLabel={t("keep_editing")} size="default" className="flex items-center gap-2" /> </div> ) : ( <div className="flex items-center gap-2"> <Button variant="outline" onClick={() => setOpen(null)} onClick={requestClose} disabled={saving || reloading} > {t("common:cancel")} Loading @@ -521,6 +565,7 @@ export function ServerFiles() { {saving ? t("saving") : t("save")} </Button> </div> )} </DialogFooter> </> )} Loading