gui, sessions: a moved subscription's other sign-in ways are a link away, ZCode's app's own account among them (Jinyu), and a session in a WSL distro is deleted as this computer's are (TJHHHH) — Jinyu asked to add a ZCode account from the one the ZCode app is signed in to rather than signing in again. The ZCode plugin (0.1.8) already does that (its "ZCode app's sign-in" reads ~/.zcode/v2/credentials.json), but a moved subscription's Add went straight to the plugin's first way (#777: one click), so it couldn't be reached. Now ZCode's site step shows its other ways beside the sites (the app's account, the GLM Coding Plan API key), and while a moved subscription's sign-in waits, Other ways to sign in… (when it has ways other than the one under way and Use an API key instead) cancels it and shows the plugin's ways; the owner's suggestion on #777 (suyu-ops). Factory, whose two ways are its device code and its key, still goes straight through with no extra link. No plugin release or mover change. With a sandbox copy of the owner's real ~/.zcode the plugin's way signed in (account, key, site zai). TJHHHH asked to delete sessions listed from WSL: they were resumable only, the page saying to delete them in WSL. Now they have their box and are deleted the same way: moved to magpie's trash over \\wsl.localhost (copied and removed across the device boundary), Claude Code's file history from the distro's ~/.claude, never this computer's; dropped from the distro's listing at once, which is listed again next; Restore puts the files back in the distro and lists it again. zh, ja, de strings; the WSL note's strings are gone. TestWSLSessionDeleted fails on the old code. On the Windows box against a sandbox folder in a running WSL2 distro (magpie-desktop), Delete moved the session, its subagents and its file history out of the distro into a %APPDATA% trash, and Restore put them back and listed it again (removed afterwards). plugin-other-ways.test.cjs (ZCode's ways at the site step and from the waiting box, Factory's one click) and sessions-wsl-delete.test.cjs (the WSL row's box, Delete in magpie's dialog posts its id, no note) fail on the old code and pass on WebKit in en and zh. sessions-folder-delete, sessions-manage, checkbox-look, plugin-moved-signin, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
460057a225
commit
653cb8ee30
8 files changed
+389
-24
No files matched your search
@@ -9052,6 +9052,18 @@ function renderSigning(sub) {
|
||||
b.onclick = () => startSignIn(sub.agent, true, id);
|
||||
box.append(b);
|
||||
}
|
||||
// a plugin's other ways, beside the sites: ZCode's app's own sign-in
|
||||
// (Jinyu: use the account ZCode is signed in to), its API key
|
||||
if (sub.plugin && sub.moved) {
|
||||
const sites = sub.sites.map(([, label]) => label);
|
||||
sub.plugin.methods.forEach((m, i) => {
|
||||
if (sites.some((l) => (m.label || "").includes(l))) return;
|
||||
const b = el("button", "text", t(m.label || (m.type === "api" ? "API key" : "Browser")));
|
||||
b.dataset.method = String(i);
|
||||
b.onclick = () => startPluginSignIn(sub, i);
|
||||
box.append(b);
|
||||
});
|
||||
}
|
||||
return box;
|
||||
}
|
||||
if (signing.state === "ghe") return renderCopilotGHE(sub);
|
||||
@@ -9175,6 +9187,21 @@ function renderSigning(sub) {
|
||||
};
|
||||
acts.append(k);
|
||||
}
|
||||
// a moved subscription goes straight to its plugin's usual way (#777:
|
||||
// one click, as the built-in did); ways other than that one and the key
|
||||
// above (ZCode's app's own sign-in) are a link away
|
||||
const others = sub.plugin && sub.moved && signing.state === "waiting"
|
||||
? (sub.plugin.methods || []).filter((_, i) => i !== signing.method && i !== keyWay).length : 0;
|
||||
if (others) {
|
||||
const acts = tt.querySelector(".acts") || tt.appendChild(el("span", "acts"));
|
||||
const other = el("button", "link other-ways", t("Other ways to sign in…"));
|
||||
other.onclick = () => {
|
||||
if (signing?.id) api("signin/" + signing.id + "/cancel", {}).catch(() => {});
|
||||
signing = { agent: sub.agent, state: "method" };
|
||||
renderProviders();
|
||||
};
|
||||
acts.append(other);
|
||||
}
|
||||
if (sub.importable) {
|
||||
// an account another tool is signed in to comes in from its file
|
||||
const imp = el("button", "link", importSay(sub.agent).instead || t("Import from a file instead…"));
|
||||
|
||||
@@ -890,6 +890,9 @@ const I18N = {
|
||||
"{name} is used through its own CLI, which isn't on this computer yet. magpie is installing it with the official installer; the sign-in page opens as soon as it's done.": "{name} 需通过自身 CLI 使用,本机尚未安装。正在用官方安装脚本安装,完成后立即打开登录页。",
|
||||
"magpie opened the sign-in page. The account shows up here as soon as you're done.": "已打开登录页,完成后账号会出现在这里。",
|
||||
"Open again": "重新打开",
|
||||
"Other ways to sign in…": "其他登录方式…",
|
||||
"ZCode app's sign-in": "使用 ZCode 应用已登录的账号",
|
||||
"GLM Coding Plan API key": "GLM Coding Plan API Key",
|
||||
"Stop waiting for this sign-in": "不再等待这次登录",
|
||||
"Copy link": "复制链接",
|
||||
"Sign-in link": "登录链接",
|
||||
@@ -1696,7 +1699,6 @@ const I18N = {
|
||||
"magpie can list {agent}'s sessions, but cannot resume or delete them.": "可列出 {agent} 的会话,但无法继续或删除。",
|
||||
"These {agent} sessions are read only; magpie can list them, but cannot resume or delete them.": "这些 {agent} 会话只读:可列出,但无法继续或删除。",
|
||||
"Some {agent} sessions are read only and cannot be deleted.": "部分 {agent} 会话为只读,无法删除。",
|
||||
"Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL.": "WSL 中的会话可以恢复,但不能在 magpie 里删除:请在 WSL 中删除。",
|
||||
"In WSL {distro}: resumed there through wsl.exe": "在 WSL {distro} 中:通过 wsl.exe 在那里恢复",
|
||||
"Select every session shown": "选中所有显示的会话",
|
||||
"Select": "选择",
|
||||
@@ -3992,6 +3994,9 @@ const I18N = {
|
||||
"{name} is used through its own CLI, which isn't on this computer yet. magpie is installing it with the official installer; the sign-in page opens as soon as it's done.": "{name} は専用の CLI を通じて使いますが、このコンピュータにはまだありません。magpie が公式インストーラでインストールしています。完了するとすぐにサインインページが開きます。",
|
||||
"magpie opened the sign-in page. The account shows up here as soon as you're done.": "magpie がサインインページを開きました。完了するとすぐにアカウントがここに表示されます。",
|
||||
"Open again": "もう一度開く",
|
||||
"Other ways to sign in…": "ほかの方法でサインイン…",
|
||||
"ZCode app's sign-in": "ZCode アプリでサインイン中のアカウント",
|
||||
"GLM Coding Plan API key": "GLM Coding Plan の API キー",
|
||||
"Stop waiting for this sign-in": "このサインインの待機をやめる",
|
||||
"Copy link": "リンクをコピー",
|
||||
"Sign-in link": "サインインリンク",
|
||||
@@ -4807,7 +4812,6 @@ const I18N = {
|
||||
"magpie can list {agent}'s sessions, but cannot resume or delete them.": "magpie は {agent} のセッションを一覧表示できますが、再開や削除はできません。",
|
||||
"These {agent} sessions are read only; magpie can list them, but cannot resume or delete them.": "これらの {agent} セッションは読み取り専用です。magpie は一覧表示できますが、再開や削除はできません。",
|
||||
"Some {agent} sessions are read only and cannot be deleted.": "一部の {agent} セッションは読み取り専用のため削除できません。",
|
||||
"Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL.": "WSL 内のセッションは再開できますが、magpie からは削除できません。WSL 内で削除してください。",
|
||||
"In WSL {distro}: resumed there through wsl.exe": "WSL {distro} 内:wsl.exe 経由でそこで再開します",
|
||||
"Select every session shown": "表示中のセッションをすべて選択",
|
||||
"Select": "選択",
|
||||
@@ -7086,6 +7090,9 @@ const I18N = {
|
||||
"{name} is used through its own CLI, which isn't on this computer yet. magpie is installing it with the official installer; the sign-in page opens as soon as it's done.": "{name} wird über seine eigene CLI verwendet, die auf diesem Computer noch fehlt. magpie installiert sie mit dem offiziellen Installer; die Anmeldeseite öffnet sich, sobald das erledigt ist.",
|
||||
"magpie opened the sign-in page. The account shows up here as soon as you're done.": "magpie hat die Anmeldeseite geöffnet. Das Konto erscheint hier, sobald Sie fertig sind.",
|
||||
"Open again": "Erneut öffnen",
|
||||
"Other ways to sign in…": "Auf andere Weise anmelden…",
|
||||
"ZCode app's sign-in": "Anmeldung der ZCode-App",
|
||||
"GLM Coding Plan API key": "GLM-Coding-Plan-API-Schlüssel",
|
||||
"Stop waiting for this sign-in": "Nicht mehr auf diese Anmeldung warten",
|
||||
"Copy link": "Link kopieren",
|
||||
"Sign-in link": "Anmeldelink",
|
||||
@@ -7901,7 +7908,6 @@ const I18N = {
|
||||
"magpie can list {agent}'s sessions, but cannot resume or delete them.": "magpie kann die Sitzungen von {agent} auflisten, aber weder fortsetzen noch löschen.",
|
||||
"These {agent} sessions are read only; magpie can list them, but cannot resume or delete them.": "Diese Sitzungen von {agent} sind schreibgeschützt; magpie kann sie auflisten, aber weder fortsetzen noch löschen.",
|
||||
"Some {agent} sessions are read only and cannot be deleted.": "Einige Sitzungen von {agent} sind schreibgeschützt und können nicht gelöscht werden.",
|
||||
"Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL.": "Sitzungen in WSL lassen sich fortsetzen, aber nicht aus magpie löschen: Löschen Sie sie in WSL.",
|
||||
"In WSL {distro}: resumed there through wsl.exe": "In WSL {distro}: wird dort über wsl.exe fortgesetzt",
|
||||
"Select every session shown": "Alle angezeigten Sitzungen auswählen",
|
||||
"Select": "Auswählen",
|
||||
|
||||
@@ -216,9 +216,6 @@
|
||||
: "magpie can list {agent}'s sessions, but cannot resume or delete them.";
|
||||
box.append(el("p", "usage-note sm-note", t(key, { agent: a.name })));
|
||||
}
|
||||
if (a?.deletable && data.sessions.some((s) => s.wsl)) {
|
||||
box.append(el("p", "usage-note sm-note", t("Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL.")));
|
||||
}
|
||||
box.append(selectBar(), el("div", "sm-tree"));
|
||||
queueMicrotask(redrawList);
|
||||
return box;
|
||||
@@ -229,7 +226,7 @@
|
||||
const bar = el("div", "row-head sm-bar");
|
||||
const a = current();
|
||||
if (!a?.deletable) { bar.hidden = true; return bar; }
|
||||
const list = shown().filter((s) => !s.read_only && !s.wsl);
|
||||
const list = shown().filter((s) => !s.read_only);
|
||||
const all = el("input", "sm-check");
|
||||
all.type = "checkbox";
|
||||
all.checked = list.length > 0 && list.every((s) => picked.has(s.id));
|
||||
@@ -289,7 +286,7 @@
|
||||
const open = !!q || opened.has(cwd);
|
||||
const g = el("div", "list sm-group");
|
||||
const r = el("div", "row sm-folder");
|
||||
const writable = items.filter((s) => !s.read_only && !s.wsl);
|
||||
const writable = items.filter((s) => !s.read_only);
|
||||
// the folder's box picks every session of it shown, folded or not,
|
||||
// for the bar's Delete; the bar still counts sessions (#527)
|
||||
if (current()?.deletable && writable.length) {
|
||||
@@ -333,7 +330,7 @@
|
||||
const wrap = el("div", "sess-item sm-item" + (detail === s.id ? " open" : ""));
|
||||
const r = el("div", "row sess sm-sess");
|
||||
r.dataset.id = s.id;
|
||||
if (a?.deletable && !s.read_only && !s.wsl) {
|
||||
if (a?.deletable && !s.read_only) {
|
||||
const c = el("input", "sm-check");
|
||||
c.type = "checkbox";
|
||||
c.checked = picked.has(s.id);
|
||||
@@ -376,7 +373,7 @@
|
||||
}
|
||||
}
|
||||
if (s.carry?.length) r.append(carryPick(s));
|
||||
if (a?.deletable && !s.read_only && !s.wsl) {
|
||||
if (a?.deletable && !s.read_only) {
|
||||
const del = el("button", "copy sm-del");
|
||||
del.type = "button";
|
||||
del.title = t("Delete");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A moved subscription still signs in with one click (#777), and its
|
||||
// plugin's other ways are offered beside it: ZCode's site step also offers
|
||||
// the ZCode app's own sign-in and the API key (Jinyu: add the account ZCode
|
||||
// is signed in to, not a sign-in page), and the waiting box of one with
|
||||
// ways besides the one under way and its key has "Other ways to sign in…",
|
||||
// which cancels that sign-in and shows the ways. Factory, with its real two
|
||||
// ways, still goes straight to its device code, its key the one link away.
|
||||
// English and Chinese; the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
|
||||
const plugins = [
|
||||
{ id: "zcode", pid: "zcode", name: "ZCode", icon: "generic", spec: "@magpie-community/opencode-zcode-auth", signedIn: false, models: 3,
|
||||
methods: [{ type: "oauth", label: "ZCode: Z.ai GLM Coding Plan" }, { type: "oauth", label: "ZCode: BigModel (智谱) GLM Coding Plan" }, { type: "oauth", label: "ZCode app's sign-in" }, { type: "api", label: "GLM Coding Plan API key" }] },
|
||||
{ id: "factory", pid: "factory", name: "Factory", icon: "generic", spec: "@magpie-community/opencode-factory-auth", signedIn: false, models: 3,
|
||||
methods: [{ type: "oauth", label: "Sign in with Factory (device code)" }, { type: "api", label: "Factory API key (fk-…)", placeholder: "fk-…" }] },
|
||||
];
|
||||
|
||||
function server(lang, asked) {
|
||||
const payload = () => ({ providers: [{ id: "deepseek", name: "DeepSeek", icon: "deepseek", chat: "https://api.deepseek.com/v1", models: [], agents: [], key: {} }], presets: [], excluded: [], gateway: { running: true, window: true }, plugins, onPlugins: ["zcode", "factory"] });
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
const body = () => route.request().postDataJSON();
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/providers") return json(payload());
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
|
||||
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
|
||||
if (url.pathname === "/api/plugin-signin/prompt") { asked.push(["prompt", body()]); return json({ prompt: null, inputs: {} }); }
|
||||
if (url.pathname === "/api/plugin-signin") {
|
||||
const b = body();
|
||||
asked.push(["signin", b]);
|
||||
return json({ id: "s" + asked.length, agent: b.provider, state: "waiting", url: "https://fake.test/device", code: b.provider === "factory" ? "ABCD-EFGH" : "" });
|
||||
}
|
||||
if (/^\/api\/signin\/s\d+\/cancel$/.test(url.pathname)) { asked.push(["cancel", url.pathname.split("/")[3]]); return json({}); }
|
||||
if (/^\/api\/signin\/s\d+$/.test(url.pathname)) return new Promise(() => {}); // stays waiting
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const L = {
|
||||
en: { anyway: "Sign in anyway", app: "ZCode app's sign-in", key: "GLM Coding Plan API key", other: "Other ways to sign in…", how: "How do you sign in to" },
|
||||
zh: { anyway: "仍然登录", app: "使用 ZCode 应用已登录的账号", key: "GLM Coding Plan API Key", other: "其他登录方式…", how: "用哪种方式登录" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": a moved subscription's other ways to sign in", async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
for (const lang of ["en", "zh"]) {
|
||||
await t.test(lang, async () => {
|
||||
const w = L[lang];
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], asked = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, asked));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator("#addProvider").click();
|
||||
const sheet = page.locator("#addSheet");
|
||||
const box = sheet.locator(".signing");
|
||||
const signins = () => asked.filter(([k]) => k === "signin").map(([, b]) => b);
|
||||
|
||||
// ZCode's site step: the two sites, then the app's sign-in and the key
|
||||
await sheet.locator('.tile[data-pick="ZCode"]').click();
|
||||
await box.locator('button[data-site="bigmodel"]').waitFor();
|
||||
const ways = await box.locator("button[data-site], button[data-method]").evaluateAll((bs) => bs.map((b) => b.dataset.site || "m" + b.dataset.method + ":" + b.textContent));
|
||||
assert.deepEqual(ways, ["zai", "bigmodel", "m2:" + w.app, "m3:" + w.key]);
|
||||
if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-other-ways-zcode-${engine}-${lang}.png`) });
|
||||
|
||||
// the app's sign-in signs in with the plugin's way 2, no page to open
|
||||
await box.locator('button[data-method="2"]').click();
|
||||
await box.locator("button.other-ways").waitFor();
|
||||
assert.deepEqual(signins(), [{ provider: "zcode", method: 2, inputs: {} }]);
|
||||
if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-other-ways-waiting-${engine}-${lang}.png`) });
|
||||
|
||||
// "Other ways to sign in…" cancels it and shows every way
|
||||
await box.locator("button.other-ways", { hasText: w.other }).click();
|
||||
await box.locator("span.n", { hasText: w.how }).waitFor();
|
||||
assert.deepEqual(asked.filter(([k]) => k === "cancel").map(([, id]) => id), ["s2"]);
|
||||
assert.equal(await box.locator(".choices button[data-method]").count(), 4);
|
||||
await box.locator("button.text:not(.primary)").first().click(); // Cancel
|
||||
|
||||
// Factory, two ways: still one click to its device code, no picker;
|
||||
// its other way is the key link it had, with no "Other ways" beside it
|
||||
asked.length = 0;
|
||||
await sheet.locator('.tile[data-pick="Factory"]').click();
|
||||
await box.locator("button", { hasText: w.anyway }).click();
|
||||
await box.locator(".devcode code", { hasText: "ABCD-EFGH" }).waitFor();
|
||||
assert.deepEqual(signins(), [{ provider: "factory", method: 0, inputs: {} }]);
|
||||
assert.equal(await box.locator(".choices").count(), 0);
|
||||
assert.equal(await box.locator("button.other-ways").count(), 0);
|
||||
if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-other-ways-factory-${engine}-${lang}.png`) });
|
||||
await box.locator(".acts button.link").last().click();
|
||||
await box.locator('input[type="password"]').waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A session in a WSL distro is deleted from the Sessions page as this
|
||||
// computer's are (TJHHHH: 请问是否可以增加wsl内对于会话的删除呢): its row has
|
||||
// its box beside the WSL badge, its folder's box picks it, and the bar's
|
||||
// Delete asks in magpie's own dialog and posts sessions/delete with its id;
|
||||
// no note says WSL's can't be deleted. In English and Chinese; no backend,
|
||||
// the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const at = (min) => new Date(Date.now() - min * 60e3).toISOString();
|
||||
const sess = (id, title, min, wsl) => ({
|
||||
agent: "claude", id, cwd: "/work/app", title, start: at(min + 5), last: at(min), models: [], wsl,
|
||||
resume: wsl ? `wsl.exe -d 'Ubuntu' --cd '/work/app' -e sh -lc 'exec \${SHELL:-sh} -lic ''claude --resume ${id}'''` : `cd /work/app && claude --resume ${id}`,
|
||||
path: wsl ? `\\\\wsl.localhost\\Ubuntu\\home\\me\\.claude\\projects\\-work-app\\${id}.jsonl` : `~/.claude/projects/-work-app/${id}.jsonl`,
|
||||
size: 4096, messages: 6, files: 1, deletable: true,
|
||||
});
|
||||
|
||||
function serve(lang, calls) {
|
||||
let sessions = [sess("w-1", "in the distro", 30, "Ubuntu"), sess("l-1", "on windows", 60)];
|
||||
return async (route) => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/sessions/manage") {
|
||||
return json({ agents: [{ agent: "claude", count: sessions.length, deletable: true, name: "Claude Code", icon: "claude" }],
|
||||
agent: "claude", sessions, terminal: false, trash: [], trashDir: "C:\\Users\\me\\AppData\\Roaming\\magpie\\trash\\sessions" });
|
||||
}
|
||||
if (url.pathname === "/api/sessions/delete") {
|
||||
const body = req.postDataJSON();
|
||||
calls.push(body);
|
||||
sessions = sessions.filter((s) => !body.ids.includes(s.id));
|
||||
return json({ deleted: body.ids, refused: [] });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { nav: "Sessions", del: "Delete", picked: "1 selected", note: "Sessions in WSL" },
|
||||
zh: { nav: "会话", del: "删除", picked: "已选 1 个", note: "WSL 中的会话" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: a session in WSL is deleted as this computer's are`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 560 }, reducedMotion: "reduce" })).newPage();
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-sessions-wsl-delete.png`) });
|
||||
await browser.close();
|
||||
});
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("dialog", (d) => { errors.push("a browser dialog: " + d.message()); d.dismiss(); });
|
||||
const calls = [];
|
||||
await page.route("**/*", serve(lang, calls));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator("#nav").getByRole("button", { name: w.nav, exact: true }).click();
|
||||
const view = page.locator("#view-sessions");
|
||||
const row = view.locator('.row.sm-sess[data-id="w-1"]');
|
||||
await view.locator(".row.sm-folder").first().waitFor();
|
||||
if (!(await row.isVisible())) await view.locator(".row.sm-folder .fold").first().click();
|
||||
await row.waitFor();
|
||||
|
||||
assert.equal(await row.locator(".badge.wsl").textContent(), "WSL Ubuntu");
|
||||
assert.equal(await view.locator(".sm-note", { hasText: w.note }).count(), 0, "no note that WSL's can't be deleted");
|
||||
assert.equal(await view.locator(".sm-bar").isHidden(), false);
|
||||
const folderBox = view.locator(".row.sm-folder input.sm-folder-check");
|
||||
assert.equal(await folderBox.count(), 1);
|
||||
|
||||
// its own box picks it, and Delete posts it after the dialog
|
||||
const box = row.locator("input.sm-check");
|
||||
assert.equal(await box.count(), 1, "the WSL session has its box");
|
||||
await box.check();
|
||||
assert.equal((await view.locator(".sm-bar .sm-count").textContent()).trim(), w.picked);
|
||||
await view.locator(".sm-bar .sm-delete").click();
|
||||
const ask = page.locator("#modal .sm-ask");
|
||||
await ask.waitFor();
|
||||
assert.deepEqual(calls, []);
|
||||
await ask.getByRole("button", { name: w.del, exact: true }).click();
|
||||
await ask.waitFor({ state: "detached" });
|
||||
await page.waitForFunction(() => !document.querySelector('.row.sm-sess[data-id="w-1"]'));
|
||||
assert.deepEqual(calls, [{ agent: "claude", ids: ["w-1"] }]);
|
||||
assert.equal(await view.locator('.row.sm-sess[data-id="l-1"]').count(), 1);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,8 @@ func ListAgent(agent string) []Managed {
|
||||
s.Carry = carries(s)
|
||||
}
|
||||
s.Transcript = HasTranscript(s.Agent)
|
||||
// a WSL distro's are listed and resumed, not deleted: magpie moves
|
||||
// no files out of a distro
|
||||
m := Managed{Session: s, Files: len(fs), Deletable: Deletable(agent) && !s.ReadOnly && s.WSL == ""}
|
||||
// a WSL distro's too (TJHHHH): moved out over \\wsl.localhost
|
||||
m := Managed{Session: s, Files: len(fs), Deletable: Deletable(agent) && !s.ReadOnly}
|
||||
for _, f := range fs {
|
||||
m.Size += f.size
|
||||
if st := cache[f.path]; st != nil && f.main {
|
||||
@@ -175,7 +174,9 @@ const manifest = "session.json"
|
||||
// Codex session's later segments, its subagents' elsewhere) and, for Claude
|
||||
// Code, the file history, todos and environment it keeps by the session's
|
||||
// id. A session any file of which was written in the last minute is not
|
||||
// touched (ErrActive).
|
||||
// touched (ErrActive). One in a WSL distro is moved out of it over
|
||||
// \\wsl.localhost the same way, its Claude Code files from the distro's
|
||||
// ~/.claude, and Restore puts it back there.
|
||||
func Delete(agent, id string) (Trashed, error) {
|
||||
if !Deletable(agent) {
|
||||
return Trashed{}, fmt.Errorf("magpie can't delete %s sessions", agent)
|
||||
@@ -212,10 +213,12 @@ func Delete(agent, id string) (Trashed, error) {
|
||||
if len(fs) == 0 {
|
||||
return Trashed{}, errors.New("no such session")
|
||||
}
|
||||
if s.WSL != "" {
|
||||
return Trashed{}, fmt.Errorf("magpie doesn't delete sessions in WSL %s: delete it there", s.WSL)
|
||||
}
|
||||
paths := sessionPaths(agent, id, fs)
|
||||
if len(paths) == 0 {
|
||||
// a stopped distro's listing, of files since gone
|
||||
wslForget(fs)
|
||||
return Trashed{}, errors.New("no such session")
|
||||
}
|
||||
now := time.Now()
|
||||
for _, p := range paths {
|
||||
if recent(p, now) {
|
||||
@@ -252,6 +255,7 @@ func Delete(agent, id string) (Trashed, error) {
|
||||
delete(cache, f.path)
|
||||
}
|
||||
saveCache()
|
||||
wslForget(fs)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -304,6 +308,13 @@ func sessionPaths(agent, id string, fs []file) []string {
|
||||
}
|
||||
if agent == "claude" {
|
||||
dir := ClaudeDir()
|
||||
if fs[0].wsl != "" {
|
||||
// the distro's own ~/.claude
|
||||
if dir = wslHomeOf(fs[0].wsl); dir == "" {
|
||||
return out
|
||||
}
|
||||
dir = filepath.Join(dir, ".claude")
|
||||
}
|
||||
add(filepath.Join(dir, "file-history", id))
|
||||
add(filepath.Join(dir, "session-env", id))
|
||||
todos, _ := filepath.Glob(filepath.Join(dir, "todos", id+"-*.json"))
|
||||
@@ -511,5 +522,6 @@ func Restore(key string) (Trashed, error) {
|
||||
}
|
||||
}
|
||||
os.RemoveAll(dir)
|
||||
wslRelistNow(t.Items)
|
||||
return t, nil
|
||||
}
|
||||
@@ -265,6 +265,76 @@ func wslDirs() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// wslHomeOf is the home of a listed distro, "" when it isn't.
|
||||
func wslHomeOf(distro string) string {
|
||||
wslSess.Lock()
|
||||
defer wslSess.Unlock()
|
||||
if l := wslSess.lists[distro]; l != nil {
|
||||
return l.Home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// wslForget drops a deleted session's files from their distro's listing,
|
||||
// and has the next read list the distros again (which keeps that on disk).
|
||||
func wslForget(fs []file) {
|
||||
gone := map[string]bool{}
|
||||
for _, f := range fs {
|
||||
if f.wsl != "" {
|
||||
gone[f.path] = true
|
||||
}
|
||||
}
|
||||
if len(gone) == 0 {
|
||||
return
|
||||
}
|
||||
wslSess.Lock()
|
||||
defer wslSess.Unlock()
|
||||
for _, l := range wslSess.lists {
|
||||
var kept []wslEntry
|
||||
for _, e := range l.Files {
|
||||
if !gone[e.Path] {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
l.Files = kept
|
||||
}
|
||||
wslSess.at = time.Time{}
|
||||
}
|
||||
|
||||
// wslRelistNow lists the distros again when a restored session's files
|
||||
// went back into one, after any listing under way (which may have missed
|
||||
// them), waiting for it as a first listing is waited for: the session is
|
||||
// listed when the page asks next.
|
||||
func wslRelistNow(items []moved) {
|
||||
if WSLHomes == nil {
|
||||
return
|
||||
}
|
||||
wslSess.Lock()
|
||||
in := false
|
||||
for _, l := range wslSess.lists {
|
||||
for _, m := range items {
|
||||
in = in || under(m.From, l.Home)
|
||||
}
|
||||
}
|
||||
busy := wslSess.done
|
||||
wslSess.Unlock()
|
||||
if !in {
|
||||
return
|
||||
}
|
||||
if busy != nil {
|
||||
<-busy
|
||||
}
|
||||
done := make(chan struct{})
|
||||
wslSess.Lock()
|
||||
wslSess.done, wslSess.at = done, time.Now()
|
||||
go wslList(wslSess.gen, done)
|
||||
wslSess.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(wslFirstWait):
|
||||
}
|
||||
}
|
||||
|
||||
// wslReset forgets the listings, in memory.
|
||||
func wslReset() {
|
||||
wslSess.Lock()
|
||||
|
||||
@@ -78,19 +78,58 @@ func TestWSLSessionsListed(t *testing.T) {
|
||||
if !strings.Contains(dirs, filepath.Join(home, ".claude")) || !strings.Contains(dirs, filepath.Join(home, ".codex")) {
|
||||
t.Fatalf("dirs %s", dirs)
|
||||
}
|
||||
}
|
||||
|
||||
// listed and resumed, never deleted from Windows
|
||||
for _, m := range ListAgent("claude") {
|
||||
if m.Deletable {
|
||||
t.Fatalf("a WSL session deletable: %+v", m)
|
||||
// A session in a WSL distro is deleted as this computer's are (TJHHHH: 请问
|
||||
// 是否可以增加wsl内对于会话的删除呢): moved to magpie's trash, its Claude Code
|
||||
// files from the distro's ~/.claude and not this computer's, gone from the
|
||||
// listing at once; restored, it is back in the distro and listed again.
|
||||
func TestWSLSessionDeleted(t *testing.T) {
|
||||
home, _ := wslDistro(t)
|
||||
const id = "11111111-2222-3333-4444-555555555555"
|
||||
own := os.Getenv("CLAUDE_CONFIG_DIR")
|
||||
distro := filepath.Join(home, ".claude")
|
||||
for _, p := range []string{filepath.Join(distro, "file-history", id, "a@v1"), filepath.Join(own, "file-history", id, "a@v1")} {
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("x"), 0o644)
|
||||
}
|
||||
old := time.Now().Add(-time.Hour)
|
||||
filepath.WalkDir(home, func(p string, _ os.DirEntry, _ error) error { return os.Chtimes(p, old, old) })
|
||||
List(0)
|
||||
wslSettle()
|
||||
m, ok := findManaged(ListAgent("claude"), id)
|
||||
if !ok || m.WSL != "Ubuntu" || !m.Deletable {
|
||||
t.Fatalf("the WSL session listed as %+v, %v", m, ok)
|
||||
}
|
||||
tr, err := Delete("claude", id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{m.Path, filepath.Join(distro, "file-history", id)} {
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
t.Fatalf("%s is still there", p)
|
||||
}
|
||||
}
|
||||
if _, err := Delete("claude", cc.ID); err == nil || !strings.Contains(err.Error(), "WSL Ubuntu") {
|
||||
t.Fatalf("delete: %v", err)
|
||||
if _, err := os.Lstat(filepath.Join(own, "file-history", id, "a@v1")); err != nil {
|
||||
t.Fatal("this computer's own file history was moved")
|
||||
}
|
||||
if _, err := os.Stat(cc.Path); err != nil {
|
||||
if _, ok := findManaged(ListAgent("claude"), id); ok {
|
||||
t.Fatal("still listed")
|
||||
}
|
||||
if trash := Trash(); len(trash) != 1 || trash[0].Key != tr.Key {
|
||||
t.Fatalf("trash %+v", trash)
|
||||
}
|
||||
if _, err := Restore(tr.Key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{m.Path, filepath.Join(distro, "file-history", id, "a@v1")} {
|
||||
if _, err := os.Lstat(p); err != nil {
|
||||
t.Fatalf("%s wasn't restored", p)
|
||||
}
|
||||
}
|
||||
if m, ok := findManaged(ListAgent("claude"), id); !ok || m.WSL != "Ubuntu" {
|
||||
t.Fatalf("restored session listed as %+v, %v", m, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A stopped distro's sessions are listed as last read, and nothing in it is
|
||||
|
||||
Reference in new issue
Block a user