From ccd4a766d9ede52795b2327b11a1bd266ef03ada Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 4 Jun 2026 10:22:02 +0200 Subject: [PATCH] feat: improve machine navigation controls --- .changeset/hide-single-machine-navigation.md | 5 + src/client/src/components/MachineList.test.ts | 20 +++ src/client/src/components/MachineList.ts | 133 +++++++++++++++--- src/client/src/components/PiWebApp.ts | 6 +- .../appShell/AppNavigationPanel.test.ts | 24 ++++ .../components/appShell/AppNavigationPanel.ts | 26 ++-- 6 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 .changeset/hide-single-machine-navigation.md create mode 100644 src/client/src/components/MachineList.test.ts create mode 100644 src/client/src/components/appShell/AppNavigationPanel.test.ts diff --git a/.changeset/hide-single-machine-navigation.md b/.changeset/hide-single-machine-navigation.md new file mode 100644 index 0000000..68d9a2e --- /dev/null +++ b/.changeset/hide-single-machine-navigation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows. diff --git a/src/client/src/components/MachineList.test.ts b/src/client/src/components/MachineList.test.ts new file mode 100644 index 0000000..3143589 --- /dev/null +++ b/src/client/src/components/MachineList.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { Machine } from "../api"; +import { canRemoveMachine } from "./MachineList"; + +describe("canRemoveMachine", () => { + it("only allows remote machines to be removed from the machine list", () => { + expect(canRemoveMachine(machine("local", "local"))).toBe(false); + expect(canRemoveMachine(machine("remote-a", "remote"))).toBe(true); + }); +}); + +function machine(id: string, kind: Machine["kind"]): Machine { + return { + id, + name: id, + kind, + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; +} diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 12bbd69..e23b193 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -1,6 +1,7 @@ -import { LitElement, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { LitElement, css, html, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import type { Machine, MachineHealth } from "../api"; +import { actionMenuPanelStyle } from "./actionMenu"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -12,33 +13,86 @@ export class MachineList extends LitElement { @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) onSelect?: (machine: Machine) => void; + @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise; @property({ attribute: false }) onToggleCollapsed?: () => void; + @state() private openMenuMachineId: string | undefined; + @state() private menuStyle = ""; + + private readonly onDocumentClick = (event: MouseEvent) => { + if (event.composedPath().includes(this)) return; + this.openMenuMachineId = undefined; + }; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + super.disconnectedCallback(); + } + + protected override updated(changed: PropertyValues): void { + if (changed.has("machines") && this.openMenuMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openMenuMachineId)) this.openMenuMachineId = undefined; + if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined; + } override render() { return html`

${this.renderHeading()}

- ${this.collapsed ? null : this.machines.map((machine) => { - const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown"; - const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown"; - return html` -
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} - @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }} - > -
- ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} -
-
- `; - })} + ${this.collapsed ? null : html` +
+ ${this.machines.map((machine) => this.renderMachine(machine))} +
+ `}
`; } + private renderMachine(machine: Machine) { + const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown"; + const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown"; + const hasRemoveAction = canRemoveMachine(machine) && this.onRemove !== undefined; + return html` +
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} + @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} + > +
+ ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} +
+ ${hasRemoveAction ? this.renderMachineMenu(machine) : null} +
+ `; + } + + private renderMachineMenu(machine: Machine) { + const open = this.openMenuMachineId === machine.id; + const menuId = machineMenuId(machine.id); + return html` +
+ + ${open ? html` +
{ event.stopPropagation(); }}> + +
+ ` : null} +
+ `; + } + private renderHeading() { if (!this.collapsible) return "Machines"; const selectedSummary = this.selected?.name ?? "No machine selected"; @@ -46,5 +100,44 @@ export class MachineList extends LitElement { return html``; } - static override styles = listStyles; + private toggleMenu(machineId: string, target: EventTarget | null): void { + if (this.openMenuMachineId === machineId) { + this.openMenuMachineId = undefined; + return; + } + this.menuStyle = actionMenuPanelStyle(target); + this.openMenuMachineId = machineId; + } + + private removeMachine(machine: Machine): void { + this.openMenuMachineId = undefined; + void this.onRemove?.(machine); + } + + private handleMachineKeydown(event: KeyboardEvent, machine: Machine): void { + if (event.key === "Escape" && this.openMenuMachineId === machine.id) { + event.preventDefault(); + event.stopPropagation(); + this.openMenuMachineId = undefined; + return; + } + activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); + } + + static override styles = [ + listStyles, + css` + .machine-row.no-actions .action-main { border-radius: 8px; } + .machine-menu-panel button.danger { color: var(--pi-danger); } + .machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); } + `, + ]; +} + +export function canRemoveMachine(machine: Machine): boolean { + return machine.kind === "remote"; +} + +function machineMenuId(machineId: string): string { + return `machine-menu-${machineId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 8672df9..dc717f2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -595,6 +595,7 @@ export class PiWebApp extends LitElement { this.mobileNavigation.expand("projects"); await this.machines.selectMachine(machine); })} + .onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }} .projects=${this.state.projects} .selectedProject=${this.state.selectedProject} .workspaceActivities=${this.state.workspaceActivities} @@ -761,7 +762,7 @@ export class PiWebApp extends LitElement { addProject: () => { this.setState({ projectDialogOpen: true }); }, addMachine: () => this.addMachineFromPrompt(), refreshSelectedMachine: () => this.machines.refreshMachineHealth(), - removeSelectedMachine: () => this.removeSelectedMachine(), + removeSelectedMachine: () => this.removeMachine(), openSelectedMachine: () => { this.openSelectedMachine(); }, configureAuth: () => this.auth.openLogin(), logoutAuth: () => this.auth.openLogout(), @@ -901,8 +902,7 @@ export class PiWebApp extends LitElement { await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) }); } - private async removeSelectedMachine(): Promise { - const machine = this.state.selectedMachine; + private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise { if (machine === undefined || machine.kind === "local") return; if (!window.confirm(`Remove ${machine.name}?\n\nThis only removes it from this PI WEB gateway.`)) return; await this.machines.deleteMachine(machine); diff --git a/src/client/src/components/appShell/AppNavigationPanel.test.ts b/src/client/src/components/appShell/AppNavigationPanel.test.ts new file mode 100644 index 0000000..57757c8 --- /dev/null +++ b/src/client/src/components/appShell/AppNavigationPanel.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { Machine } from "../../api"; +import { shouldShowMachinesSection } from "./AppNavigationPanel"; + +describe("shouldShowMachinesSection", () => { + it("hides the machines section when there is no machine choice", () => { + expect(shouldShowMachinesSection([])).toBe(false); + expect(shouldShowMachinesSection([machine("local")])).toBe(false); + }); + + it("shows the machines section when there are multiple machines", () => { + expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true); + }); +}); + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 853bb74..34ac8d2 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -49,6 +49,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; + @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; override render() { return html` @@ -59,15 +60,18 @@ export class AppNavigationPanel extends LitElement { - { this.onToggleMachines?.(); }} - .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} - > + ${shouldShowMachinesSection(this.machines) ? html` + { this.onToggleMachines?.(); }} + .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} + .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} + > + ` : null} 1; +}