Unverified Commit bcf24541 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 6595a2c5
Loading
Loading
Loading
Loading
+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
@@ -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
Changes for panel/src/i18n/resources/zh-CN/files.json: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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 −0
Changes for panel/src/lib/hooks.ts: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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]);
}
+50 −5
Changes for panel/src/pages/ServerFiles.tsx: 50 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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,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")}
@@ -521,6 +565,7 @@ export function ServerFiles() {
                      {saving ? t("saving") : t("save")}
                    </Button>
                  </div>
                )}
              </DialogFooter>
            </>
          )}