diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a3e1b..4364139 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,10 @@ jobs: # Windows runner: clippy doesn't link, so it needs no Windows toolchain. - name: Clippy for Windows run: cargo clippy -p localhost-core -p localhost-cli --target x86_64-pc-windows-msvc --all-targets -- -D warnings + # `tests/machines_ssh.rs` reaches another Machine through a private + # sshd on loopback, and skips where there is none. + - name: Install sshd + run: sudo apt-get update && sudo apt-get install -y openssh-server - run: cargo test windows: diff --git a/AGENTS.md b/AGENTS.md index 0b3a26d..c959190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,10 +66,12 @@ The code, the UI and the docs use the same words. Use these, and avoid the alter | **Suggestion** | A Service proposed by scanning a folder, applied only once the user checks it. | detected service | | **Run** | One execution of a Service, launch until its whole process tree has ended. A Service has at most one active Run. | process, session, instance | | **Execution Target** | Where a Project's Services run: this machine, or a WSL distro. | host, runtime, environment, agent | +| **Machine** | Another computer whose localhost this app shows and controls, reached over the system's `ssh`. Its Projects, Secrets and Runs stay on it. The UI says "Machine" and "Runs on". | host, server, remote, environment | +| **Forward** | A Port of a Machine made reachable on this computer's loopback, through the SSH connection, so Open in Browser works. Same port number unless that one is taken here. | tunnel, proxy | | **Listener** | Any process listening on a TCP port, attributed to a Run, to a Project (its working directory is inside the folder), or to nobody. | port owner | | **Unmanaged Listener** | A Listener that belongs to no Run and no Project. localhost can show and stop it, never adopt it. | rogue, stray | | **Orphan** | A process tree left from a Run of a localhost session that ended unexpectedly. Never re-adopted as Running. | zombie, leftover | -| **Origin** | Who asked for a change: main window, menu bar, CLI, or an agent through `localhost mcp`. | source, actor, caller | +| **Origin** | Who asked for a change: main window, menu bar, CLI, an agent through `localhost mcp`, or another Machine's app through `localhost remote`. | source, actor, caller | | **Service State** | Exactly one of the six states below. | status (in the UI) | | State | Meaning | @@ -131,6 +133,12 @@ These are product rules, not implementation details. A change that breaks one ne - Updates are offered, never installed unasked: a check at launch and every few hours (off in Settings), a notice, then Update Now and Restart to Update. Restarting to update stops every Service first, like quitting, after listing them, and retires the shared core so the new version starts its own. A `.deb` or `.rpm` only links to the release. Builds without the updater's public key never check. - Each active Run is recorded on disk (pid, process group, start time). At the next launch, survivors are offered as Orphans: Stop them (default) or Leave them. +**Machines** +- Add Machine connects first: a Machine is kept only once `ssh localhost remote --stdio` works, otherwise the typed `MachineProblem` says why and what to do next. The SSH host field suggests the `Host`s of `~/.ssh/config`, read only. +- A lost connection is made again on its own, after 1 s growing to 30 s, or at once when the computer wakes; a problem waiting won't fix (a host key, a sign-in that needs a prompt, no or an old localhost there, anything SSH says that localhost doesn't recognize) is shown once and waits for Reconnect. Reconnecting never starts or stops a Service. +- A Machine's Projects show below this computer's and act like them: Start, Stop, Restart, logs, Open in Browser. They are edited on the Machine. Open in Browser forwards the Port to the same port here first; if that port is taken here, it says so (`PortConflict`) rather than using another. +- Quitting asks first when Services started from here on another Machine are active: that Machine stops them once the connection ends, unless localhost is open on it. + **Detection** - Scanning only reads: no network, nothing written. It looks at the root, folders up to two levels deep (skipping dependency, build and hidden folders) and folders detectors declare (workspace packages, Cargo members). - Detectors (Node, Compose, Python, Cargo) are small and independent and return plain command lines. Every Suggestion is reviewed: likely ones checked, the rest folded away, each editable before anything is saved. @@ -169,6 +177,7 @@ All state, process management, logs and persistence live in the core, which neve | `LogStore`, `LogBuffer` | Each Run's output: in-memory buffer and rotated file. | | `RunRegistry` | Records active Runs on disk, to find Orphans. | | `Targets`, `remote` | Where each Project runs, behind one interface: this machine through the shared core, a WSL distro through a `localhostd`. Merges every target's statuses and events. | +| `machine` | Other Machines: `Machines` keeps the list (`machines.json`), keeps each connected over an `ssh` ControlMaster running `localhost remote --stdio` there, and mirrors its Projects and statuses for the apps; `relay` is that command's side. Its own versioned protocol, which only grows. | | `remote::shared` | The shared core: the `remote` protocol on a Unix socket in the storage root, or on Windows a named pipe named after it, started by its first client, one per storage root. Only the transport differs per platform. Decides which Runs a disconnecting client takes with it. | | `listener` | Finds Listeners (libproc, `/proc`, `GetExtendedTcpTable`, never `lsof`) and attributes them. | | `ProjectScanner` + detectors | Reads a folder and returns Suggestions. | @@ -210,6 +219,7 @@ The hard-to-reverse choices, and why. Changing one is an issue and a discussion - **Windows Runs in Job Objects, stopped with Ctrl-Break.** Windows has no process-group signals; a job holds the whole tree and dies with the core even on a crash. Ctrl-Break needs a shared console, so the core attaches once to a hidden one. A core an agent starts breaks away from the agent's job, which Node ends with the agent. - **WSL Projects run by a core inside the distro.** A Windows process sees nothing behind `wsl.exe`. The app copies its `localhostd` into the distro and drives it over stdio: no socket, port or firewall rule. Configuration and Secrets stay on Windows. - **Configuration stored by localhost, not in the Project folder.** Writing into users' repos is a surprising side effect. The Project file is the explicit bridge. +- **Machines through the system's `ssh`, viewed, never merged.** The user already reaches their other computers with `ssh` (keys, agent, `~/.ssh/config`, `ProxyJump`, Tailscale), so localhost adds no sign-in, listener or port of its own: one `BatchMode` ControlMaster per Machine runs `localhost remote --stdio` there, and Forwards ride on it, bound to loopback. Host keys are checked as configured and never trusted on the user's behalf. Each Machine keeps its Configuration, Secrets and Runs; changes there are made as `Origin::Machine`, and that Machine's core holds only the Runs the connection started, stopping them when it ends unless an app is open there. Two computers often run different versions, so the Machine protocol is versioned on its own and only grows: unknown requests are answered `Unsupported`, unknown messages skipped. - **Pipes, not a PTY.** Keeping stderr distinct matters for failures and, later, for agents. The colour hints make up for the missing terminal. - **`$PORT`, and moves scoped to one Run.** It is the existing convention, needs no parser and works the same from any client. A moved port never rewrites the configuration. - **Run stats sampled on request.** Nothing is sampled unless a stats popover is open (every 1.5 s). The tree includes descendants that left the session. Memory is each platform's honest number rather than RSS, which counts forked workers once each. diff --git a/README.md b/README.md index 8ff4a77..63592f7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A **Project** is a folder. A **Service** is one long-running command in it: `pnp - **Keyboard first**: a command palette, a shortcut for every action, arrow keys across the window. Screen readers, light and dark, reduced motion. - **Coding agents included**: Claude Code, Cursor, Codex and other MCP clients start, stop and inspect your Services through `localhost mcp` instead of a hidden shell. The agent and the app share one background core, so what the agent starts shows in the app, with its logs. See [Coding agents (MCP)](#coding-agents-mcp). - **Updates that ask first**: a check at launch and every 6 hours, a notice in the sidebar, and Restart to Update stops your running Services only after asking. See [Updates](#updates). +- **Your other computers too**: a dev box you reach with `ssh` is a **Machine**. Its Projects show below yours, and start, stop and open in your browser from here, while they run on it. See [Run Projects on another computer](#8-run-projects-on-another-computer). - **Local**: no account, no cloud, works offline. Configuration lives in localhost's own storage, never in your project folders. A native SwiftUI app on macOS with a menu bar companion; one app for Linux and Windows with a tray icon. On Windows, Projects run on Windows itself or inside a WSL 2 distro. All of them, the `localhost` command and the MCP server run on one Rust core. @@ -159,9 +160,20 @@ Before starting a Service with a Port, localhost checks who holds it. If somethi The **Ports** view (**⌘0**) lists every process listening on a TCP port and whom it belongs to: one of your Services, a process running inside one of your Projects' folders, or nothing localhost knows (other apps and system services are folded below). Select one and press ⌘. to stop it; localhost asks first for anything it didn't start. -### 7. Move a Project to another machine +### 7. Copy a Project's configuration to another computer -**Export Project…** saves a Project as a YAML Project file, and **Import Project…** reads one back, showing what it contains before adding anything; choose the folder if it is somewhere else on this machine. Secret values are never exported: the file lists Secret names, and the import shows which ones to fill in. The format is in [docs/configuration.md](docs/configuration.md). +**Export Project…** saves a Project as a YAML Project file, and **Import Project…** reads one back, showing what it contains before adding anything; choose the folder if it is somewhere else on this computer. Secret values are never exported: the file lists Secret names, and the import shows which ones to fill in. The format is in [docs/configuration.md](docs/configuration.md). + +### 8. Run Projects on another computer + +A dev box or home server you reach with `ssh` can be a **Machine**: its Projects stay and run there, with its own configuration and Secrets, and this app shows and controls them. + +1. Check that `ssh devbox localhost --version` works from a terminal without asking anything (a key, in the SSH agent if it has a passphrase, and a host key you already trusted). It runs without your interactive profile: with zsh, put `localhost` on the `PATH` in `~/.zshenv`, not `~/.zshrc`. +2. Open **Settings → Machines → Add Machine…**. In **SSH host**, type a `Host` from `~/.ssh/config` (the field lists them), or `user@host:port`. localhost connects first, and says why and what to do if it can't. +3. The Machine's Projects show in the sidebar under its name, below this computer's: Start, Stop, Restart, logs. They are edited on the Machine. +4. **Open in Browser** forwards the Service's Port to the same port here, through the SSH connection and on loopback only, then opens it. + +Any network `ssh` uses works: a LAN, a VPN, `ProxyJump`, Tailscale. Nothing new listens on either computer. A lost connection is made again on its own; Services you started from here stop on the Machine when the connection ends, unless localhost is open there. More in the [Machines docs](https://www.getlocalhost.app/docs/machines). ## Keyboard shortcuts @@ -272,6 +284,7 @@ Inside that folder: - `projects/`: one JSON file per Project. A file that can't be read is renamed to `….unreadable` and reported at launch, never deleted. - `logs/`: each Run's output, the last 5 Runs per Service. - `runs/`: the Runs active right now, to find leftovers after a crash. +- `machines.json`: the Machines you added, their names and SSH destinations; `machines/` holds their SSH connections' sockets while they are open. - `agent-backups/`: each coding agent's configuration file as it was before localhost first connected it. - `bin/`: the AppImage's `localhost` command, copied out for coding agents (Linux AppImage only). @@ -393,6 +406,10 @@ Add the Project’s folder and localhost suggests Services from `package.json` s Yes. `localhost mcp` is an MCP server for Claude Code, Cursor, Codex and other coding agents: they list your Projects, start and stop Services, and read their status, logs and ports, instead of running `pnpm dev` in a shell you can’t see. They can also add, edit and remove Projects and Services, checked as the app checks them: removing needs `confirm: true` and never touches a Project’s folder, and `localhost mcp --read-only` leaves those tools out. The agent and the app share one background core, so what the agent starts shows in the app, and the Services it started stop when it disconnects, unless the app is open. +### Can I run my dev servers on a dev box or home server and open them from my laptop? + +Yes. Add it in Settings → Machines with the name you give `ssh` (a `Host` from `~/.ssh/config`, or `user@host:port`), and its Projects show in the sidebar below your laptop’s. Start, stop and read their logs from there; Open in Browser forwards the Port to the same port on your laptop through the SSH connection, so `http://localhost:5173` works as if it ran locally. Its Services, configuration and Secrets stay on it. It works over any network `ssh` reaches, such as Tailscale or a VPN, and adds no account or listening port. localhost has to be installed on both computers. + ### Is it free and open source? Yes. localhost is free and open source under the MIT license. There is no account and no backend, and it works offline. diff --git a/apps/desktop/src-tauri/src/bridge.rs b/apps/desktop/src-tauri/src/bridge.rs index b5fae77..6f2dfa0 100644 --- a/apps/desktop/src-tauri/src/bridge.rs +++ b/apps/desktop/src-tauri/src/bridge.rs @@ -32,9 +32,9 @@ pub struct Snapshot { #[serde(rename_all = "camelCase")] pub struct StatusView { #[serde(flatten)] - status: ServiceStatus, + pub(crate) status: ServiceStatus, failure_message: Option, - url_to_open: Option, + pub(crate) url_to_open: Option, } impl StatusView { @@ -51,7 +51,7 @@ impl StatusView { #[serde(rename_all = "camelCase")] pub struct ProjectState { project_id: Id, - state: Option, + pub(crate) state: Option, } /// A command's failure: the core's typed error, and its message for the user. @@ -93,6 +93,10 @@ pub enum CoreEvent { pub struct Subscriptions(Mutex>>); impl Subscriptions { + pub fn push(&self, task: JoinHandle<()>) { + self.0.lock().unwrap().push(task); + } + pub fn close_all(&self) { for task in self.0.lock().unwrap().drain(..) { task.abort(); @@ -107,13 +111,11 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel) let core = state.core()?; let (projects, mut library_events) = core.library.subscribe(); let (statuses, mut service_events) = core.targets.subscribe(); - let mut view = View { projects, statuses, states: HashMap::new() }; - let project_states: Vec = view.projects.iter().map(|project| view.project_state(project)).collect(); - view.states = project_states.iter().map(|state| (state.project_id, state.state)).collect(); + let mut view = View::new(projects, statuses); let snapshot = Snapshot { projects: view.projects.clone(), - statuses: view.statuses.values().map(|status| view.status_view(status)).collect(), - project_states, + statuses: view.status_views(), + project_states: view.project_states(), }; let task = tauri::async_runtime::spawn(async move { @@ -142,18 +144,37 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel) } } }); - state.subscriptions.0.lock().unwrap().push(task); + state.subscriptions.push(task); Ok(snapshot) } -/// One subscriber's copy of the state, for the derived Project states. -struct View { - projects: Vec, +/// One subscriber's copy of the state, for the derived Project states: of +/// this computer's Projects, or another Machine's (`machines`). +pub(crate) struct View { + pub(crate) projects: Vec, statuses: HashMap, states: HashMap>, } impl View { + pub(crate) fn new(projects: Vec, statuses: HashMap) -> View { + let mut view = View { projects, statuses, states: HashMap::new() }; + view.states = view.project_states().into_iter().map(|state| (state.project_id, state.state)).collect(); + view + } + + pub(crate) fn statuses(&self) -> &HashMap { + &self.statuses + } + + pub(crate) fn status_views(&self) -> Vec { + self.statuses.values().map(|status| self.status_view(status)).collect() + } + + pub(crate) fn project_states(&self) -> Vec { + self.projects.iter().map(|project| self.project_state(project)).collect() + } + /// Applies the event; returns the Project whose state may have changed. fn apply_library(&mut self, event: &ProjectLibraryEvent) -> Option { match event { @@ -175,7 +196,7 @@ impl View { } } - fn status_view(&self, status: &ServiceStatus) -> StatusView { + pub(crate) fn status_view(&self, status: &ServiceStatus) -> StatusView { let service = self.projects.iter().find_map(|project| project.service(status.service_id)); StatusView::new(status.clone(), service) } @@ -191,13 +212,13 @@ impl View { .collect() } - fn apply_status(&mut self, status: &ServiceStatus) -> Option { + pub(crate) fn apply_status(&mut self, status: &ServiceStatus) -> Option { self.statuses.insert(status.service_id, status.clone()); self.projects.iter().find(|project| project.service(status.service_id).is_some()).map(|project| project.id) } /// The Project's state if it differs from the last one sent. - fn refresh_state(&mut self, project_id: Id) -> Option { + pub(crate) fn refresh_state(&mut self, project_id: Id) -> Option { let project = self.projects.iter().find(|project| project.id == project_id)?; let state = self.project_state(project); (self.states.insert(project_id, state.state) != Some(state.state)).then_some(state) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2f85a2c..1ab0845 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod launch; mod lifecycle; mod listeners; mod logs; +mod machines; mod palette; mod perf; mod services; @@ -29,6 +30,7 @@ pub use targets::DAEMON_ARGUMENT; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use localhost_core::machine::Machines; use localhost_core::{ Launcher, LocalhostError, Origin, ProjectLibrary, SecretStore, ServiceManagerOptions, SystemSecretStore, Targets, default_storage_root, @@ -69,6 +71,8 @@ impl Core { /// update on offer and the app's lifetime. pub struct AppState { core: Result, + /// The other Machines, or why their list couldn't be read. + machines: Result, logs: logs::LogStreams, subscriptions: bridge::Subscriptions, lifecycle: lifecycle::Lifecycle, @@ -165,6 +169,18 @@ fn specta_builder() -> tauri_specta::Builder { agents::command_line_status, agents::add_command_to_path, agents::remove_command_from_path, + machines::subscribe_machines, + machines::add_machine, + machines::rename_machine, + machines::remove_machine, + machines::reconnect_machine, + machines::ssh_hosts, + machines::machine_start_service, + machines::machine_stop_service, + machines::machine_restart_service, + machines::machine_start_all, + machines::machine_stop_all, + machines::machine_open_service, ]) .events(collect_events![lifecycle::Reveal, launch::OrphansFound, updates::UpdateState]) } @@ -200,8 +216,15 @@ pub fn run() { if let Ok(core) = &core { core.targets.set_automatic_port_move(settings.get().automatic_port_move); } + let machines = Machines::open(&storage_root(), Path::new("ssh")); + if let (Ok(machines), false) = (&machines, is_measuring) { + // Connecting spawns the tasks that keep each one connected. + let _runtime = tauri::async_runtime::handle().inner().enter(); + machines::connect_all(machines); + } app.manage(AppState { core, + machines, logs: logs::LogStreams::default(), subscriptions: bridge::Subscriptions::default(), lifecycle: lifecycle::Lifecycle::default(), diff --git a/apps/desktop/src-tauri/src/lifecycle.rs b/apps/desktop/src-tauri/src/lifecycle.rs index 8b90351..cdae85c 100644 --- a/apps/desktop/src-tauri/src/lifecycle.rs +++ b/apps/desktop/src-tauri/src/lifecycle.rs @@ -144,15 +144,23 @@ pub fn quit(app: &AppHandle, origin: Origin) { let services = projects.iter().flat_map(|project| &project.services); services.filter(|service| core.targets.status(service.id).state.is_active()).count() }); + let on_machines = state.machines().map_or(0, crate::machines::active_count); + let count = count + on_machines; if count == 0 { exit(app, origin); return; } let title = if count == 1 { "Stop 1 service and quit?".to_string() } else { format!("Stop {count} services and quit?") }; + let message = if on_machines == 0 { + "Running services are stopped before localhost quits." + } else { + "Running services are stopped before localhost quits. Those started from here on other machines stop too, \ + unless localhost is open on them." + }; let mut dialog = app .dialog() - .message("Running services are stopped before localhost quits.") + .message(message) .title(title) .kind(MessageDialogKind::Warning) .buttons(MessageDialogButtons::OkCancelCustom("Stop and Quit".into(), "Cancel".into())); diff --git a/apps/desktop/src-tauri/src/logs.rs b/apps/desktop/src-tauri/src/logs.rs index bb0453a..6b8f6a6 100644 --- a/apps/desktop/src-tauri/src/logs.rs +++ b/apps/desktop/src-tauri/src/logs.rs @@ -96,18 +96,22 @@ pub struct LogStreams { } /// Streams `source` over `batches`: what is buffered first, then new -/// lines. Returns the id to close it with. +/// lines; on another Machine (`machine_id`), through its connection. +/// Returns the id to close it with. #[tauri::command] #[specta::specta] pub async fn open_log( state: tauri::State<'_, AppState>, source: LogSource, + machine_id: Option, batches: Channel, ) -> CommandResult { - let targets = &state.core()?.targets; let (buffered, receiver) = match &source { LogSource::Run { run_id } => { - let (lines, receiver) = targets.subscribe_to_run(*run_id).await?; + let (lines, receiver) = match machine_id { + Some(machine_id) => state.machines()?.subscribe_to_run(machine_id, *run_id).await?, + None => state.core()?.targets.subscribe_to_run(*run_id).await?, + }; let lines: Vec = lines.iter().map(|line| LogLine::new(line, None)).collect(); let (sender, combined) = mpsc::unbounded_channel(); forward(receiver, sender, |line| LogLine::new(&line, None)); @@ -115,7 +119,10 @@ pub async fn open_log( } LogSource::Services { service_ids } => { let ids: HashSet = service_ids.iter().copied().collect(); - let (lines, receiver) = targets.subscribe_to_services(ids).await?; + let (lines, receiver) = match machine_id { + Some(machine_id) => state.machines()?.subscribe_to_services(machine_id, ids).await?, + None => state.core()?.targets.subscribe_to_services(ids).await?, + }; let lines = lines.iter().map(|line| LogLine::new(&line.line, Some(line.service_id))).collect(); let (sender, combined) = mpsc::unbounded_channel(); forward(receiver, sender, |line| LogLine::new(&line.line, Some(line.service_id))); diff --git a/apps/desktop/src-tauri/src/machines.rs b/apps/desktop/src-tauri/src/machines.rs new file mode 100644 index 0000000..0bec693 --- /dev/null +++ b/apps/desktop/src-tauri/src/machines.rs @@ -0,0 +1,334 @@ +//! Other Machines: computers whose localhost this app shows, reached with +//! the system's `ssh`. The core keeps each one connected and mirrors its +//! Projects and statuses; this sends them to the UI as this computer's are +//! (`bridge`), each Machine with a view of its own, and turns its buttons +//! into calls on the Machine. Changes there are made as `Origin::Machine`, +//! which the other Machine decides. + +use std::collections::HashMap; + +use localhost_core::machine::{MachineSnapshot, MachineState, Machines, MachinesEvent, ssh_config_hosts}; +use localhost_core::{Id, LocalhostError, Machine, ServiceEvent}; +use serde::Serialize; +use specta::Type; +use tauri::ipc::Channel; + +use crate::AppState; +use crate::bridge::{CommandResult, ProjectState, StatusView, View}; + +/// One Machine as the UI shows it. Its Projects and statuses are those of +/// its last connection, kept while it reconnects. +#[derive(Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct MachineView { + machine: Machine, + state: MachineState, + /// How it is, in the core's words: "Connected · localhost 0.5.0", or why not. + summary: String, + is_connected: bool, + /// It waits for the user to connect it again. + needs_user: bool, + projects: Vec, + statuses: Vec, + project_states: Vec, +} + +#[derive(Clone, Serialize, Type)] +// Per variant, not `rename_all_fields`, which specta doesn't read. +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MachineEvent { + /// Every Machine afresh: one was added, removed or renamed, or its + /// state, Projects or statuses changed as a whole. + Machines { machines: Vec }, + /// A Service's state changed there. + #[serde(rename_all = "camelCase")] + Status { machine_id: Id, status: Box }, + /// Sent after the status that changed it. + #[serde(rename_all = "camelCase")] + ProjectState { machine_id: Id, project_state: ProjectState }, +} + +/// The UI's copy of one Machine. +struct Shown { + machine: Machine, + state: MachineState, + view: View, +} + +impl Shown { + fn new(snapshot: MachineSnapshot) -> Shown { + let statuses = snapshot.statuses.into_iter().map(|status| (status.service_id, status)).collect(); + Shown { machine: snapshot.machine, state: snapshot.state, view: View::new(snapshot.projects, statuses) } + } + + fn view(&self) -> MachineView { + MachineView { + summary: self.state.summary(&self.machine.name), + is_connected: matches!(self.state, MachineState::Connected { .. }), + needs_user: self.state.needs_user(), + machine: self.machine.clone(), + state: self.state.clone(), + projects: self.view.projects.clone(), + statuses: self.view.status_views(), + project_states: self.view.project_states(), + } + } +} + +/// The Machines as they are, then every change over `events` until the +/// window goes away. +#[tauri::command] +#[specta::specta] +pub fn subscribe_machines( + state: tauri::State<'_, AppState>, + events: Channel, +) -> CommandResult> { + let machines = state.machines()?; + let (snapshots, mut changes) = machines.subscribe(); + let mut shown: Vec = snapshots.into_iter().map(Shown::new).collect(); + let snapshot = shown.iter().map(Shown::view).collect(); + + let task = tauri::async_runtime::spawn(async move { + while let Some(change) = changes.recv().await { + let sent = apply(&mut shown, change); + for event in sent { + // A send fails once the window is closed or reloaded. + if events.send(event).is_err() { + return; + } + } + } + }); + state.subscriptions.push(task); + Ok(snapshot) +} + +/// Applies the change; returns what the UI is told. +fn apply(shown: &mut Vec, change: MachinesEvent) -> Vec { + let every = |shown: &Vec| vec![MachineEvent::Machines { machines: shown.iter().map(Shown::view).collect() }]; + match change { + MachinesEvent::MachinesChanged { machines } => { + let mut previous: HashMap = shown.drain(..).map(|one| (one.machine.id, one)).collect(); + *shown = machines + .into_iter() + .map(|machine| match previous.remove(&machine.id) { + Some(one) => Shown { machine, ..one }, + None => Shown { + machine, + state: MachineState::NotConnected, + view: View::new(Vec::new(), HashMap::new()), + }, + }) + .collect(); + every(shown) + } + MachinesEvent::StateChanged { machine_id, state } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + one.state = state; + every(shown) + } + MachinesEvent::ProjectsChanged { machine_id, projects } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + one.view = View::new(projects, one.view.statuses().clone()); + every(shown) + } + MachinesEvent::StatusesChanged { machine_id, statuses } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + let statuses = statuses.into_iter().map(|status| (status.service_id, status)).collect(); + one.view = View::new(std::mem::take(&mut one.view.projects), statuses); + every(shown) + } + MachinesEvent::ServiceChanged { machine_id, event: ServiceEvent::ServiceStateChanged(status) } => { + let Some(one) = shown.iter_mut().find(|one| one.machine.id == machine_id) else { return Vec::new() }; + let project_id = one.view.apply_status(&status); + let mut sent = vec![MachineEvent::Status { machine_id, status: Box::new(one.view.status_view(&status)) }]; + sent.extend( + project_id + .and_then(|id| one.view.refresh_state(id)) + .map(|project_state| MachineEvent::ProjectState { machine_id, project_state }), + ); + sent + } + // Runs starting and ending show up in the status changes too. + MachinesEvent::ServiceChanged { .. } => Vec::new(), + } +} + +/// Connects to the Machine `ssh` reaches as `destination`, and only once +/// that works keeps it. Fails with why it can't be reached. +#[tauri::command] +#[specta::specta] +pub async fn add_machine( + state: tauri::State<'_, AppState>, + destination: String, + name: Option, +) -> CommandResult { + Ok(state.machines()?.add(name, &destination).await?) +} + +#[tauri::command] +#[specta::specta] +pub fn rename_machine(state: tauri::State<'_, AppState>, machine_id: Id, name: String) -> CommandResult { + Ok(state.machines()?.rename(machine_id, &name)?) +} + +/// Disconnects and forgets the Machine. Nothing changes on it. +#[tauri::command] +#[specta::specta] +pub async fn remove_machine(state: tauri::State<'_, AppState>, machine_id: Id) -> CommandResult { + Ok(state.machines()?.remove(machine_id)?) +} + +/// Connects again now: at once if it was waiting, else anew. +#[tauri::command] +#[specta::specta] +pub async fn reconnect_machine(state: tauri::State<'_, AppState>, machine_id: Id) -> CommandResult { + let machines = state.machines()?; + match machines.state(machine_id) { + MachineState::Connected { .. } => machines.reconnect(machine_id)?, + _ => machines.connect(machine_id)?, + } + Ok(()) +} + +/// The hosts named in `~/.ssh/config`, to suggest as a destination. Only read. +#[tauri::command] +#[specta::specta] +pub async fn ssh_hosts() -> Vec { + tauri::async_runtime::spawn_blocking(ssh_config_hosts).await.unwrap_or_default() +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_start_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + state.machines()?.start(machine_id, service_id, None).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_stop_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + state.machines()?.stop(machine_id, service_id).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_restart_service( + state: tauri::State<'_, AppState>, + machine_id: Id, + service_id: Id, +) -> CommandResult { + state.machines()?.restart(machine_id, service_id).await?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_start_all(state: tauri::State<'_, AppState>, machine_id: Id, project_id: Id) -> CommandResult { + Ok(state.machines()?.start_all(machine_id, project_id).await?) +} + +#[tauri::command] +#[specta::specta] +pub async fn machine_stop_all(state: tauri::State<'_, AppState>, machine_id: Id, project_id: Id) -> CommandResult { + Ok(state.machines()?.stop_all(machine_id, project_id).await?) +} + +/// Open in Browser: forwards the Service's Port to the same port here, +/// then opens its URL. Fails with `PortConflict` when that port is taken here. +#[tauri::command] +#[specta::specta] +pub async fn machine_open_service(state: tauri::State<'_, AppState>, machine_id: Id, service_id: Id) -> CommandResult { + match state.machines()?.open_service(machine_id, service_id).await? { + Some(url) => crate::desktop::open_url(url), + None => Ok(()), + } +} + +/// Connects to every Machine as the app starts; each then keeps reconnecting. +pub fn connect_all(machines: &Machines) { + for machine in machines.machines() { + let _ = machines.connect(machine.id); + } +} + +/// Active Services on connected Machines started from a Machine: those this +/// computer may hold, which stop there once it disconnects, unless +/// localhost is open on that Machine. +pub fn active_count(machines: &Machines) -> usize { + let (snapshots, _) = machines.subscribe(); + snapshots + .iter() + .filter(|snapshot| matches!(snapshot.state, MachineState::Connected { .. })) + .flat_map(|snapshot| &snapshot.statuses) + .filter(|status| { + status.state.is_active() + && status.run.as_ref().is_some_and(|run| run.origin == localhost_core::Origin::Machine) + }) + .count() +} + +impl AppState { + pub fn machines(&self) -> Result<&Machines, LocalhostError> { + self.machines.as_ref().map_err(Clone::clone) + } +} + +#[cfg(test)] +mod tests { + use localhost_core::{Key, NewService, Project, ServiceState, ServiceStatus}; + + use super::*; + + fn project() -> Project { + let root = std::env::temp_dir().join(format!("localhost-machines-{}", Id::new())); + let library = + localhost_core::ProjectLibrary::open(&root, std::sync::Arc::new(localhost_core::MemorySecretStore::new())) + .unwrap(); + let web = NewService { port: Some(3000), ..NewService::new("Web", Key::new("web").unwrap(), "pnpm dev") }; + let path = root.to_string_lossy().into_owned(); + let project = library + .add_project("Shop", Key::new("shop").unwrap(), &path, vec![web], localhost_core::Origin::Cli) + .unwrap(); + let _ = std::fs::remove_dir_all(&root); + project + } + + #[test] + fn shows_each_machine_as_this_computer_is_shown() { + let devbox = Machine { id: Id::new(), name: "devbox".into(), destination: "devbox".into() }; + let mut shown = Vec::new(); + let sent = apply(&mut shown, MachinesEvent::MachinesChanged { machines: vec![devbox.clone()] }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert_eq!((machines[0].summary.as_str(), machines[0].needs_user), ("Not connected", true)); + + let project = project(); + let web = project.services[0].clone(); + apply(&mut shown, MachinesEvent::ProjectsChanged { machine_id: devbox.id, projects: vec![project.clone()] }); + let state = MachineState::Connected { version: "0.5.0".into() }; + let sent = apply(&mut shown, MachinesEvent::StateChanged { machine_id: devbox.id, state }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert!(machines[0].is_connected); + assert_eq!(machines[0].project_states[0].state, Some(ServiceState::Stopped)); + + let mut status = ServiceStatus::never_run(web.id); + status.state = ServiceState::Failed; + let event = ServiceEvent::ServiceStateChanged(status); + let sent = apply(&mut shown, MachinesEvent::ServiceChanged { machine_id: devbox.id, event }); + assert!( + matches!(&sent[0], MachineEvent::Status { status, .. } if status.url_to_open.as_deref() == Some("http://localhost:3000")) + ); + assert!(matches!( + &sent[1], + MachineEvent::ProjectState { project_state, .. } if project_state.state == Some(ServiceState::Failed) + )); + + // Renamed: its Projects and statuses stay. + let renamed = Machine { name: "Dev box".into(), ..devbox.clone() }; + let sent = apply(&mut shown, MachinesEvent::MachinesChanged { machines: vec![renamed] }); + let [MachineEvent::Machines { machines }] = sent.as_slice() else { panic!("not every Machine") }; + assert_eq!((machines[0].machine.name.as_str(), machines[0].projects.len()), ("Dev box", 1)); + assert_eq!(machines[0].statuses[0].status.state, ServiceState::Failed); + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0b9c93c..82d12c2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react"; import { askAboutFoundOrphans, runLaunchChecks } from "./actions"; import { commands, events, type Reveal } from "./bindings"; import { Alerts } from "./components/Alerts"; -import { Announcer } from "./components/Announcer"; +import { Announcer, MachinesAnnouncer } from "./components/Announcer"; import { CommandPalette } from "./components/CommandPalette"; import { EmptyState, KeyTip } from "./components/EmptyState"; import { Sheets } from "./components/Sheets"; import { FolderIcon, LinesIcon } from "./components/Icons"; import { LogPane } from "./components/LogPane"; import { PortsView } from "./components/PortsView"; -import { ProjectActions, ServicesColumn } from "./components/ServicesColumn"; +import { type Place, ProjectActions, ServicesColumn, thisComputer } from "./components/ServicesColumn"; import { Sidebar } from "./components/Sidebar"; import { Toolbar } from "./components/Toolbar"; import { importProject } from "./editors"; import { SHORTCUTS, serviceShortcut, shortcutLabel } from "./keyboard"; +import { startMachines, useMachines } from "./machines"; import { loadHome, tildePath } from "./paths"; import { onPerfMeasurement, runPerfMeasurement } from "./perf"; import { allServices, noService, type ServiceSelection, type SidebarSelection } from "./selection"; @@ -32,7 +33,7 @@ let started: Promise | null = null; * A WSL distro's Orphans are asked about when it connects, which may be later. */ function start(): Promise { - started ??= Promise.all([loadSettings(), loadHome(), startCore(), startUpdates()]).then(async () => { + started ??= Promise.all([loadSettings(), loadHome(), startCore(), startMachines(), startUpdates()]).then(async () => { void events.orphansFound.listen(() => void askAboutFoundOrphans()); await runLaunchChecks(); await runPerfMeasurement(); @@ -67,9 +68,19 @@ function MainWindow({ view }: { view: CoreView }) { // Under a sheet, an alert or the palette, the window behind can't be reached, by screen readers either. const isBehindModal = isPaletteOpen || sheet !== null || alerts.length > 0; + const machines = useMachines(); const isPorts = sidebarSelection?.kind === "ports"; const selectedId = sidebarSelection?.kind === "project" ? sidebarSelection.projectId : null; - const selected = isPorts ? undefined : (view.projects.find((project) => project.id === selectedId) ?? view.projects[0]); + // A Project of another Machine, while it still has it. + const onMachine = sidebarSelection?.kind === "machineProject" ? sidebarSelection : null; + const machine = onMachine ? machines.find((shown) => shown.machine.id === onMachine.machineId) : undefined; + const machineProject = machine?.view.projects.find((project) => project.id === onMachine?.projectId); + const place: Place = + machine && machineProject ? { machineId: machine.machine.id, isReachable: machine.isConnected } : thisComputer; + const selectedView = machine && machineProject ? machine.view : view; + const selected = isPorts + ? undefined + : (machineProject ?? view.projects.find((project) => project.id === selectedId) ?? view.projects[0]); const select = (next: SidebarSelection) => { setSidebarSelection(next); setSelection(noService); @@ -91,7 +102,7 @@ function MainWindow({ view }: { view: CoreView }) { }), [], ); - useWindowShortcuts({ view, selected, selection, setSelection, navigator }); + useWindowShortcuts({ view, selected, selection, setSelection, navigator, place, selectedView }); useReveal(navigator); useEffect(() => onPerfMeasurement(() => setSelection(allServices)), []); @@ -100,7 +111,16 @@ function MainWindow({ view }: { view: CoreView }) {
{isPorts ? ( @@ -112,10 +132,17 @@ function MainWindow({ view }: { view: CoreView }) { <> } + // A path on another Machine isn't one here. + subtitle={machine && place.machineId ? `${machine.machine.name} · ${selected.path}` : tildePath(selected.path)} + actions={} + /> + - {selection.kind === "none" ? (
) : ( - + )} ) : ( @@ -141,6 +168,7 @@ function MainWindow({ view }: { view: CoreView }) { icon={} title="No Projects" description="Add a project folder to get started." + isStacked tip={ - {isPaletteOpen && } + {isPaletteOpen && ( + // Another Machine's Projects are controlled from the window for now. + + )} - + +
); } diff --git a/apps/desktop/src/actions.ts b/apps/desktop/src/actions.ts index d2b920c..1e3b35b 100644 --- a/apps/desktop/src/actions.ts +++ b/apps/desktop/src/actions.ts @@ -11,27 +11,31 @@ import { import { listenerKey } from "./listeners"; import { currentUi, setProgress, setStoppingListener, showAlert, showSheet } from "./ui"; -// What the main window's buttons do. Failures become an alert; a start -// blocked by a taken port becomes the question about it. +// What the main window's buttons do, on this computer or, given its id, +// another Machine. Failures become an alert; a start blocked by a taken +// port here becomes the question about it. On another Machine, its row +// says why it Failed. -export async function start(serviceId: Id, port: number | null = null) { - askIfBlocked(await commands.startService(serviceId, port)); +export async function start(serviceId: Id, port: number | null = null, machineId: Id | null = null) { + if (machineId) report(await commands.machineStartService(machineId, serviceId)); + else askIfBlocked(await commands.startService(serviceId, port)); } -export async function restart(serviceId: Id) { - askIfBlocked(await commands.restartService(serviceId)); +export async function restart(serviceId: Id, machineId: Id | null = null) { + if (machineId) report(await commands.machineRestartService(machineId, serviceId)); + else askIfBlocked(await commands.restartService(serviceId)); } -export async function stop(serviceId: Id) { - report(await commands.stopService(serviceId)); +export async function stop(serviceId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStopService(machineId, serviceId) : commands.stopService(serviceId))); } -export async function startAll(projectId: Id) { - report(await commands.startAll(projectId)); +export async function startAll(projectId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStartAll(machineId, projectId) : commands.startAll(projectId))); } -export async function stopAll(projectId: Id) { - report(await commands.stopAll(projectId)); +export async function stopAll(projectId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineStopAll(machineId, projectId) : commands.stopAll(projectId))); } /** "Stop it and start": stops whatever holds the blocked start's port, then starts the Service. */ @@ -40,8 +44,9 @@ export async function stopHolderAndStart(blocked: BlockedStart) { askIfBlocked(await commands.stopHolderAndStart(blocked.serviceId, blocked.holder.listener)); } -export async function openUrl(serviceId: Id) { - report(await commands.openServiceUrl(serviceId)); +/** Open in Browser; another Machine's Port is forwarded to the same port here first. */ +export async function openUrl(serviceId: Id, machineId: Id | null = null) { + report(await (machineId ? commands.machineOpenService(machineId, serviceId) : commands.openServiceUrl(serviceId))); } /** Opens the folder in the file manager, a terminal or the editor, asking for the editor first if none is set. */ diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 2cc953f..2ee3fab 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -457,11 +457,12 @@ async saveSettings(settings: Settings) : Promise> { }, /** * Streams `source` over `batches`: what is buffered first, then new - * lines. Returns the id to close it with. + * lines; on another Machine (`machine_id`), through its connection. + * Returns the id to close it with. */ -async openLog(source: LogSource, batches: TAURI_CHANNEL) : Promise> { +async openLog(source: LogSource, machineId: Id | null, batches: TAURI_CHANNEL) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("open_log", { source, batches }) }; + return { status: "ok", data: await TAURI_INVOKE("open_log", { source, machineId, batches }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -603,6 +604,118 @@ async removeCommandFromPath() : Promise> if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +/** + * The Machines as they are, then every change over `events` until the + * window goes away. + */ +async subscribeMachines(events: TAURI_CHANNEL) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("subscribe_machines", { events }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Connects to the Machine `ssh` reaches as `destination`, and only once + * that works keeps it. Fails with why it can't be reached. + */ +async addMachine(destination: string, name: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("add_machine", { destination, name }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async renameMachine(machineId: Id, name: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("rename_machine", { machineId, name }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Disconnects and forgets the Machine. Nothing changes on it. + */ +async removeMachine(machineId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("remove_machine", { machineId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Connects again now: at once if it was waiting, else anew. + */ +async reconnectMachine(machineId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("reconnect_machine", { machineId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * The hosts named in `~/.ssh/config`, to suggest as a destination. Only read. + */ +async sshHosts() : Promise { + return await TAURI_INVOKE("ssh_hosts"); +}, +async machineStartService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_start_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStopService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_stop_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineRestartService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_restart_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStartAll(machineId: Id, projectId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_start_all", { machineId, projectId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async machineStopAll(machineId: Id, projectId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_stop_all", { machineId, projectId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Open in Browser: forwards the Service's Port to the same port here, + * then opens its URL. Fails with `PortConflict` when that port is taken here. + */ +async machineOpenService(machineId: Id, serviceId: Id) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("machine_open_service", { machineId, serviceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -984,6 +1097,12 @@ export type LocalhostError = { kind: "invalidKey"; detail: string } | { kind: "i * or reached, or the connection to it was lost. */ { kind: "coreUnavailable"; detail: string } | +/** + * This machine's shared core is another version's, which this one + * can't drive: kept alive by an older app or a coding agent's + * `localhost mcp`. + */ +{ kind: "incompatibleCore"; detail: { version: string } } | /** * Something already listens on the port; `owner` is that Listener, if * localhost can see it (processes of other users can't be inspected). @@ -1035,7 +1154,28 @@ export type LocalhostError = { kind: "invalidKey"; detail: string } | { kind: "i /** * An agent's own command (`claude mcp …`) failed: what it printed. */ -{ kind: "agentCommandFailed"; detail: { command: string; output: string } } +{ kind: "agentCommandFailed"; detail: { command: string; output: string } } | +/** + * Not something `ssh` can connect to: empty, with spaces, or like an option. + */ +{ kind: "invalidMachineDestination"; detail: string } | +/** + * Another Machine is already reached at this destination. + */ +{ kind: "duplicateMachine"; detail: string } | { kind: "machineNotFound"; detail: Id } | +/** + * Another Machine (by name) can't be reached over SSH, or the + * connection to it was lost. + */ +{ kind: "machineUnavailable"; detail: { machine: string; problem: MachineProblem } } | +/** + * The Port can't be forwarded from another Machine: SSH's reason. + */ +{ kind: "forwardFailed"; detail: { port: number; reason: string } } | +/** + * The other end runs an older localhost, which can't do this. + */ +{ kind: "unsupported" } export type LogBatch = { lines: LogLine[]; /** * Lines that arrived but were left out because the batch kept only its @@ -1075,6 +1215,105 @@ export type LogSource = * The latest Runs of these Services, and any they start later, in one stream. */ { kind: "services"; serviceIds: Id[] } +/** + * Another Machine, as this app remembers it. + */ +export type Machine = { id: Id; +/** + * What the app calls it: by default its destination. + */ +name: string; +/** + * What `ssh` connects to: a `Host` of `~/.ssh/config`, or `user@host`. + */ +destination: string } +export type MachineEvent = +/** + * Every Machine afresh: one was added, removed or renamed, or its + * state, Projects or statuses changed as a whole. + */ +{ kind: "machines"; machines: MachineView[] } | +/** + * A Service's state changed there. + */ +{ kind: "status"; machineId: Id; status: StatusView } | +/** + * Sent after the status that changed it. + */ +{ kind: "projectState"; machineId: Id; projectState: ProjectState } +/** + * Why another Machine can't be reached, as the app explains it. + */ +export type MachineProblem = +/** + * There is no `ssh` command on this computer. + */ +{ kind: "sshNotInstalled" } | +/** + * Its host key isn't in `known_hosts` yet. localhost never trusts one + * on the user's behalf. + */ +{ kind: "hostKeyNotTrusted" } | +/** + * Its host key differs from the one in `known_hosts`. + */ +{ kind: "hostKeyChanged" } | +/** + * SSH would have to ask for a password or passphrase, and can't. + */ +{ kind: "signInNeedsPrompt" } | { kind: "hostNotFound" } | +/** + * The network refused or timed out: SSH's words. + */ +{ kind: "unreachable"; detail: string } | +/** + * No `localhost` command there, or not on the `PATH` SSH gives. + */ +{ kind: "notInstalled" } | +/** + * Its localhost has no `localhost remote` (the version it reported, if any). + */ +{ kind: "tooOld"; detail: string | null } | +/** + * localhost there started but couldn't serve this app, in its own + * words, e.g. its shared core is another version's. + */ +{ kind: "cantServe"; detail: string } | +/** + * It was connected and the connection ended. + */ +{ kind: "lost" } | +/** + * Anything else SSH said, e.g. a mistake in `~/.ssh/config`. Not + * tried again on its own. + */ +{ kind: "other"; detail: string } +/** + * Where the app is with one Machine. + */ +export type MachineState = { kind: "notConnected" } | { kind: "connecting" } | +/** + * Connected to the localhost of this version there. + */ +{ kind: "connected"; detail: { version: string } } | +/** + * Not connected, for this reason. `is_retrying`: another attempt comes + * on its own. + */ +{ kind: "unavailable"; detail: { problem: MachineProblem; isRetrying: boolean } } +/** + * One Machine as the UI shows it. Its Projects and statuses are those of + * its last connection, kept while it reconnects. + */ +export type MachineView = { machine: Machine; state: MachineState; +/** + * How it is, in the core's words: "Connected · localhost 0.5.0", or why not. + */ +summary: string; isConnected: boolean; +/** + * It waits for the user to connect it again. + */ +needsUser: boolean; projects: Project[]; statuses: StatusView[]; projectStates: ProjectState[] } /** * Who or what requested a state-changing action. * @@ -1085,7 +1324,11 @@ export type Origin = "mainWindow" | "menuBar" | "cli" | /** * A coding agent, through `localhost mcp`. */ -"agent" +"agent" | +/** + * The localhost app of another Machine, through `localhost remote`. + */ +"machine" /** * A target (this machine's shared core, a WSL distro) had Orphans as it * connected: the window asks about them. diff --git a/apps/desktop/src/components/Announcer.tsx b/apps/desktop/src/components/Announcer.tsx index 03e5898..6d79a5a 100644 --- a/apps/desktop/src/components/Announcer.tsx +++ b/apps/desktop/src/components/Announcer.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import type { Id } from "../bindings"; +import type { Id, Project, ServiceState } from "../bindings"; import type { CoreView } from "../core"; -import { type Announcement, currentStates, onAnnouncement, settledAnnouncements } from "../announce"; +import { type Announcement, announce, currentStates, onAnnouncement, settledAnnouncements } from "../announce"; +import type { ShownMachine } from "../machines"; /** How long a message stays in its live region: long enough to be read, then out of the way. */ const MESSAGE_LIFETIME = 10_000; @@ -52,3 +53,36 @@ export function Announcer({ view, selectedProjectId }: { view: CoreView; selecte ); } + +/** + * Says when a Service of another Machine settles, through `Announcer`: with + * the Machine's name unless it is the selected one. What a Machine reads + * as it connects (its Projects afresh) is not announced. + */ +export function MachinesAnnouncer({ + machines, + selected, +}: { + machines: ShownMachine[]; + selected: { machineId: Id; projectId: Id | null } | null; +}) { + const seen = useRef(new Map }>()); + + useEffect(() => { + for (const shown of machines) { + const id = shown.machine.id; + const last = seen.current.get(id); + if (!last || last.projects !== shown.view.projects) { + seen.current.set(id, { projects: shown.view.projects, states: currentStates(shown.view) }); + continue; + } + const isSelected = selected?.machineId === id; + for (const { text, isUrgent } of settledAnnouncements(last.states, shown.view, isSelected ? selected.projectId : null)) { + announce(isSelected ? text : `${shown.machine.name}, ${text}`, isUrgent); + } + } + // Only new statuses are announced; the selection is read as they arrive. + }, [machines]); + + return null; +} diff --git a/apps/desktop/src/components/EmptyState.module.css b/apps/desktop/src/components/EmptyState.module.css index dc38502..feb5157 100644 --- a/apps/desktop/src/components/EmptyState.module.css +++ b/apps/desktop/src/components/EmptyState.module.css @@ -34,6 +34,14 @@ margin-top: 6px; } +/* Three side by side don't fit a column: one above the other, the first on top. */ +.stacked { + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: 220px; +} + .tip { margin: 4px 0 0; font-size: 12px; diff --git a/apps/desktop/src/components/EmptyState.tsx b/apps/desktop/src/components/EmptyState.tsx index 46521de..52cd273 100644 --- a/apps/desktop/src/components/EmptyState.tsx +++ b/apps/desktop/src/components/EmptyState.tsx @@ -7,18 +7,20 @@ type Props = { description: string; /** Buttons under the description. */ children?: ReactNode; + /** The buttons one above the other at one width, for more than two. */ + isStacked?: boolean; /** A line under the buttons that teaches a shortcut, e.g. a `KeyTip`. */ tip?: ReactNode; }; /** A column with nothing to show: an icon, a title, a line and actions. */ -export function EmptyState({ icon, title, description, children, tip }: Props) { +export function EmptyState({ icon, title, description, children, isStacked = false, tip }: Props) { return (
{icon}

{title}

{description}

- {children &&
{children}
} + {children &&
{children}
} {tip}
); diff --git a/apps/desktop/src/components/FailedBanner.tsx b/apps/desktop/src/components/FailedBanner.tsx index ec29243..292b741 100644 --- a/apps/desktop/src/components/FailedBanner.tsx +++ b/apps/desktop/src/components/FailedBanner.tsx @@ -1,5 +1,6 @@ import { start } from "../actions"; import type { Run, Service } from "../bindings"; +import { type Place, thisComputer } from "./ServicesColumn"; import type { LogBuffer } from "../log/buffer"; import { failureExcerpt, failureTitle, runDuration } from "../status"; import { ClearIcon } from "./Icons"; @@ -14,13 +15,14 @@ type Props = { buffer: LogBuffer; /** Puts the banner away for this Run. */ onHide: () => void; + place?: Place; }; /** * Why a Service Failed: what ended it and when, the error lines from its * output when the log below doesn't already show them all, and Start. */ -export function FailedBanner({ service, run, failureMessage, buffer, onHide }: Props) { +export function FailedBanner({ service, run, failureMessage, buffer, onHide, place = thisComputer }: Props) { const excerpt = failureExcerpt(buffer.lines); const duration = runDuration(run); return ( @@ -30,7 +32,11 @@ export function FailedBanner({ service, run, failureMessage, buffer, onHide }: P {failureTitle(service, run, failureMessage)} {duration && {duration}} - )} - {(status.state === "starting" || status.state === "running") && status.run && !status.run.endedAt && ( + {!machineId && (status.state === "starting" || status.state === "running") && status.run && !status.run.endedAt && ( )} @@ -114,7 +125,7 @@ export function LogPane({ project, view, selection }: { project: Project; view: onFilter={setFilter} filterField={filterField} onCopy={() => void copyLog(buffer, filter, linePrefixes)} - onClear={() => void clearLogs()} + onClear={machineId ? undefined : () => void clearLogs()} > {header} @@ -126,6 +137,7 @@ export function LogPane({ project, view, selection }: { project: Project; view: failureMessage={status.failureMessage} buffer={buffer} onHide={() => setHiddenBannerRunId(status.run!.id)} + place={place} /> )}
@@ -139,9 +151,9 @@ export function LogPane({ project, view, selection }: { project: Project; view: {(service ? !status?.run : buffer.lines.length === 0) && (

No output yet.

- {service && status?.state === "stopped" && ( + {service && status?.state === "stopped" && place.isReachable && ( <> - @@ -151,7 +163,12 @@ export function LogPane({ project, view, selection }: { project: Project; view: {!service && project.services.length > 0 && !isAnyActive && ( <>

Every service’s output shows here, each line marked with its service.

- @@ -172,7 +189,8 @@ type HeaderProps = { onFilter: (filter: string) => void; filterField: React.RefObject; onCopy: () => void; - onClear: () => void; + /** None for a log that can't be cleared here: another Machine's. */ + onClear?: () => void; }; /** The bar above a log: what it shows, then the filter and a `⋯` menu. */ @@ -208,10 +226,7 @@ function LogHeader({ children, filter, onFilter, filterField, onCopy, onClear }: ); diff --git a/apps/desktop/src/components/MachinesSettings.module.css b/apps/desktop/src/components/MachinesSettings.module.css new file mode 100644 index 0000000..f69601e --- /dev/null +++ b/apps/desktop/src/components/MachinesSettings.module.css @@ -0,0 +1,58 @@ +.empty { + margin: 0; +} + +.destination { + margin-left: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.grow { + flex: 1; + width: auto; +} + +.add { + display: grid; + gap: 8px; + padding: 11px 0; + border-top: 1px solid var(--separator); +} + +.label { + display: flex; + align-items: center; + gap: 12px; + font-size: 12px; + font-weight: 600; +} + +.label > input { + font-weight: normal; +} + +.add > p { + margin: 0; +} + +/* Why the Machine can't be reached: often long, and says what to type in a terminal. */ +.problem { + color: var(--state-failed); + font-size: 12px; + user-select: text; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--separator); +} + +.add .footer { + border-top: none; + padding-top: 4px; +} diff --git a/apps/desktop/src/components/MachinesSettings.tsx b/apps/desktop/src/components/MachinesSettings.tsx new file mode 100644 index 0000000..09aba3a --- /dev/null +++ b/apps/desktop/src/components/MachinesSettings.tsx @@ -0,0 +1,239 @@ +import { useEffect, useId, useState } from "react"; +import { commands } from "../bindings"; +import { report } from "../actions"; +import { type ShownMachine, useMachines } from "../machines"; +import { Menu } from "./Menu"; +import settings from "./SettingsDialog.module.css"; +import styles from "./MachinesSettings.module.css"; + +export const MACHINES_TITLE = "Machines"; +export const MACHINES_SUBTITLE = + "Other computers whose projects you run from here, over SSH. Their projects, secrets and services stay on them."; + +/** + * Settings › Machines: each other computer with where `ssh` reaches it and + * how it is, and Add Machine. Everything happens here, in the window: + * renaming and removing ask in the row itself. + */ +export function MachinesSettings() { + const machines = useMachines(); + const [isAdding, setIsAdding] = useState(false); + return ( + <> +
+

{MACHINES_TITLE}

+

{MACHINES_SUBTITLE}

+
+ {machines.map((shown) => ( + + ))} + {machines.length === 0 && !isAdding && ( +

+ No machines yet. Add one you can reach with ssh, like a dev box on your network or over Tailscale. +

+ )} + {isAdding ? ( + setIsAdding(false)} /> + ) : ( +
+ +
+ )} + + ); +} + +/** A Machine: its name, destination and state, and Reconnect, Rename and Remove. */ +function MachineRow({ shown }: { shown: ShownMachine }) { + const { machine } = shown; + const [mode, setMode] = useState<"show" | "rename" | "remove">("show"); + const [name, setName] = useState(machine.name); + const reconnect = async () => report(await commands.reconnectMachine(machine.id)); + const rename = async () => { + setMode("show"); + if (name.trim() !== machine.name) report(await commands.renameMachine(machine.id, name)); + }; + const remove = async () => report(await commands.removeMachine(machine.id)); + + if (mode === "rename") { + return ( +
+ setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void rename(); + if (event.key === "Escape") { + event.stopPropagation(); + setMode("show"); + } + }} + /> + + +
+ ); + } + if (mode === "remove") { + return ( +
+
+ Remove “{machine.name}”? + + localhost stops showing it here and closes its forwarded ports. Nothing changes on {machine.name}: its projects + and services stay as they are. + +
+ + +
+ ); + } + return ( +
+
+ + {machine.name} + {machine.name !== machine.destination && {machine.destination}} + + {shown.summary} +
+ {shown.needsUser && ( + + )} + void reconnect() }, + { + label: "Rename…", + onSelect: () => { + setName(machine.name); + setMode("rename"); + }, + }, + "separator", + { label: "Remove…", isDestructive: true, onSelect: () => setMode("remove") }, + ]} + /> +
+ ); +} + +/** + * Where `ssh` reaches the Machine (a host of `~/.ssh/config`, suggested, or + * `user@host:port`) and what to call it. Add connects first, and only once + * that works is the Machine kept; otherwise it says why, and what to do next. + */ +function AddMachine({ onDone }: { onDone: () => void }) { + const [destination, setDestination] = useState(""); + const [name, setName] = useState(""); + const [hosts, setHosts] = useState([]); + const [problem, setProblem] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const hostsId = useId(); + const hintId = useId(); + + useEffect(() => { + void commands.sshHosts().then(setHosts); + }, []); + + const add = async () => { + if (!destination.trim() || isConnecting) return; + setIsConnecting(true); + const result = await commands.addMachine(destination.trim(), name.trim() || null); + setIsConnecting(false); + if (result.status === "ok") onDone(); + else setProblem(result.error.message); + }; + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") void add(); + if (event.key === "Escape" && !isConnecting) { + event.stopPropagation(); + onDone(); + } + }; + + return ( +
{ + event.preventDefault(); + void add(); + }} + > + +

+ A host from ~/.ssh/config, or user@host:port. localhost connects as ssh does, with your keys and settings. +

+ + {problem && ( +

+ {problem} +

+ )} +
+ {isConnecting && Connecting to {destination.trim()}…} + + +
+
+ ); +} diff --git a/apps/desktop/src/components/ServicesColumn.tsx b/apps/desktop/src/components/ServicesColumn.tsx index c4c6434..5761c78 100644 --- a/apps/desktop/src/components/ServicesColumn.tsx +++ b/apps/desktop/src/components/ServicesColumn.tsx @@ -1,6 +1,6 @@ import { type CSSProperties, useId, useState } from "react"; import { openFolder, openUrl, restart, start, startAll, stop, stopAll } from "../actions"; -import type { Folder, Project, Service, StatusView } from "../bindings"; +import type { Folder, Id, Project, Service, StatusView } from "../bindings"; import { exportProject, requestRemoveService, setActiveProfile, setFavorite } from "../editors"; import { type CoreView, serviceStatus } from "../core"; import type { ServiceSelection } from "../selection"; @@ -16,15 +16,35 @@ import { StateGlyph } from "./StateGlyph"; import { ProjectStats } from "./Stats"; import styles from "./ServicesColumn.module.css"; +/** + * Where a Project is: this computer (`machineId` null), or another Machine, + * whose Services are controlled while it is connected and edited there. + */ +export type Place = { machineId: Id | null; isReachable: boolean }; + +export const thisComputer: Place = { machineId: null, isReachable: true }; + type Props = { project: Project; view: CoreView; selection: ServiceSelection; onSelect: (selection: ServiceSelection) => void; + place?: Place; }; /** The selected Project's Services with their state and actions, the combined stream on top. */ -export function ServicesColumn({ project, view, selection, onSelect }: Props) { +export function ServicesColumn({ project, view, selection, onSelect, place = thisComputer }: Props) { + if (project.services.length === 0 && place.machineId) { + return ( +
+ } + title="No Services" + description="Add services to this project on its machine." + /> +
+ ); + } if (project.services.length === 0) { return (
@@ -92,41 +112,54 @@ export function ServicesColumn({ project, view, selection, onSelect }: Props) { id={optionId(service.id)} isSelected={selection.kind === "service" && selection.serviceId === service.id} onSelect={() => onSelect({ kind: "service", serviceId: service.id })} + place={place} /> ))} - + {!place.machineId && ( + + )}
); } -/** The selected Project's toolbar actions: its Environment Profile once it has one, Start All, Stop All, and the rest in `⋯`. */ -export function ProjectActions({ project, view }: { project: Project; view: CoreView }) { +/** + * The selected Project's toolbar actions: its Environment Profile once it + * has one, Start All, Stop All, and the rest in `⋯`. Another Machine's + * Projects have only Start All and Stop All: the rest is changed there. + */ +export function ProjectActions({ project, view, place = thisComputer }: { project: Project; view: CoreView; place?: Place }) { const anyActive = project.services.some((service) => isActive(serviceStatus(view, service.id).state)); const [showsStats, setShowsStats] = useState(false); return ( <> - {(project.profiles ?? []).length > 0 && } + {(project.profiles ?? []).length > 0 && !place.machineId && } - + {!place.machineId && ( {showsStats && setShowsStats(false)} />} + )} ); } @@ -195,25 +229,32 @@ type RowProps = { id: string; isSelected: boolean; onSelect: () => void; + place: Place; }; /** A Service: a rail in its colour, its state, name and Port, its command, and, while selected, its actions. */ -function ServiceRow({ project, service, status, index, id, isSelected, onSelect }: RowProps) { +function ServiceRow({ project, service, status, index, id, isSelected, onSelect, place }: RowProps) { const state = status.state; // The active Run's port, which may have moved, else the Service's Port. const port = isActive(state) ? (status.run?.port ?? service.port) : service.port; const summary = rowSummary(project, service, status); const summaryClass = state === "failed" ? styles.failed : isWarning(status) ? styles.warning : "secondary"; - const edit = () => showSheet({ kind: "editService", serviceId: service.id }); - const context = useContextMenu(`${service.name} actions`, () => [ - ...stateMenuItems(service, status), - "separator", - { label: "Edit Service…", onSelect: edit }, - "separator", - ...folderMenuItems({ kind: "service", serviceId: service.id }), - "separator", - { label: "Remove Service…", isDestructive: true, onSelect: () => void requestRemoveService(service.id) }, - ]); + const edit = () => { + if (!place.machineId) showSheet({ kind: "editService", serviceId: service.id }); + }; + const context = useContextMenu(`${service.name} actions`, () => + place.machineId + ? stateMenuItems(service, status, place) + : [ + ...stateMenuItems(service, status, place), + "separator", + { label: "Edit Service…", onSelect: edit }, + "separator", + ...folderMenuItems({ kind: "service", serviceId: service.id }), + "separator", + { label: "Remove Service…", isDestructive: true, onSelect: () => void requestRemoveService(service.id) }, + ], + ); return (
  • {service.commandLine}
    - {isSelected && } + {isSelected && } {context.menu} @@ -243,7 +284,9 @@ function ServiceRow({ project, service, status, index, id, isSelected, onSelect } /** Open (or Restart) and Stop while live, Start otherwise; the main one tinted. */ -function ServiceActions({ service, status }: { service: Service; status: StatusView }) { +function ServiceActions({ service, status, place }: { service: Service; status: StatusView; place: Place }) { + const machineId = place.machineId; + const disabled = !place.isReachable; const act = (action: () => Promise) => (event: React.MouseEvent) => { event.stopPropagation(); void action(); @@ -257,7 +300,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact prominent" title={`Open ${url} (${shortcutLabel(SHORTCUTS.openUrl)})`} aria-label={`Open ${service.name} in Browser`} - onClick={act(() => openUrl(service.id))} + disabled={disabled} + onClick={act(() => openUrl(service.id, machineId))} > Open @@ -266,7 +310,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className={`button compact ${url ? "" : "prominent"}`} title={`Restart (${shortcutLabel(SHORTCUTS.startOrRestart)})`} aria-label={`Restart ${service.name}`} - onClick={act(() => restart(service.id))} + disabled={disabled} + onClick={act(() => restart(service.id, machineId))} > Restart @@ -274,7 +319,8 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact" title={`Stop (${shortcutLabel(SHORTCUTS.stop)})`} aria-label={`Stop ${service.name}`} - onClick={act(() => stop(service.id))} + disabled={disabled} + onClick={act(() => stop(service.id, machineId))} > Stop @@ -294,36 +340,41 @@ function ServiceActions({ service, status }: { service: Service; status: StatusV className="button compact prominent" title={`Start (${shortcutLabel(SHORTCUTS.startOrRestart)})`} aria-label={`Start ${service.name}`} - onClick={act(() => start(service.id))} + disabled={disabled} + onClick={act(() => start(service.id, null, machineId))} > Start - + {!machineId && ( + + )}
    ); } /** What a Service's context menu offers for its state, like its buttons. */ -function stateMenuItems(service: Service, status: StatusView): MenuItem[] { +function stateMenuItems(service: Service, status: StatusView, place: Place): MenuItem[] { + const machineId = place.machineId; + if (!place.isReachable) return []; switch (status.state) { case "starting": case "running": return [ - ...(status.urlToOpen ? [{ label: "Open in Browser", onSelect: () => void openUrl(service.id) }] : []), - { label: "Restart", onSelect: () => void restart(service.id) }, - { label: "Stop", onSelect: () => void stop(service.id) }, + ...(status.urlToOpen ? [{ label: "Open in Browser", onSelect: () => void openUrl(service.id, machineId) }] : []), + { label: "Restart", onSelect: () => void restart(service.id, machineId) }, + { label: "Stop", onSelect: () => void stop(service.id, machineId) }, ]; case "stopping": return []; default: - return [{ label: "Start", onSelect: () => void start(service.id) }]; + return [{ label: "Start", onSelect: () => void start(service.id, null, machineId) }]; } } diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index 435a5d2..37aca18 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -4,15 +4,17 @@ import { saveSettings, useSettings } from "../settings"; import { checkForUpdates } from "../updates"; import { CONNECT_AGENTS_SUBTITLE, CONNECT_AGENTS_TITLE, ConnectAgents } from "./ConnectAgents"; import { Dialog, useModal } from "./Dialog"; -import { CircleHalfIcon, GearIcon, GlobeIcon, LinkIcon } from "./Icons"; +import { CircleHalfIcon, DesktopIcon, GearIcon, GlobeIcon, LinkIcon } from "./Icons"; +import { MachinesSettings } from "./MachinesSettings"; import styles from "./SettingsDialog.module.css"; -type Section = "general" | "appearance" | "ports" | "agents"; +type Section = "general" | "appearance" | "ports" | "machines" | "agents"; const sections: { id: Section; title: string; icon: ReactNode }[] = [ { id: "general", title: "General", icon: }, { id: "appearance", title: "Appearance", icon: }, { id: "ports", title: "Ports", icon: }, + { id: "machines", title: "Machines", icon: }, { id: "agents", title: "Agents", icon: }, ]; @@ -61,6 +63,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) { {section === "general" && } {section === "appearance" && } {section === "ports" && } + {section === "machines" && } {section === "agents" && ( <> diff --git a/apps/desktop/src/components/Sidebar.module.css b/apps/desktop/src/components/Sidebar.module.css index 1967a41..18dd650 100644 --- a/apps/desktop/src/components/Sidebar.module.css +++ b/apps/desktop/src/components/Sidebar.module.css @@ -97,6 +97,19 @@ font-size: 12px; } +/* How another Machine is, and Reconnect when it waits for the user. */ +.machineNote { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +/* Another Machine's Projects while it isn't connected: shown as they were. */ +.dimmed { + opacity: 0.6; +} + .row { position: relative; display: flex; diff --git a/apps/desktop/src/components/Sidebar.test.tsx b/apps/desktop/src/components/Sidebar.test.tsx index ad4a402..cd9a389 100644 --- a/apps/desktop/src/components/Sidebar.test.tsx +++ b/apps/desktop/src/components/Sidebar.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { viewFromSnapshot } from "../core"; -import { project, service, status } from "../test/fixtures"; +import { machineView, project, service, status } from "../test/fixtures"; +import { shownMachine } from "../machines"; import { Sidebar } from "./Sidebar"; describe("Sidebar", () => { @@ -52,4 +53,30 @@ describe("Sidebar", () => { fireEvent.change(search, { target: { value: "zzz" } }); expect(screen.getByText("No projects match “zzz”.")).toBeTruthy(); }); + + it("shows each other Machine's Projects below this computer's, and why one isn't connected", () => { + const web = service("Web"); + const shop = project("Shop", 0, [web]); + const devbox = shownMachine( + machineView("devbox", [shop], [status(web.id, "running")], { projectStates: [{ projectId: shop.id, state: "running" }] }), + ); + const lab = shownMachine( + machineView("lab", [], [], { + state: { kind: "unavailable", detail: { problem: { kind: "hostKeyNotTrusted" }, isRetrying: false } }, + summary: "lab’s host key isn’t trusted yet. Connect once from a terminal with `ssh lab`, then try again.", + isConnected: false, + needsUser: true, + }), + ); + const onSelect = vi.fn(); + const empty = viewFromSnapshot({ projects: [], statuses: [], projectStates: [] }); + render(); + + const rows = screen.getAllByRole("option"); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual(["Ports", "Shop, 1 of 1 running"]); + fireEvent.click(rows[1]); + expect(onSelect).toHaveBeenCalledWith({ kind: "machineProject", machineId: devbox.machine.id, projectId: shop.id }); + expect(screen.getByText(/lab’s host key isn’t trusted yet/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reconnect lab" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 3ef95de..bb5663d 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -1,14 +1,16 @@ import { useId, useState } from "react"; -import type { Project } from "../bindings"; +import { report, startAll, stopAll } from "../actions"; +import { commands, type Project } from "../bindings"; import { type CoreView, serviceState } from "../core"; import type { SidebarSelection } from "../selection"; import { importProject } from "../editors"; import { SHORTCUTS, shortcutLabel } from "../keyboard"; +import type { ShownMachine } from "../machines"; import { isActive, projectServicesSummary } from "../status"; import { showAlert, showSheet } from "../ui"; import { useUpdate } from "../updates"; import { ClearIcon, GearIcon, GlobeIcon, KeyboardIcon, PlusIcon, SearchIcon } from "./Icons"; -import { Menu, useContextMenu } from "./Menu"; +import { Menu, type MenuItem, useContextMenu } from "./Menu"; import { projectMenuItems } from "./ServicesColumn"; import { ShortcutBadge } from "./ShortcutBadge"; import { StateGlyph } from "./StateGlyph"; @@ -17,31 +19,37 @@ import styles from "./Sidebar.module.css"; type Props = { view: CoreView; + /** Other Machines, each shown below this computer's Projects. */ + machines?: ShownMachine[]; selection: SidebarSelection | null; onSelect: (selection: SidebarSelection) => void; }; /** - * The wordmark and project search, Ports and the Projects, a newer version - * when there is one, and a footer with Settings and how many Services run. + * The wordmark and project search, Ports and the Projects, then each other + * Machine's, a newer version when there is one, and a footer with Settings + * and how many Services run. */ -export function Sidebar({ view, selection, onSelect }: Props) { +export function Sidebar({ view, machines = [], selection, onSelect }: Props) { const [search, setSearch] = useState(""); const update = useUpdate(); const query = search.trim().toLowerCase(); - const projects = query - ? view.projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) - : view.projects; + const matching = (projects: Project[]) => + query ? projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) : projects; + const projects = matching(view.projects); const running = [...view.statuses.values()].filter((status) => isActive(status.state)).length; // Orca follows the selected row, as focus stays on the list. const listId = useId(); const optionId = (key: string) => `${listId}-${key}`; + const machineKey = (machineId: string, projectId: string) => `${machineId}:${projectId}`; const selectedKey = selection?.kind === "ports" ? "ports" : selection?.kind === "project" && projects.some((project) => project.id === selection.projectId) ? selection.projectId - : null; + : selection?.kind === "machineProject" + ? machineKey(selection.machineId, selection.projectId) + : null; return (