fix(panel): 文件编辑器关闭或离开页面前确认放弃未保存修改,状态轮询失败只提示不再卸载编辑器

This commit is contained in:
Lemon-miaow committed 2026-09-25 09:38:11 +08:00
1 parent 6595a2c581
commit bcf245410a
4 files changed
+95 -26

No files matched your search

+5 -1
View File
@@ -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}}"
}
+5 -1
View File
@@ -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}}"
}
+16
View File
@@ -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]);
}
+69 -24
View File
@@ -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,
@@ -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";
@@ -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 ?? "",
@@ -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) {
@@ -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}
@@ -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;
@@ -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">
@@ -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">
@@ -501,26 +530,42 @@ export function ServerFiles() {
? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) })
: formatBytes(dirtyBytes)}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setOpen(null)}
disabled={saving || reloading}
>
{t("common:cancel")}
</Button>
<Button
onClick={() => void handleSave()}
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{saving ? t("saving") : t("save")}
</Button>
</div>
{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={requestClose}
disabled={saving || reloading}
>
{t("common:cancel")}
</Button>
<Button
onClick={() => void handleSave()}
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{saving ? t("saving") : t("save")}
</Button>
</div>
)}
</DialogFooter>
</>
)}