feat(desktop): remote machines as cards with the next step on them

Each machine is a card: its dot, name, address and state as a tag on one
line, its folder and counts under it. A machine that is not connected
offers Test it right on the card; edit, test, hide from the rail and
remove move into the card's More menu, and the switch stays in view.
Machines found in ~/.ssh/config can be added together with Add them all.

The market install card's close button now names what it dismisses, so
it is not mistaken for the Settings sheet's own Close.
This commit is contained in:
esengine committed 2026-11-05 01:54:50 +00:00
1 parent 14e6b7c264
commit 5f937aa60d
9 files changed
+112 -36

No files matched your search

+4
View File
@@ -440,6 +440,10 @@ export const ACTIONS: UIAction[] = [
// asking twice is the point when the first answer was "not yet".
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.more", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.edit", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.remove-ask", kind: "view", target: "entity", proof: "interaction" },
{ id: "remotes.add-all", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -196,3 +196,26 @@
.skupd .rgrp[data-lvl="high"] { padding-inline-start: 0; box-shadow: none; }
.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; }
.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); }
.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); }
.rmthost-btn .studio-icon { width: 14px; height: 14px; }
.rmthost-menu {
position: absolute; inset-inline-end: 0; top: calc(100% + 4px); z-index: 40; display: flex; flex-direction: column; min-width: 180px; padding: 4px;
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float);
}
.rmthost-menu > button { min-height: 30px; padding: 4px 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); text-align: start; white-space: nowrap; cursor: pointer; }
.rmthost-menu > button:hover:not(:disabled) { background: var(--overlay); }
.rmthost-menu > button[data-danger] { color: var(--err-ink); }
@@ -426,6 +426,7 @@ export const EXT: Messages = {
"ext.you_cannot_vote_on_a": { zh: "不能评价自己发布的包", en: "You cannot vote on a package you published" },
"ext.you_chose_to_trust_this": { zh: "你选择了信任这个发布者", en: "You chose to trust this publisher" },
"ext.more_for_name": { zh: "更多:{name}", en: "More for {name}" },
"ext.close_install_confirmation": { zh: "关掉安装确认", en: "Dismiss the install confirmation" },
"ext.add_menu": { zh: "添加…", en: "Add…" },
"ext.add_from_market": { zh: "从市场安装", en: "Install from the market" },
"ext.add_from_market_hint": { zh: "浏览别人分享的插件", en: "Browse what others share" },
@@ -236,4 +236,6 @@ export const REMOTE: Messages = {
"remote.write_what_to_tell_the": { zh: "写下要告诉另一个会话的话。对方的用户批准后,它才会交给那边的模型。", en: "Write what to tell the other session. It reaches that session’s model only after its user approves." },
"remote.wsl_distribution": { zh: "WSL 发行版", en: "WSL distribution" },
"remote.youre_offline": { zh: "网络已断开", en: "You're offline" },
"remote.add_them_all": { zh: "全部添加", en: "Add them all" },
"remote.more_for_name": { zh: "更多:编辑、测试连接、在侧栏隐藏、移除 {name}", en: "More for {name}: edit, test, hide from the rail, remove" },
};
@@ -79,10 +79,12 @@ describe("market author navigation keys", () => {
await screen.findByText("ship-notes");
expect(mine).toHaveBeenCalledTimes(1);
await userEvent.keyboard("{ArrowRight}");
for (let i = 0; i < 5 && document.activeElement !== nameInput(); i++) await userEvent.tab();
const sourceInput = () => screen.getByRole("textbox", { name: /^来源地址/ });
for (let i = 0; i < 5 && document.activeElement !== sourceInput(); i++) await userEvent.tab();
expect(document.activeElement).toBe(sourceInput());
await userEvent.keyboard("https://example.com/SKILL.md"); await userEvent.tab();
expect(document.activeElement).toBe(nameInput());
await userEvent.keyboard("key-kit"); await userEvent.tab(); await userEvent.keyboard("1.2.3");
await userEvent.tab(); await userEvent.keyboard("https://example.com/SKILL.md");
for (let i = 0; i < 6; i++) await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "提交审核" }));
await userEvent.keyboard("{Enter}");
@@ -41,7 +41,7 @@ export function PlanConfirm({ slug, plan, busy, error, onCancel, onInstall, own
<div className="mkt addpkg mkc" data-stage="confirm" aria-busy={busy}>
<div className="mkc-head">
<b>{t("ext.name_version_will_install_the", { name: slug, version: plan.version })}</b>
<button type="button" className="mkc-x" data-action="market.cancel" aria-label={t("common.close")} title={t("common.close")} disabled={busy} onClick={onCancel}>
<button type="button" className="mkc-x" data-action="market.cancel" aria-label={t("ext.close_install_confirmation")} title={t("ext.close_install_confirmation")} disabled={busy} onClick={onCancel}>
<StudioIcon name="close" />
</button>
</div>
+58 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { t } from "../i18n";
import type { HubPort } from "../port/hub";
import type { RemoteHost, RemoteHostEdit, RemoteProbe, WSLDistros } from "../port/remote";
@@ -6,6 +6,8 @@ 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";
interface Props {
hub: HubPort;
@@ -141,6 +143,15 @@ export function Remotes({ hub, onError }: Props) {
</label>
);
const probe = (name: string) => {
setProbing(name);
hub
.probeRemote(name)
.then((rep) => setProbes((p) => ({ ...p, [name]: rep })))
.catch(onError)
.finally(() => setProbing(""));
};
return (
<div className="rmtbook">
{hosts.map((host) => (
@@ -150,35 +161,13 @@ export function Remotes({ hub, onError }: Props) {
<b>{host.name}</b>
<span className="tg">{host.target}</span>
<span className="st">{t(STATUS_LABEL[host.status] ?? host.status)}</span>
<button
className="rmtlnk"
data-action="remote.probe"
data-target={host.name}
disabled={probing === host.name}
onClick={() => {
setProbing(host.name);
hub
.probeRemote(host.name)
.then((rep) => setProbes((p) => ({ ...p, [host.name]: rep })))
.catch(onError)
.finally(() => setProbing(""));
}}
>
{t(probing === host.name ? "remote.asking" : "common.test_it")}
</button>
<button
className="rmtlnk"
onClick={() => {
setEditing(host.name);
setDraft(draftOf(host));
}}
>
{t("common.edit")}
</button>
<button className="rmtlnk" data-action="remote.hide" data-target={host.name} onClick={() => toggleHiddenHost(host.name)}>
{t(hidden.includes(host.name) ? "remote.show_in_the_rail" : "remote.hide_from_the_rail")}
</button>
{host.disabled ? <span className="st">{t("common.suspended")}</span> : null}
<span className="rmtfill" />
{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}
@@ -187,9 +176,24 @@ export function Remotes({ hub, onError }: Props) {
label={t(host.disabled ? "remote.turn_this_machine_on" : "remote.turn_this_machine_off")}
onClick={() => void save({ ...draftOf(host), disabled: !host.disabled })}
/>
<button className="rmtlnk" data-danger="" disabled={!!busy} onClick={() => setConfirm(host.name)}>
{t("common.remove")}
</button>
<RemoteMore name={host.name}>
{(close) => <>
<button role="menuitem" data-action="remote.edit" data-target={host.name} onClick={() => { close(); setEditing(host.name); setDraft(draftOf(host)); }}>
{t("common.edit")}
</button>
{host.status === "connected" && (
<button role="menuitem" data-action="remote.probe" data-target={host.name} disabled={probing === host.name} onClick={() => { close(); probe(host.name); }}>
{t(probing === host.name ? "remote.asking" : "common.test_it")}
</button>
)}
<button role="menuitem" data-action="remote.hide" data-target={host.name} onClick={() => { close(); toggleHiddenHost(host.name); }}>
{t(hidden.includes(host.name) ? "remote.show_in_the_rail" : "remote.hide_from_the_rail")}
</button>
<button role="menuitem" data-danger="" data-action="remote.remove-ask" data-target={host.name} disabled={!!busy} onClick={() => { close(); setConfirm(host.name); }}>
{t("common.remove")}
</button>
</>}
</RemoteMore>
</div>
<div className="sub">
{host.workspace ? <span dir="ltr">{host.workspace}</span> : <span className="dim">{t("remote.no_default_workspace")}</span>}
@@ -233,6 +237,12 @@ export function Remotes({ hub, onError }: Props) {
+ {alias}
</button>
))}
{candidates.length > 1 && (
<button data-action="remotes.add-all" className="rmtcand" data-all="" disabled={!!busy}
onClick={() => void (async () => { for (const alias of candidates) await save({ name: alias, useSSHConfig: true }); })()}>
{t("remote.add_them_all")}
</button>
)}
</div>
)}
@@ -465,3 +475,19 @@ function ProbeCard({ probe, host }: { probe: RemoteProbe; host: string }) {
</div>
);
}
function RemoteMore({ name, children }: { name: string; children: (close: () => void) => ReactNode }) {
const [open, setOpen] = useState(false);
const box = useRef<HTMLSpanElement>(null);
const close = () => setOpen(false);
useDismiss(open, box, close);
return (
<span className="rmthost-more" ref={box}>
<button className="rmthost-btn" data-action="remote.more" data-target={name} aria-haspopup="menu" aria-expanded={open}
aria-label={t("remote.more_for_name", { name })} title={t("common.more")} onClick={() => setOpen((v) => !v)}>
<StudioIcon name="more" />
</button>
{open && <span className="rmthost-menu" role="menu" aria-label={name}>{children(close)}</span>}
</span>
);
}
@@ -41,6 +41,23 @@ 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: /^更多:/ }));
expect(document.querySelectorAll('[data-action="remote.hide"]')).toHaveLength(1);
});
});
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[]> => []);
const hub = {
remoteHosts: async () => [host],
remoteCandidates: async () => ["attic", "old-nas"],
wslDistros: async () => ({ available: false, distros: [] }),
saveRemoteHost,
} as unknown as HubPort;
render(<Remotes hub={hub} onError={() => {}} />);
await userEvent.click(await screen.findByRole("button", { name: "全部添加" }));
await waitFor(() => expect(saveRemoteHost).toHaveBeenCalledTimes(2));
expect(saveRemoteHost.mock.calls.map(([e]) => [e.name, e.useSSHConfig])).toEqual([["attic", true], ["old-nas", true]]);
});
});
@@ -83,7 +83,8 @@ describe("a saved WSL machine", () => {
const { hub } = draw({}, [dev]);
expect(await screen.findByText("wsl:Ubuntu-24.04")).toBeTruthy();
expect(screen.getByText("WSL 发行版")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "编辑" }));
await userEvent.click(screen.getByRole("button", { name: /^更多:/ }));
await userEvent.click(screen.getByRole("menuitem", { name: "编辑" }));
expect(screen.queryByRole("radiogroup", { name: "连接类型" })).toBeNull();
expect((screen.getByLabelText("发行版") as HTMLSelectElement).disabled).toBe(true);
expect(screen.queryByText("地址")).toBeNull();