Archived
feat: improve machine navigation controls
This commit is contained in:
@@ -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.
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
@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<this>): 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`
|
||||
<section>
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${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`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${machine.baseUrl ?? machine.name}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${this.machines.map((machine) => this.renderMachine(machine))}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
class=${`action-row machine-row ${this.selected?.id === machine.id ? "selected" : ""} ${hasRemoveAction ? "" : "no-actions"}`}
|
||||
tabindex="0"
|
||||
title=${machine.baseUrl ?? machine.name}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
</div>
|
||||
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineMenu(machine: Machine) {
|
||||
const open = this.openMenuMachineId === machine.id;
|
||||
const menuId = machineMenuId(machine.id);
|
||||
return html`
|
||||
<div class="action-menu">
|
||||
<button
|
||||
class="action-menu-toggle"
|
||||
title="Machine actions"
|
||||
aria-label=${`Actions for ${machine.name}`}
|
||||
aria-expanded=${String(open)}
|
||||
aria-controls=${menuId}
|
||||
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(machine.id, event.currentTarget); }}
|
||||
>⋯</button>
|
||||
${open ? html`
|
||||
<div class="action-menu-panel machine-menu-panel" id=${menuId} style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
}
|
||||
|
||||
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, "-")}`;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
const machine = this.state.selectedMachine;
|
||||
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
@@ -59,15 +60,18 @@ export class AppNavigationPanel extends LitElement {
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||
</div>
|
||||
</header>
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.machinesCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
></machine-list>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.machinesCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
></machine-list>
|
||||
` : null}
|
||||
<project-list
|
||||
.projects=${this.projects}
|
||||
.selected=${this.selectedProject}
|
||||
@@ -130,3 +134,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user