From c8eb8693fa4ab75463cfe767ba3d5ae6c9e0eb58 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 02:03:09 -0800 Subject: [PATCH] feat(studio): native session import is a dialog with a dashed drop area, theme and session import results are the recovery board's green and warning strips --- desktop/frontend-next/src/design/recovery.css | 25 +++++++++ .../frontend-next/src/design/recovery.test.ts | 16 ++++++ desktop/frontend-next/src/i18n/msg/data.ts | 1 + .../src/ui/NativeSessionImport.test.tsx | 32 +++++++++++- .../src/ui/NativeSessionImport.tsx | 51 +++++++++++++++---- desktop/frontend-next/src/ui/ThemeImport.tsx | 4 +- desktop/frontend-next/src/ui/ThemeRemove.tsx | 1 + 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/desktop/frontend-next/src/design/recovery.css b/desktop/frontend-next/src/design/recovery.css index 27fbee89f5..829414de32 100644 --- a/desktop/frontend-next/src/design/recovery.css +++ b/desktop/frontend-next/src/design/recovery.css @@ -40,3 +40,28 @@ .bk-fields > label { display: block; } .bk-fields > label > span { display: block; margin: 10px 0 6px; font: var(--w-med) var(--fs-meta)/1.4 var(--ui); color: var(--muted); } .bk-fields input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); } +.ns-done, .ns-fail { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); } +.ns-done { background: color-mix(in srgb, var(--ok) 10%, transparent); } +.ns-done .studio-icon { color: var(--ok-ink); } +.ns-fail { background: color-mix(in srgb, var(--warn) 12%, transparent); } +.ns-fail .studio-icon { color: var(--warn-ink); } +.ns-done .studio-icon, .ns-fail .studio-icon { flex: none; width: 16px; height: 16px; } +.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); } +.ns-dlg { width: 440px; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); } +.ns-dlg > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; } +.ns-dlg > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); } +.ns-dlg .ns-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; } +.ns-dlg .ns-x:hover { background: var(--overlay); color: var(--text); } +.ns-dlg > p { margin: 0; padding: 10px 16px 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); } +.ns-dlg .ns-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 12px 16px 16px; padding: 22px 16px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); } +.ns-dlg .ns-drop[data-over] { border-color: var(--accent); background: var(--overlay); } +.ns-dlg .ns-drop .studio-icon { width: 18px; height: 18px; color: var(--faint); } +.ns-dlg .ns-drop .btn { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1 var(--ui); cursor: pointer; } +.ns-dlg > footer { display: flex; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid var(--hair); } +.ns-dlg .ns-cancel { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; } +.ns-dlg .ns-cancel:hover { background: var(--overlay); color: var(--text); } +.themeacts[data-landed] { padding: 8px 8px 8px 12px; border-radius: var(--r-md); background: color-mix(in srgb, var(--ok) 10%, transparent); } +.themeacts[data-landed] .note { color: var(--text); } +.themeacts .btn.sm, .themeacts .btn.sm:hover { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); } +.themefail { display: flex; align-items: center; margin-top: 8px; padding: 10px 12px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); } +.pal-rm[data-confirming], .pal-rm.btn.sm[aria-busy] { color: var(--err-ink); } diff --git a/desktop/frontend-next/src/design/recovery.test.ts b/desktop/frontend-next/src/design/recovery.test.ts index fd14185256..aa7f2bd042 100644 --- a/desktop/frontend-next/src/design/recovery.test.ts +++ b/desktop/frontend-next/src/design/recovery.test.ts @@ -36,3 +36,19 @@ describe("the export chips follow the board", () => { expect(rule(".bk-fields")).toMatch(/grid-template-columns:\s*1fr 1fr/); }); }); + +describe("the import results follow the board", () => { + it("draws a landed theme as a green strip and a refused one as a warning strip", () => { + expect(rule(".themeacts[data-landed]")).toMatch(/var\(--ok\) 10%/); + expect(rule(".themefail")).toMatch(/var\(--warn\) 12%/); + }); + + it("draws the armed delete in the error tone", () => { + expect(css).toContain('.pal-rm[data-confirming]'); + }); + + it("draws the session-import placeholder as a 440px dialog with a dashed drop area", () => { + expect(rule(".ns-dlg")).toMatch(/width:\s*440px/); + expect(rule(".ns-dlg .ns-drop")).toMatch(/border:\s*1px dashed var\(--border\)/); + }); +}); diff --git a/desktop/frontend-next/src/i18n/msg/data.ts b/desktop/frontend-next/src/i18n/msg/data.ts index fe93ad148b..01c3ad6218 100644 --- a/desktop/frontend-next/src/i18n/msg/data.ts +++ b/desktop/frontend-next/src/i18n/msg/data.ts @@ -33,6 +33,7 @@ export const DATA: Messages = { "data.decrypting": { zh: "正在解密…", en: "Decrypting…" }, "data.differs_from_this_machine": { zh: "与本机不同", en: "Differs from this machine" }, "data.drive_free_free_of_total": { zh: "{drive} 剩余 {free} / {total}", en: "{drive} — {free} free of {total}" }, + "data.drop_a_session_json_file_here": { zh: "把会话 JSON 文件拖到这里", en: "Drop a session JSON file here" }, "data.e_g_work_laptop": { zh: "例如:公司笔记本", en: "e.g. work laptop" }, "data.encrypting": { zh: "正在加密…", en: "Encrypting…" }, "data.encrypting_and_uploading": { zh: "正在加密上传…", en: "Encrypting and uploading…" }, diff --git a/desktop/frontend-next/src/ui/NativeSessionImport.test.tsx b/desktop/frontend-next/src/ui/NativeSessionImport.test.tsx index 883818100f..3271272407 100644 --- a/desktop/frontend-next/src/ui/NativeSessionImport.test.tsx +++ b/desktop/frontend-next/src/ui/NativeSessionImport.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { MockHub } from "../port/mock_hub"; @@ -48,9 +48,39 @@ it("offers the file through the app's own button, with the native input out of s expect(input.hidden).toBe(true); const open = vi.spyOn(input, "click"); await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" })); + expect(open).not.toHaveBeenCalled(); + const dialog = await screen.findByRole("dialog"); + expect(dialog.textContent).toContain("把会话 JSON 文件拖到这里"); + await userEvent.click(screen.getByRole("button", { name: "选择会话 JSON 文件" })); expect(open).toHaveBeenCalledOnce(); }); +it("takes a file dropped on the dashed area and closes the dialog", async () => { + const hub = new MockHub(); + const imported = vi.spyOn(hub, "importNativeSession"); + render(); + await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" })); + const drop = document.querySelector(".ns-drop")!; + const file = new File(["{}"], "s.json", { type: "application/json" }); + fireEvent.drop(drop, { dataTransfer: { files: [file] } }); + await screen.findByRole("status"); + expect(imported).toHaveBeenCalledWith(file, "/workspace"); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("closes the dialog with Cancel and with Escape without importing anything", async () => { + const hub = new MockHub(); + const imported = vi.spyOn(hub, "importNativeSession"); + render(); + await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" })); + await userEvent.click(screen.getByRole("button", { name: "取消" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" })); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(imported).not.toHaveBeenCalled(); +}); + it("holds the button while an import is running", async () => { const hub = new MockHub(); let finish: () => void = () => {}; diff --git a/desktop/frontend-next/src/ui/NativeSessionImport.tsx b/desktop/frontend-next/src/ui/NativeSessionImport.tsx index 6fc570fb0c..bf76bd8f58 100644 --- a/desktop/frontend-next/src/ui/NativeSessionImport.tsx +++ b/desktop/frontend-next/src/ui/NativeSessionImport.tsx @@ -1,36 +1,65 @@ import { useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { HubPort } from "../port/hub"; import { t } from "../i18n"; import { reason } from "../i18n/kernel"; +import { StudioIcon } from "./StudioIcon"; export function NativeSessionImport({ hub, workspace, onImported }: { hub: HubPort; workspace: string; onImported: () => void }) { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const input = useRef(null); + const [asking, setAsking] = useState(false); + const [over, setOver] = useState(false); + const take = (file: File | undefined) => { + if (!file) return; + setAsking(false); + setBusy(true); setError(""); setMessage(""); + void (async () => { + await hub.importNativeSession(file, workspace); + setMessage(t("data.session_imported_with_its_original")); + onImported(); + })().catch((e) => setError(reason(e))).finally(() => setBusy(false)); + }; return

{t("data.import_a_native_session")}

{t("data.import_from_a_session_file")} {t("data.keep_the_composition_version_and")} - {message && {message}} - {error && {error}}
{ - const file = event.target.files?.[0]; - if (!file) return; - setBusy(true); setError(""); setMessage(""); - void (async () => { - await hub.importNativeSession(file, workspace); - setMessage(t("data.session_imported_with_its_original")); - onImported(); - })().catch((e) => setError(reason(e))).finally(() => setBusy(false)); + take(event.target.files?.[0]); event.target.value = ""; }} /> -
+ {message &&
{message}
} + {error &&
{error}
} + {asking && createPortal( +
{ if (e.target === e.currentTarget) setAsking(false); }}> +
{ if (e.key === "Escape") { e.stopPropagation(); setAsking(false); } }}> +
+

{t("data.import_a_native_session")}

+ +
+

{t("data.keep_the_composition_version_and")}

+
{ e.preventDefault(); setOver(true); }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files?.[0]); }}> + + {t("data.drop_a_session_json_file_here")} + +
+
+
+
, + document.body, + )}
; } diff --git a/desktop/frontend-next/src/ui/ThemeImport.tsx b/desktop/frontend-next/src/ui/ThemeImport.tsx index 5a52a281d0..71c03e4075 100644 --- a/desktop/frontend-next/src/ui/ThemeImport.tsx +++ b/desktop/frontend-next/src/ui/ThemeImport.tsx @@ -100,7 +100,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) { {t(busy ? "common.importing" : "look.import_theme")} -
+

{note || t(empty ? "look.no_themes_installed_choose_a" : "look.choose_a_zip_or_select")}

@@ -121,7 +121,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) {
{failed && ( -
+
{failed}
)} diff --git a/desktop/frontend-next/src/ui/ThemeRemove.tsx b/desktop/frontend-next/src/ui/ThemeRemove.tsx index 5169c88634..0c2eb55efb 100644 --- a/desktop/frontend-next/src/ui/ThemeRemove.tsx +++ b/desktop/frontend-next/src/ui/ThemeRemove.tsx @@ -20,6 +20,7 @@ export function ThemeRemove({ name, onRemove }: { name: string; onRemove: () => type="button" className="btn sm pal-rm" data-action="theme.remove" + data-confirming={phase === "confirm" ? "" : undefined} aria-label={t("look.delete_theme_name", { name })} disabled={phase === "pending"} aria-busy={phase === "pending" || undefined}