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

This commit is contained in:
yhh committed 2026-11-06 02:03:09 -08:00
1 parent 851f5fe19f
commit c8eb8693fa
7 files changed
+116 -14

No files matched your search

@@ -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); }
@@ -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\)/);
});
});
@@ -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…" },
@@ -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(<NativeSessionImport hub={hub} workspace="/workspace" onImported={vi.fn()}/>);
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(<NativeSessionImport hub={hub} workspace="/workspace" onImported={vi.fn()}/>);
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 = () => {};
@@ -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<HTMLInputElement>(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 <section className="grp">
<h3 className="lbl">{t("data.import_a_native_session")}</h3>
<div className="item">
<div className="l">
<b>{t("data.import_from_a_session_file")}</b>
<span className="hint">{t("data.keep_the_composition_version_and")}</span>
{message && <span className="ready" role="status">{message}</span>}
{error && <span className="held" role="alert">{error}</span>}
</div>
<input ref={input} hidden type="file" accept="application/json,.json" aria-label={t("data.choose_a_session_json_file")} data-action-change="session.import-native" disabled={busy || !workspace} onChange={(event) => {
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 = "";
}} />
<button className="btn" data-action="session.import-native" disabled={busy || !workspace} onClick={() => input.current?.click()}>
<button className="btn" data-action="session.import-native" disabled={busy || !workspace} onClick={() => setAsking(true)}>
{t(busy ? "common.importing" : "data.choose_a_session_file")}
</button>
</div>
{message && <div className="ns-done" role="status"><StudioIcon name="check" />{message}</div>}
{error && <div className="ns-fail" role="alert"><StudioIcon name="warning" />{error}</div>}
{asking && createPortal(
<div className="ns-veil" onMouseDown={(e) => { if (e.target === e.currentTarget) setAsking(false); }}>
<div className="ns-dlg" role="dialog" aria-modal="true" aria-labelledby="ns-title"
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); setAsking(false); } }}>
<header>
<h3 id="ns-title">{t("data.import_a_native_session")}</h3>
<button type="button" className="ns-x" aria-label={t("common.close")} title={t("common.close")} onClick={() => setAsking(false)}><StudioIcon name="close" /></button>
</header>
<p>{t("data.keep_the_composition_version_and")}</p>
<div className="ns-drop" data-over={over ? "" : undefined}
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files?.[0]); }}>
<StudioIcon name="file" />
<span>{t("data.drop_a_session_json_file_here")}</span>
<button type="button" className="btn" autoFocus data-action="session.import-native-choose" onClick={() => input.current?.click()}>{t("data.choose_a_session_json_file")}</button>
</div>
<footer><button type="button" className="ns-cancel" onClick={() => setAsking(false)}>{t("common.cancel")}</button></footer>
</div>
</div>,
document.body,
)}
</section>;
}
+2 -2
View File
@@ -100,7 +100,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) {
</span>
{t(busy ? "common.importing" : "look.import_theme")}
</button>
<div className="themeacts">
<div className="themeacts" data-landed={landed ? "" : undefined}>
<p className="note" role="status" aria-atomic="true">
{note || t(empty ? "look.no_themes_installed_choose_a" : "look.choose_a_zip_or_select")}
</p>
@@ -121,7 +121,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) {
</button>
</div>
{failed && (
<div className="find" data-lvl="err" role="alert">
<div className="find themefail" data-lvl="err" role="alert">
<span className="t">{failed}</span>
</div>
)}
@@ -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}