diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index a4dcbffd..c33bb6d3 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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…")); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 790ed7a7..489fd5c6 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/sessions.js b/internal/gui/assets/sessions.js index 6dc24975..116f4a35 100644 --- a/internal/gui/assets/sessions.js +++ b/internal/gui/assets/sessions.js @@ -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"); diff --git a/internal/gui/tests/plugin-other-ways.test.cjs b/internal/gui/tests/plugin-other-ways.test.cjs new file mode 100644 index 00000000..7ee0f730 --- /dev/null +++ b/internal/gui/tests/plugin-other-ways.test.cjs @@ -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, []); + }); + } + }); +} diff --git a/internal/gui/tests/sessions-wsl-delete.test.cjs b/internal/gui/tests/sessions-wsl-delete.test.cjs new file mode 100644 index 00000000..f9674455 --- /dev/null +++ b/internal/gui/tests/sessions-wsl-delete.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/sessions/manage.go b/internal/sessions/manage.go index 887d543b..524329db 100644 --- a/internal/sessions/manage.go +++ b/internal/sessions/manage.go @@ -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 } diff --git a/internal/sessions/wsl.go b/internal/sessions/wsl.go index cda70167..9cae48b0 100644 --- a/internal/sessions/wsl.go +++ b/internal/sessions/wsl.go @@ -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() diff --git a/internal/sessions/wsl_test.go b/internal/sessions/wsl_test.go index 2ed1a219..98132d5f 100644 --- a/internal/sessions/wsl_test.go +++ b/internal/sessions/wsl_test.go @@ -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