mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): remote machines as cards with one next step and the on/off in the menu
This commit is contained in:
1 parent
c8c4162818
commit
b9c03d4a95
4 files changed
+74
-44
No files matched your search
@@ -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); }
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user