feat(studio): remote machines as cards with one next step and the on/off in the menu

This commit is contained in:
esengine committed 2026-11-05 08:23:54 +00:00
1 parent c8c4162818
commit b9c03d4a95
4 files changed
+74 -44

No files matched your search

+20 -9
View File
@@ -197,17 +197,28 @@
.skupd > .acts { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin: 4px -16px -14px; padding: 10px 16px; border-top: 1px solid var(--hair); }
.skupd > .acts .note { flex: 1; min-width: 0; color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); }
.rmtbook { display: flex; flex-direction: column; gap: 8px; }
.rmtbook > .rmtrow { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); }
.rmtrow > .hd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
.rmtrow > .hd > b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
.rmtrow > .hd > .tg { color: var(--faint); font: var(--w-med) var(--fs-meta) var(--mono); }
.rmtrow > .hd > .st { flex: none; min-width: 0; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-med) var(--fs-micro)/1.8 var(--ui); }
.rmtrow[data-state="connected"] > .hd > .st { background: color-mix(in srgb, var(--ok) 12%, transparent); color: var(--ok-ink); }
.rmtrow[data-state="stopped"] > .hd > .st { background: color-mix(in srgb, var(--err) 10%, transparent); color: var(--err-ink); }
.rmtfill { flex: 1; }
.rmthead { display: flex; justify-content: flex-end; }
.rmthead .rmtadd { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; }
.rmthead .rmtadd .studio-icon { width: 13px; height: 13px; }
.rmtbook > .rmtrow { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); }
.rmtbook > .rmtrow > :not(.rmticon, .rmtmain, .rmtside) { grid-column: 1 / -1; }
.rmticon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--r-sm); background: var(--overlay); color: var(--muted); }
.rmticon .studio-icon { width: 15px; height: 15px; }
.rmtmain { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rmtrow > .rmtmain > .hd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
.rmtrow .hd > b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
.rmtrow .hd > .tg { color: var(--faint); font: var(--w-med) var(--fs-meta) var(--mono); }
.rmtrow .hd > .st { display: inline-flex; flex: none; align-items: center; gap: 5px; min-width: 0; padding: 0 8px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-med) var(--fs-micro)/1.8 var(--ui); }
.rmtrow .hd > .st::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.rmtrow .hd > .st[data-state="connected"] { background: color-mix(in srgb, var(--ok) 12%, transparent); color: var(--ok-ink); }
.rmtrow .hd > .st:is([data-state="connecting"], [data-state="reconnecting"]) { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent-ink); }
.rmtrow .hd > .st:is([data-state="degraded"], [data-state="idle"]) { background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn-ink); }
.rmtrow .hd > .st[data-state="stopped"] { background: color-mix(in srgb, var(--err) 10%, transparent); color: var(--err-ink); }
.rmtrow[data-off] .rmtmain { opacity: .6; }
.rmtside { display: flex; align-items: center; gap: 6px; }
.rmtnext { height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; }
.rmtnext:disabled { opacity: .5; cursor: default; }
.rmtrow > .sub { margin-top: 4px; color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); }
.rmtrow .sub { padding: 0; color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); }
.rmthost-more { position: relative; display: inline-flex; }
.rmthost-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); cursor: pointer; }
.rmthost-btn:hover, .rmthost-btn[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
+29 -29
View File
@@ -5,7 +5,6 @@ import type { RemoteHost, RemoteHostEdit, RemoteProbe, WSLDistros } from "../por
import { say } from "../i18n/kernel";
import { toggleHiddenHost, useHiddenHosts } from "../state/remotehide";
import { RemoteDirs } from "./RemoteDirs";
import { Switch } from "./Switch";
import { useDismiss } from "./dismiss";
import { StudioIcon } from "./StudioIcon";
@@ -154,30 +153,45 @@ export function Remotes({ hub, onError }: Props) {
return (
<div className="rmtbook">
{!draft && (
<div className="rmthead">
<button className="rmtadd" onClick={() => setDraft(draftOf(null))}>
<StudioIcon name="plus" />{t("remote.add_a_machine")}
</button>
</div>
)}
{hosts.map((host) => (
<div className="rmtrow" key={host.name} data-state={host.status}>
<div className="rmtrow" key={host.name} data-state={host.status} data-off={host.disabled ? "" : undefined}>
<span className="rmticon" aria-hidden="true"><StudioIcon name="server" /></span>
<div className="rmtmain">
<div className="hd">
<i className="rmtpip" aria-hidden="true" />
<b>{host.name}</b>
<span className="tg">{host.target}</span>
<span className="st">{t(STATUS_LABEL[host.status] ?? host.status)}</span>
{host.disabled ? <span className="st">{t("common.suspended")}</span> : null}
<span className="rmtfill" />
<span className="st" data-state={host.disabled ? "off" : host.status}>{t(host.disabled ? "common.suspended" : (STATUS_LABEL[host.status] ?? host.status))}</span>
</div>
<div className="sub">
{host.workspace ? <span dir="ltr">{host.workspace}</span> : <span className="dim">{t("remote.no_default_workspace")}</span>}
{workspacesOf(host).length > 1 ? (
<span className="tag">{t("remote.n_more_projects", { n: workspacesOf(host).length - 1 })}</span>
) : null}
{host.kind === "wsl" ? <span className="tag">{t("remote.wsl_distribution")}</span> : null}
{host.useSSHConfig ? <span className="tag">{t("remote.follows_ssh_config")}</span> : null}
{host.forwards ? <span className="tag">{t("remote.n_forwards", { n: host.forwards })}</span> : null}
{host.panes ? <span className="tag">{t("remote.n_panes", { n: host.panes })}</span> : null}
</div>
</div>
<div className="rmtside">
{host.status !== "connected" && !host.disabled && (
<button className="rmtnext" data-action="remote.probe" data-target={host.name} disabled={probing === host.name} onClick={() => probe(host.name)}>
{t(probing === host.name ? "remote.asking" : "common.test_it")}
</button>
)}
<Switch
data-action="remote.enable"
data-target={host.name}
on={!host.disabled}
busy={busy === host.name}
label={t(host.disabled ? "remote.turn_this_machine_on" : "remote.turn_this_machine_off")}
onClick={() => void save({ ...draftOf(host), disabled: !host.disabled })}
/>
<RemoteMore name={host.name}>
{(close) => <>
<button role="menuitem" data-action="remote.enable" data-target={host.name} disabled={busy === host.name}
onClick={() => { close(); void save({ ...draftOf(host), disabled: !host.disabled }); }}>
{t(host.disabled ? "remote.turn_this_machine_on" : "remote.turn_this_machine_off")}
</button>
<button role="menuitem" data-action="remote.edit" data-target={host.name} onClick={() => { close(); setEditing(host.name); setDraft(draftOf(host)); }}>
{t("common.edit")}
</button>
@@ -195,16 +209,6 @@ export function Remotes({ hub, onError }: Props) {
</>}
</RemoteMore>
</div>
<div className="sub">
{host.workspace ? <span dir="ltr">{host.workspace}</span> : <span className="dim">{t("remote.no_default_workspace")}</span>}
{workspacesOf(host).length > 1 ? (
<span className="tag">{t("remote.n_more_projects", { n: workspacesOf(host).length - 1 })}</span>
) : null}
{host.kind === "wsl" ? <span className="tag">{t("remote.wsl_distribution")}</span> : null}
{host.useSSHConfig ? <span className="tag">{t("remote.follows_ssh_config")}</span> : null}
{host.forwards ? <span className="tag">{t("remote.n_forwards", { n: host.forwards })}</span> : null}
{host.panes ? <span className="tag">{t("remote.n_panes", { n: host.panes })}</span> : null}
</div>
{probes[host.name] ? <ProbeCard probe={probes[host.name]} host={host.name} /> : null}
{confirm === host.name && (
<div className="rmtconfirm" role="alertdialog">
@@ -427,11 +431,7 @@ export function Remotes({ hub, onError }: Props) {
</button>
</div>
</div>
) : (
<button className="rmtadd" onClick={() => setDraft(draftOf(null))}>
+ {t("remote.add_a_machine")}
</button>
)}
) : null}
</div>
);
}
@@ -28,10 +28,11 @@ function draw(saveRemoteHost = vi.fn(async (_entry: RemoteHostEdit): Promise<Rem
// row's only switch: whether the rail lists it is a separate preference, and a
// separate row action, because that one commits on click while a control inside
// the edit form would only commit on Save.
describe("the switch that turns a machine off", () => {
describe("the menu item that turns a machine off", () => {
it("saves the whole row with the flag flipped", async () => {
const save = draw();
await userEvent.click(await screen.findByRole("switch", { name: "停用这台主机" }));
await userEvent.click(await screen.findByRole("button", { name: /^更多:/ }));
await userEvent.click(screen.getByRole("menuitem", { name: "停用这台主机" }));
await waitFor(() => expect(save).toHaveBeenCalled());
const entry = save.mock.calls[0][0] as RemoteHostEdit;
expect(entry.name).toBe("gpu-box");
@@ -40,12 +41,29 @@ describe("the switch that turns a machine off", () => {
it("keeps the hide preference to one control, out of the edit form", async () => {
draw();
await screen.findByRole("switch", { name: "停用这台主机" });
await userEvent.click(screen.getByRole("button", { name: /^更多:/ }));
await userEvent.click(await screen.findByRole("button", { name: /^更多:/ }));
expect(document.querySelectorAll('[data-action="remote.hide"]')).toHaveLength(1);
});
});
describe("a machine card", () => {
it("names the link in one status pill and offers the next step on the card, not a switch", async () => {
draw();
await screen.findByText("gpu-box");
expect(document.querySelector(".rmtrow .st")?.getAttribute("data-state")).toBe("idle");
expect(document.querySelectorAll('.rmtrow > .rmtside [data-action="remote.probe"]')).toHaveLength(1);
expect(screen.queryByRole("switch")).toBeNull();
});
it("puts the add button above the list", async () => {
draw();
await screen.findByText("gpu-box");
const add = document.querySelector(".rmthead .rmtadd");
expect(add).not.toBeNull();
expect(document.querySelectorAll(".rmtbook > .rmtadd")).toHaveLength(0);
});
});
describe("the machines found in ~/.ssh/config", () => {
it("adds every one of them from one button, each through the ssh config", async () => {
const saveRemoteHost = vi.fn(async (_entry: RemoteHostEdit): Promise<RemoteHost[]> => []);
@@ -93,9 +93,10 @@ describe("a saved WSL machine", () => {
expect((hub.saveRemoteHost as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({ name: "dev", kind: "wsl", distro: "Ubuntu-24.04" });
});
it("keeps its row when the switch turns it off, sending the whole row back", async () => {
it("keeps its row when the menu turns it off, sending the whole row back", async () => {
const { hub } = draw({}, [dev]);
await userEvent.click(await screen.findByRole("switch", { name: /停用这台主机/ }));
await userEvent.click(await screen.findByRole("button", { name: /^更多:/ }));
await userEvent.click(screen.getByRole("menuitem", { name: /停用这台主机/ }));
await waitFor(() => expect(hub.saveRemoteHost).toHaveBeenCalledOnce());
expect((hub.saveRemoteHost as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({ name: "dev", kind: "wsl", distro: "Ubuntu-24.04", disabled: true });
});