mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
feat(mobile): reach the machines the desktop is linked to over SSH
A machine's screen now lists, under its own workspaces, every machine its tty7 holds an SSH link to, with that machine's workspaces. Panes there open, take input and get new tabs like local ones. "Needs you" gathers panes from all of them. - The gateway routes through the local server over links it already holds, the same way `tty7 -m <machine>` does. It never dials a down link, because that would guess at credentials the phone does not have. A down link shows as "Link down", with a note to reconnect it on the desktop. - Protocol, additive: `Tree.remotes`, and an optional `machine` (the link key) on `Open::Pane` and `Open::NewTab`. A local pane is asked for exactly as before, so older gateways still understand it. - Rebuilding a route target from a link key moves from the CLI into tty7-core as `RouteInfo::target` / `RouteInfo::host`, so the CLI and the gateway share one rule. The CLI now calls it.
This commit is contained in:
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use serde_json::json;
|
||||
use tty7_core::client::{ControlClient, PaneClient, PaneSession};
|
||||
use tty7_core::core::agent_hooks::{HookAgent, HookTarget, HooksState, hooks_state};
|
||||
use tty7_core::core::session::WorkspaceId;
|
||||
@@ -483,47 +482,11 @@ fn keys<'a>(routes: impl Iterator<Item = &'a RouteInfo>) -> String {
|
||||
}
|
||||
|
||||
fn route_matches(name: &str, route: &RouteInfo) -> bool {
|
||||
route.key == name || host_of(&route.key) == Some(name)
|
||||
}
|
||||
|
||||
fn host_of(key: &str) -> Option<&str> {
|
||||
let first = key.split('|').next()?;
|
||||
let after_user = first.split('@').nth(1)?;
|
||||
after_user.split(':').next()
|
||||
route.key == name || route.host() == Some(name)
|
||||
}
|
||||
|
||||
fn target_for(route: &RouteInfo) -> Result<RouteTarget> {
|
||||
if route.kind != "ssh" {
|
||||
bail!(
|
||||
"machine '{}' is a {} link — the CLI can only route over ssh links yet",
|
||||
route.key,
|
||||
route.kind
|
||||
);
|
||||
}
|
||||
if route.key.contains('|') {
|
||||
bail!(
|
||||
"machine '{}' is reached through a jump/proxy chain, which the CLI cannot \
|
||||
rebuild from the link key yet — use the GUI for this machine",
|
||||
route.key
|
||||
);
|
||||
}
|
||||
let (user, rest) = route
|
||||
.key
|
||||
.split_once('@')
|
||||
.ok_or_else(|| anyhow!("unrecognized machine key '{}'", route.key))?;
|
||||
let (host, port) = rest
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow!("unrecognized machine key '{}'", route.key))?;
|
||||
let port: u16 = port
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("unrecognized machine key '{}'", route.key))?;
|
||||
let spec = serde_json::from_value(json!({
|
||||
"user": user,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"auth_mode": "auto",
|
||||
}))?;
|
||||
Ok(RouteTarget::Ssh(Box::new(spec)))
|
||||
route.target().map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -376,6 +376,52 @@ pub struct RouteInfo {
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
impl RouteInfo {
|
||||
/// The host a link reaches: `build-box` in `me@build-box:22`. `None` for a
|
||||
/// key of another shape.
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
let first = self.key.split('|').next()?;
|
||||
let after_user = first.split('@').nth(1)?;
|
||||
after_user.split(':').next()
|
||||
}
|
||||
|
||||
/// Rebuilds the target to route to this link over the local server, from
|
||||
/// its key.
|
||||
///
|
||||
/// Routing over a link the server already holds only needs the key's
|
||||
/// `user@host:port` — the link is found by it and its credentials are
|
||||
/// never used again — so an `auto` auth mode stands in for the profile's.
|
||||
/// A jump/proxy chain would need the whole chain rebuilt, which the key
|
||||
/// alone cannot do yet.
|
||||
pub fn target(&self) -> Result<crate::daemon::router::RouteTarget, String> {
|
||||
let unrecognized = || format!("unrecognized machine key '{}'", self.key);
|
||||
if self.kind != "ssh" {
|
||||
return Err(format!(
|
||||
"machine '{}' is a {} link — tty7 can only route over ssh links yet",
|
||||
self.key, self.kind
|
||||
));
|
||||
}
|
||||
if self.key.contains('|') {
|
||||
return Err(format!(
|
||||
"machine '{}' is reached through a jump/proxy chain, which cannot be rebuilt \
|
||||
from the link key yet — use the GUI for this machine",
|
||||
self.key
|
||||
));
|
||||
}
|
||||
let (user, rest) = self.key.split_once('@').ok_or_else(unrecognized)?;
|
||||
let (host, port) = rest.rsplit_once(':').ok_or_else(unrecognized)?;
|
||||
let port: u16 = port.parse().map_err(|_| unrecognized())?;
|
||||
let spec = serde_json::from_value(serde_json::json!({
|
||||
"user": user,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"auth_mode": "auto",
|
||||
}))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(crate::daemon::router::RouteTarget::Ssh(Box::new(spec)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerStatus {
|
||||
pub pid: u32,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! The real [`Backend`]: this machine's tty7 server, over its local sockets.
|
||||
//! The real [`Backend`]: this machine's tty7 server, over its local sockets —
|
||||
//! and through it, every machine it holds an SSH link to.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
@@ -8,12 +10,13 @@ use std::time::Duration;
|
||||
use tty7_core::client::{ControlClient, PaneClient, PaneSession};
|
||||
use tty7_core::core::machine::Machine;
|
||||
use tty7_core::daemon::control::{
|
||||
ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, WorkspaceId,
|
||||
ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, RouteInfo, WorkspaceId,
|
||||
};
|
||||
use tty7_core::daemon::protocol::{DaemonMsg, WinSize};
|
||||
use tty7_core::daemon::router::RouteTarget;
|
||||
use tty7_mobile_proto::{GridSize, TabCreated};
|
||||
|
||||
use crate::serve::{Backend, PaneFeed};
|
||||
use crate::serve::{Backend, PaneFeed, Remote};
|
||||
|
||||
/// The size the gateway claims when it observes a pane. The daemon ignores it
|
||||
/// for an observer — the pane keeps the size its window gave it — but the
|
||||
@@ -37,6 +40,9 @@ const NEW_TAB_SIZE: WinSize = WinSize {
|
||||
pub struct Daemon {
|
||||
host: String,
|
||||
control: Mutex<Option<ControlClient>>,
|
||||
/// A control connection per linked machine, routed through the local
|
||||
/// server, by link key.
|
||||
remote: Mutex<HashMap<String, ControlClient>>,
|
||||
}
|
||||
|
||||
impl Default for Daemon {
|
||||
@@ -44,19 +50,23 @@ impl Default for Daemon {
|
||||
Daemon {
|
||||
host: hostname(),
|
||||
control: Mutex::new(None),
|
||||
remote: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
fn hello(&self) -> ControlHello {
|
||||
ControlHello::host_rpc(
|
||||
format!("tty7-gateway-{}", std::process::id()),
|
||||
self.host.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn request(&self, req: ControlRequest) -> io::Result<ReplyOk> {
|
||||
let mut slot = self.control.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !slot.as_ref().is_some_and(ControlClient::is_connected) {
|
||||
let hello = ControlHello::host_rpc(
|
||||
format!("tty7-gateway-{}", std::process::id()),
|
||||
self.host.clone(),
|
||||
);
|
||||
*slot = Some(ControlClient::connect(&hello)?);
|
||||
*slot = Some(ControlClient::connect(&self.hello())?);
|
||||
}
|
||||
let client = slot.as_ref().expect("connected just above");
|
||||
let reply = client.request(req);
|
||||
@@ -65,6 +75,70 @@ impl Daemon {
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
fn routes(&self) -> io::Result<Vec<RouteInfo>> {
|
||||
match self.request(ControlRequest::Routes)? {
|
||||
ReplyOk::Routes(routes) => Ok(routes),
|
||||
other => Err(unexpected("Routes", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to route for a linked machine — only over a link the desktop
|
||||
/// already holds. Routing to a down one would have the server dial it
|
||||
/// afresh, guessing at credentials the phone does not have.
|
||||
fn target(&self, key: &str) -> io::Result<RouteTarget> {
|
||||
let routes = self.routes()?;
|
||||
let route = routes
|
||||
.iter()
|
||||
.find(|r| r.key == key)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no machine {key}")))?;
|
||||
if !route.connected {
|
||||
return Err(link_down(key));
|
||||
}
|
||||
route.target().map_err(io::Error::other)
|
||||
}
|
||||
|
||||
fn request_on(&self, machine: Option<&str>, req: ControlRequest) -> io::Result<ReplyOk> {
|
||||
let Some(key) = machine else {
|
||||
return self.request(req);
|
||||
};
|
||||
let mut clients = self.remote.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !clients.get(key).is_some_and(ControlClient::is_connected) {
|
||||
let client = ControlClient::routed(self.target(key)?, &self.hello())?;
|
||||
clients.insert(key.to_string(), client);
|
||||
}
|
||||
let reply = clients[key].request(req);
|
||||
if reply.is_err() {
|
||||
clients.remove(key);
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
fn panes_on(&self, machine: Option<&str>) -> io::Result<PaneClient> {
|
||||
match machine {
|
||||
None => Ok(PaneClient::local()),
|
||||
Some(key) => Ok(PaneClient::routed(self.target(key)?)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_on(&self, machine: Option<&str>) -> io::Result<(Machine, Vec<PaneAgentState>)> {
|
||||
let tree = match self.request_on(machine, ControlRequest::MachineGet)? {
|
||||
ReplyOk::MachineTree(tree) => *tree,
|
||||
other => return Err(unexpected("MachineGet", &other)),
|
||||
};
|
||||
let agents = match self.request_on(machine, ControlRequest::AgentStates)? {
|
||||
ReplyOk::AgentStates(agents) => agents,
|
||||
other => return Err(unexpected("AgentStates", &other)),
|
||||
};
|
||||
Ok((tree, agents))
|
||||
}
|
||||
}
|
||||
|
||||
fn link_down(key: &str) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotConnected,
|
||||
format!("the desktop's link to {key} is down — reconnect it from tty7 on the computer"),
|
||||
)
|
||||
}
|
||||
|
||||
impl Backend for Daemon {
|
||||
@@ -73,30 +147,43 @@ impl Backend for Daemon {
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> io::Result<(Machine, Vec<PaneAgentState>)> {
|
||||
let machine = match self.request(ControlRequest::MachineGet)? {
|
||||
ReplyOk::MachineTree(machine) => *machine,
|
||||
other => return Err(unexpected("MachineGet", &other)),
|
||||
};
|
||||
let agents = match self.request(ControlRequest::AgentStates)? {
|
||||
ReplyOk::AgentStates(agents) => agents,
|
||||
other => return Err(unexpected("AgentStates", &other)),
|
||||
};
|
||||
Ok((machine, agents))
|
||||
self.snapshot_on(None)
|
||||
}
|
||||
|
||||
fn observe(&self, pane_id: u64) -> io::Result<Box<dyn PaneFeed>> {
|
||||
let session = PaneClient::local().observe(pane_id, OBSERVE_SIZE)?;
|
||||
fn remotes(&self) -> Vec<Remote> {
|
||||
let Ok(routes) = self.routes() else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Links that went away take their routed connections with them.
|
||||
self.remote
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.retain(|key, _| routes.iter().any(|r| &r.key == key && r.connected));
|
||||
routes
|
||||
.into_iter()
|
||||
.map(|route| Remote {
|
||||
name: route.host().unwrap_or(&route.key).to_string(),
|
||||
snapshot: route.connected.then(|| self.snapshot_on(Some(&route.key))),
|
||||
connected: route.connected,
|
||||
key: route.key,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result<Box<dyn PaneFeed>> {
|
||||
let session = self.panes_on(machine)?.observe(pane_id, OBSERVE_SIZE)?;
|
||||
Ok(Box::new(Observed(session)))
|
||||
}
|
||||
|
||||
fn send_input(&self, pane_id: u64, bytes: &[u8]) -> io::Result<()> {
|
||||
PaneClient::local().send_input(pane_id, bytes)
|
||||
fn send_input(&self, machine: Option<&str>, pane_id: u64, bytes: &[u8]) -> io::Result<()> {
|
||||
self.panes_on(machine)?.send_input(pane_id, bytes)
|
||||
}
|
||||
|
||||
/// The two steps `tty7 tab new` takes: spawn a shell owned by the
|
||||
/// workspace, then hang a tab on it.
|
||||
/// workspace, then hang a tab on it — on whichever machine it lives on.
|
||||
fn new_tab(
|
||||
&self,
|
||||
machine: Option<&str>,
|
||||
workspace_id: &str,
|
||||
cwd: Option<String>,
|
||||
size: Option<GridSize>,
|
||||
@@ -113,7 +200,7 @@ impl Backend for Daemon {
|
||||
..NEW_TAB_SIZE
|
||||
});
|
||||
let owner = workspace.to_string();
|
||||
let session = PaneClient::local().spawn(
|
||||
let session = self.panes_on(machine)?.spawn(
|
||||
cwd.as_deref().map(PathBuf::from),
|
||||
size,
|
||||
None,
|
||||
@@ -129,12 +216,13 @@ impl Backend for Daemon {
|
||||
agent: None,
|
||||
shell: None,
|
||||
};
|
||||
match self.request(ControlRequest::TabCreate {
|
||||
let create = ControlRequest::TabCreate {
|
||||
workspace,
|
||||
at: None,
|
||||
pane: seed,
|
||||
tab: None,
|
||||
})? {
|
||||
};
|
||||
match self.request_on(machine, create)? {
|
||||
ReplyOk::TabTree(tab) => Ok(TabCreated {
|
||||
tab_id: tab.id.to_string(),
|
||||
pane_id: pane,
|
||||
|
||||
@@ -21,7 +21,7 @@ use tty7_core::daemon::control::PaneAgentState;
|
||||
use tty7_core::daemon::protocol::DaemonMsg;
|
||||
use tty7_mobile_proto::{
|
||||
ControlEvent, ControlRequest, Frame, GridSize, Open, OpenReply, PROTOCOL_VERSION, PaneEvent,
|
||||
TabCreated, Tree, read_frame, write_bytes, write_msg,
|
||||
RemoteView, TabCreated, Tree, read_frame, write_bytes, write_msg,
|
||||
};
|
||||
|
||||
use crate::state::State;
|
||||
@@ -44,20 +44,35 @@ const PAIR_FAILURE_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// What the gateway needs from the machine it serves. The daemon is the real
|
||||
/// one; tests stand in a fake so they can drive the bridge without a PTY.
|
||||
///
|
||||
/// `machine` names a remote the desktop is linked to, by its link key, and is
|
||||
/// `None` for this machine itself.
|
||||
pub trait Backend: Send + Sync + 'static {
|
||||
fn hostname(&self) -> String;
|
||||
fn snapshot(&self) -> io::Result<(Machine, Vec<PaneAgentState>)>;
|
||||
fn observe(&self, pane_id: u64) -> io::Result<Box<dyn PaneFeed>>;
|
||||
fn send_input(&self, pane_id: u64, bytes: &[u8]) -> io::Result<()>;
|
||||
/// Every machine the desktop is linked to, each read through its link.
|
||||
fn remotes(&self) -> Vec<Remote>;
|
||||
fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result<Box<dyn PaneFeed>>;
|
||||
fn send_input(&self, machine: Option<&str>, pane_id: u64, bytes: &[u8]) -> io::Result<()>;
|
||||
/// Starts a shell in a new tab at the end of `workspace_id`.
|
||||
fn new_tab(
|
||||
&self,
|
||||
machine: Option<&str>,
|
||||
workspace_id: &str,
|
||||
cwd: Option<String>,
|
||||
size: Option<GridSize>,
|
||||
) -> io::Result<TabCreated>;
|
||||
}
|
||||
|
||||
/// One linked machine, as [`Backend::remotes`] reports it.
|
||||
pub struct Remote {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub connected: bool,
|
||||
/// Its tree, read through the link; `None` while the link is down.
|
||||
pub snapshot: Option<io::Result<(Machine, Vec<PaneAgentState>)>>,
|
||||
}
|
||||
|
||||
/// A read-only view onto one pane's output, as the daemon sends it.
|
||||
pub trait PaneFeed: Send {
|
||||
/// The next message, or `Ok(None)` if none arrived within `wait`.
|
||||
@@ -153,12 +168,15 @@ async fn serve_stream(
|
||||
workspace_id,
|
||||
cwd,
|
||||
size,
|
||||
machine,
|
||||
} => {
|
||||
let created = {
|
||||
let backend = backend.clone();
|
||||
tokio::task::spawn_blocking(move || backend.new_tab(&workspace_id, cwd, size))
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
tokio::task::spawn_blocking(move || {
|
||||
backend.new_tab(machine.as_deref(), &workspace_id, cwd, size)
|
||||
})
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
};
|
||||
match created {
|
||||
Ok(created) => {
|
||||
@@ -170,17 +188,17 @@ async fn serve_stream(
|
||||
finish(send).await;
|
||||
Ok(())
|
||||
}
|
||||
Open::Pane { pane_id } => {
|
||||
Open::Pane { pane_id, machine } => {
|
||||
let feed = {
|
||||
let backend = backend.clone();
|
||||
tokio::task::spawn_blocking(move || backend.observe(pane_id))
|
||||
let (backend, machine) = (backend.clone(), machine.clone());
|
||||
tokio::task::spawn_blocking(move || backend.observe(machine.as_deref(), pane_id))
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
};
|
||||
match feed {
|
||||
Ok(feed) => {
|
||||
write_msg(&mut send, &ok).await?;
|
||||
pane_stream(pane_id, feed, send, recv, backend).await
|
||||
pane_stream(machine, pane_id, feed, send, recv, backend).await
|
||||
}
|
||||
Err(e) => {
|
||||
write_msg(&mut send, &denied(&e.to_string())).await?;
|
||||
@@ -252,7 +270,8 @@ fn watch_tree(
|
||||
let event = match backend.snapshot() {
|
||||
Ok((machine, agents)) => {
|
||||
failing = false;
|
||||
let tree = tree::build(&host, &machine, &agents);
|
||||
let mut tree = tree::build(&host, &machine, &agents);
|
||||
tree.remotes = backend.remotes().into_iter().map(remote_view).collect();
|
||||
if forced || last.as_ref() != Some(&tree) {
|
||||
last = Some(tree.clone());
|
||||
Some(ControlEvent::Tree(tree))
|
||||
@@ -283,6 +302,24 @@ fn watch_tree(
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_view(remote: Remote) -> RemoteView {
|
||||
let (workspaces, error) = match remote.snapshot {
|
||||
Some(Ok((machine, agents))) => (
|
||||
tree::build(&remote.name, &machine, &agents).workspaces,
|
||||
None,
|
||||
),
|
||||
Some(Err(e)) => (Vec::new(), Some(e.to_string())),
|
||||
None => (Vec::new(), None),
|
||||
};
|
||||
RemoteView {
|
||||
key: remote.key,
|
||||
name: remote.name,
|
||||
connected: remote.connected,
|
||||
error,
|
||||
workspaces,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to say when the tty7 server can't be reached. No socket at all means
|
||||
/// no server is running, which is the common case and has a fix to name; any
|
||||
/// other failure is passed on as it came.
|
||||
@@ -301,6 +338,7 @@ enum Down {
|
||||
}
|
||||
|
||||
async fn pane_stream(
|
||||
machine: Option<String>,
|
||||
pane_id: u64,
|
||||
feed: Box<dyn PaneFeed>,
|
||||
mut send: SendStream,
|
||||
@@ -319,7 +357,7 @@ async fn pane_stream(
|
||||
.name(format!("gateway-input-{pane_id}"))
|
||||
.spawn(move || {
|
||||
for bytes in input_rx {
|
||||
if let Err(e) = backend.send_input(pane_id, &bytes) {
|
||||
if let Err(e) = backend.send_input(machine.as_deref(), pane_id, &bytes) {
|
||||
eprintln!("tty7-gateway: input to pane {pane_id}: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree {
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
remotes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use iroh::endpoint::presets;
|
||||
use tty7_core::core::machine::{Machine, Tab, Workspace};
|
||||
use tty7_core::daemon::control::PaneAgentState;
|
||||
use tty7_core::daemon::protocol::{DaemonMsg, WinSize};
|
||||
use tty7_gateway::serve::{self, Backend, PaneFeed};
|
||||
use tty7_gateway::serve::{self, Backend, PaneFeed, Remote};
|
||||
use tty7_gateway::state::State;
|
||||
use tty7_mobile_client::{PaneItem, Session};
|
||||
use tty7_mobile_proto::{ALPN, ControlEvent, GridSize, PairCode, PaneEvent, TabCreated};
|
||||
@@ -56,10 +56,38 @@ impl Backend for FakeMachine {
|
||||
Ok((self.machine.clone(), Vec::new()))
|
||||
}
|
||||
|
||||
fn observe(&self, pane_id: u64) -> io::Result<Box<dyn PaneFeed>> {
|
||||
/// One linked machine that is up, holding the same tree, and one whose
|
||||
/// link is down.
|
||||
fn remotes(&self) -> Vec<Remote> {
|
||||
vec![
|
||||
Remote {
|
||||
key: "me@build-box:22".into(),
|
||||
name: "build-box".into(),
|
||||
connected: true,
|
||||
snapshot: Some(Ok((self.machine.clone(), Vec::new()))),
|
||||
},
|
||||
Remote {
|
||||
key: "me@gone:22".into(),
|
||||
name: "gone".into(),
|
||||
connected: false,
|
||||
snapshot: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn observe(&self, machine: Option<&str>, pane_id: u64) -> io::Result<Box<dyn PaneFeed>> {
|
||||
if machine == Some("me@gone:22") {
|
||||
return Err(io::Error::other("the desktop's link to me@gone:22 is down"));
|
||||
}
|
||||
if pane_id != 1 {
|
||||
return Err(io::Error::other(format!("no such pane {pane_id}")));
|
||||
}
|
||||
// The remote's pane 1 says where it is, so a test can tell it from
|
||||
// the local one.
|
||||
let prompt = match machine {
|
||||
Some(key) => format!("{key}$ ").into_bytes(),
|
||||
None => b"$ ".to_vec(),
|
||||
};
|
||||
let (tx, rx) = std_mpsc::channel();
|
||||
*self.typed.lock().unwrap() = Some(tx);
|
||||
Ok(Box::new(FakeFeed {
|
||||
@@ -70,13 +98,13 @@ impl Backend for FakeMachine {
|
||||
cell_w: 8,
|
||||
cell_h: 16,
|
||||
}),
|
||||
DaemonMsg::Snapshot(b"$ ".to_vec()),
|
||||
DaemonMsg::Snapshot(prompt),
|
||||
],
|
||||
typed: rx,
|
||||
}))
|
||||
}
|
||||
|
||||
fn send_input(&self, _pane_id: u64, bytes: &[u8]) -> io::Result<()> {
|
||||
fn send_input(&self, _machine: Option<&str>, _pane_id: u64, bytes: &[u8]) -> io::Result<()> {
|
||||
let typed = self.typed.lock().unwrap();
|
||||
typed
|
||||
.as_ref()
|
||||
@@ -87,6 +115,7 @@ impl Backend for FakeMachine {
|
||||
|
||||
fn new_tab(
|
||||
&self,
|
||||
machine: Option<&str>,
|
||||
workspace_id: &str,
|
||||
cwd: Option<String>,
|
||||
size: Option<GridSize>,
|
||||
@@ -97,7 +126,7 @@ impl Backend for FakeMachine {
|
||||
// Echo what was asked for in the tab id, so the test can see it
|
||||
// crossed intact.
|
||||
Ok(TabCreated {
|
||||
tab_id: format!("{cwd:?} {size:?}"),
|
||||
tab_id: format!("{machine:?} {cwd:?} {size:?}"),
|
||||
pane_id: 2,
|
||||
})
|
||||
}
|
||||
@@ -205,7 +234,7 @@ async fn an_unpaired_phone_is_turned_away() {
|
||||
let session = Session::connect(&rig.phone, &host).await.unwrap();
|
||||
let err = within(session.control()).await.err().expect("denied");
|
||||
assert!(err.to_string().contains("not paired"), "{err}");
|
||||
let err = within(session.pane(1)).await.err().expect("denied");
|
||||
let err = within(session.pane(None, 1)).await.err().expect("denied");
|
||||
assert!(err.to_string().contains("not paired"), "{err}");
|
||||
}
|
||||
|
||||
@@ -230,7 +259,7 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() {
|
||||
Some(ControlEvent::Tree(t)) if t == first
|
||||
));
|
||||
|
||||
let (mut keys, mut screen) = within(session.pane(1)).await.unwrap();
|
||||
let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap();
|
||||
assert_eq!(
|
||||
within(screen.next()).await.unwrap(),
|
||||
Some(PaneItem::Event(PaneEvent::Size {
|
||||
@@ -254,7 +283,10 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() {
|
||||
);
|
||||
assert_eq!(within(screen.next()).await.unwrap(), None, "stream ends");
|
||||
|
||||
let err = within(session.pane(99)).await.err().expect("no such pane");
|
||||
let err = within(session.pane(None, 99))
|
||||
.await
|
||||
.err()
|
||||
.expect("no such pane");
|
||||
assert!(err.to_string().contains("no such pane 99"), "{err}");
|
||||
}
|
||||
|
||||
@@ -281,16 +313,30 @@ async fn a_paired_phone_opens_a_tab() {
|
||||
let ws = &first.workspaces[0].id;
|
||||
|
||||
let size = GridSize { cols: 56, rows: 40 };
|
||||
let created = within(session.new_tab(ws, Some("/src/demo".into()), Some(size)))
|
||||
let created = within(session.new_tab(None, ws, Some("/src/demo".into()), Some(size)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.pane_id, 2);
|
||||
assert_eq!(
|
||||
created.tab_id,
|
||||
format!("{:?} {:?}", Some("/src/demo"), Some(size))
|
||||
format!(
|
||||
"{:?} {:?} {:?}",
|
||||
None::<&str>,
|
||||
Some("/src/demo"),
|
||||
Some(size)
|
||||
)
|
||||
);
|
||||
|
||||
let err = within(session.new_tab("nope", None, None))
|
||||
let created = within(session.new_tab(Some("me@build-box:22"), ws, None, None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
created.tab_id.starts_with("Some(\"me@build-box:22\")"),
|
||||
"{}",
|
||||
created.tab_id
|
||||
);
|
||||
|
||||
let err = within(session.new_tab(None, "nope", None, None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no workspace nope"), "{err}");
|
||||
@@ -312,6 +358,40 @@ async fn an_unpaired_phone_cannot_open_a_tab() {
|
||||
};
|
||||
let session = Session::connect(&rig.phone, &host).await.unwrap();
|
||||
let ws = FakeMachine::new().machine.workspaces[0].id.to_string();
|
||||
let err = within(session.new_tab(&ws, None, None)).await.unwrap_err();
|
||||
let err = within(session.new_tab(None, &ws, None, None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not paired"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn linked_machines_come_with_the_tree_and_their_panes_open() {
|
||||
let rig = Rig::new().await;
|
||||
let session = within(rig.paired()).await;
|
||||
let (_, mut tree) = within(session.control()).await.unwrap().split();
|
||||
let Some(ControlEvent::Tree(tree)) = within(tree.next()).await.unwrap() else {
|
||||
panic!("expected a tree first");
|
||||
};
|
||||
let [up, down] = &tree.remotes[..] else {
|
||||
panic!("two remotes: {:?}", tree.remotes);
|
||||
};
|
||||
assert_eq!((up.name.as_str(), up.connected), ("build-box", true));
|
||||
assert_eq!(up.workspaces[0].name, "pale-otter");
|
||||
assert_eq!((down.connected, down.workspaces.len()), (false, 0));
|
||||
|
||||
let (_, mut screen) = within(session.pane(Some(&up.key), 1)).await.unwrap();
|
||||
assert!(matches!(
|
||||
within(screen.next()).await.unwrap(),
|
||||
Some(PaneItem::Event(PaneEvent::Size { .. }))
|
||||
));
|
||||
assert_eq!(
|
||||
within(screen.next()).await.unwrap(),
|
||||
Some(PaneItem::Output(b"me@build-box:22$ ".to_vec()))
|
||||
);
|
||||
|
||||
let err = within(session.pane(Some(&down.key), 1))
|
||||
.await
|
||||
.err()
|
||||
.expect("down");
|
||||
assert!(err.to_string().contains("is down"), "{err}");
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async fn main() -> Result<()> {
|
||||
let pane: u64 = args.get(1).context("type <pane> <text>")?.parse()?;
|
||||
let text = args.get(2).context("type <pane> <text>")?;
|
||||
let session = Session::connect(&endpoint, &host()?).await?;
|
||||
let (mut keys, mut screen) = session.pane(pane).await?;
|
||||
let (mut keys, mut screen) = session.pane(None, pane).await?;
|
||||
// Drain the replay: it ends when the pane goes quiet.
|
||||
let mut replay = 0usize;
|
||||
while let Ok(Ok(Some(item))) =
|
||||
@@ -120,7 +120,7 @@ async fn main() -> Result<()> {
|
||||
let cwd = args.get(2).cloned();
|
||||
let session = Session::connect(&endpoint, &host()?).await?;
|
||||
let size = GridSize { cols: 56, rows: 40 };
|
||||
let created = session.new_tab(ws, cwd, Some(size)).await?;
|
||||
let created = session.new_tab(None, ws, cwd, Some(size)).await?;
|
||||
println!(
|
||||
"opened tab {} with pane {}",
|
||||
created.tab_id, created.pane_id
|
||||
|
||||
@@ -141,9 +141,18 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Opens one pane: its output (replay first) arrives on the reader, and
|
||||
/// keystrokes go in through the writer.
|
||||
pub async fn pane(&self, pane_id: u64) -> Result<(PaneWriter, PaneReader)> {
|
||||
let (send, recv) = open(&self.conn, &Open::Pane { pane_id }).await?;
|
||||
/// keystrokes go in through the writer. `machine` is the key of the
|
||||
/// remote it lives on, `None` for the gateway's own machine.
|
||||
pub async fn pane(
|
||||
&self,
|
||||
machine: Option<&str>,
|
||||
pane_id: u64,
|
||||
) -> Result<(PaneWriter, PaneReader)> {
|
||||
let ask = Open::Pane {
|
||||
pane_id,
|
||||
machine: machine.map(str::to_string),
|
||||
};
|
||||
let (send, recv) = open(&self.conn, &ask).await?;
|
||||
Ok((PaneWriter { send }, PaneReader { recv }))
|
||||
}
|
||||
}
|
||||
@@ -153,6 +162,7 @@ impl Session {
|
||||
/// pane to open with [`Session::pane`].
|
||||
pub async fn new_tab(
|
||||
&self,
|
||||
machine: Option<&str>,
|
||||
workspace_id: &str,
|
||||
cwd: Option<String>,
|
||||
size: Option<GridSize>,
|
||||
@@ -161,6 +171,7 @@ impl Session {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
cwd,
|
||||
size,
|
||||
machine: machine.map(str::to_string),
|
||||
};
|
||||
// A gateway from before new tabs cannot parse the ask and drops the
|
||||
// stream without a word; say what that means rather than that it hung
|
||||
|
||||
@@ -58,7 +58,13 @@ pub enum Open {
|
||||
/// desktop, and would take the pane away from the window showing it.
|
||||
/// Keystrokes go in beside the observer, so the phone can drive the pane
|
||||
/// without owning it.
|
||||
Pane { pane_id: u64 },
|
||||
Pane {
|
||||
pane_id: u64,
|
||||
/// The [`RemoteView::key`] of the remote machine the pane lives on;
|
||||
/// absent for the gateway's own machine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
machine: Option<String>,
|
||||
},
|
||||
/// Start a shell in a new tab at the end of a workspace. One-shot: the
|
||||
/// gateway creates the tab first, then answers `Ok` followed by a
|
||||
/// [`TabCreated`], or `Denied` with what went wrong.
|
||||
@@ -74,6 +80,9 @@ pub enum Open {
|
||||
/// window is showing the tab yet. One that later does resizes it.
|
||||
#[serde(default)]
|
||||
size: Option<GridSize>,
|
||||
/// As on [`Open::Pane`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
machine: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -146,6 +155,28 @@ pub enum PaneEvent {
|
||||
pub struct Tree {
|
||||
pub host: String,
|
||||
pub workspaces: Vec<WorkspaceView>,
|
||||
/// The machines the desktop is linked to over SSH, each with its own
|
||||
/// workspaces. The gateway reaches them through those links and never
|
||||
/// dials one itself.
|
||||
#[serde(default)]
|
||||
pub remotes: Vec<RemoteView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RemoteView {
|
||||
/// The link's key (`me@build-box:22`): what [`Open::Pane`] and
|
||||
/// [`Open::NewTab`] name the machine by.
|
||||
pub key: String,
|
||||
/// What to call it: the host, or the key when it has none.
|
||||
pub name: String,
|
||||
/// Whether the desktop's link to it is up. A down link lists no
|
||||
/// workspaces; the desktop has to reconnect it.
|
||||
pub connected: bool,
|
||||
/// Why its workspaces could not be read, when the link is up but the far
|
||||
/// end did not answer.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
pub workspaces: Vec<WorkspaceView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -374,7 +405,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn frames_survive_arbitrary_chunking() {
|
||||
let mut wire = encode_msg(&Open::Pane { pane_id: 7 });
|
||||
let mut wire = encode_msg(&Open::Pane {
|
||||
pane_id: 7,
|
||||
machine: None,
|
||||
});
|
||||
wire.extend(encode_bytes(b"\x1b[31mhi\r\n"));
|
||||
wire.extend(encode_msg(&PaneEvent::Size { cols: 80, rows: 24 }));
|
||||
|
||||
@@ -388,7 +422,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(frames.len(), 3, "chunk size {chunk}");
|
||||
assert_eq!(frames[0].msg::<Open>().unwrap(), Open::Pane { pane_id: 7 });
|
||||
assert_eq!(
|
||||
frames[0].msg::<Open>().unwrap(),
|
||||
Open::Pane {
|
||||
pane_id: 7,
|
||||
machine: None
|
||||
}
|
||||
);
|
||||
assert_eq!(frames[1], Frame::Bytes(b"\x1b[31mhi\r\n".to_vec()));
|
||||
assert_eq!(
|
||||
frames[2].msg::<PaneEvent>().unwrap(),
|
||||
@@ -459,9 +499,20 @@ mod tests {
|
||||
Open::NewTab {
|
||||
workspace_id: "w".into(),
|
||||
cwd: None,
|
||||
size: None
|
||||
size: None,
|
||||
machine: None
|
||||
}
|
||||
);
|
||||
// A local pane is asked for exactly as before remotes existed, so an
|
||||
// older gateway still understands it.
|
||||
assert_eq!(
|
||||
serde_json::to_value(Open::Pane {
|
||||
pane_id: 3,
|
||||
machine: None
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({"type": "pane", "pane_id": 3})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
|
||||
@@ -222,13 +222,14 @@ async fn refresh(state: State<'_, Arc<AppState>>, host_id: String) -> CmdResult<
|
||||
async fn tab_new(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
host_id: String,
|
||||
machine: Option<String>,
|
||||
workspace_id: String,
|
||||
cwd: Option<String>,
|
||||
size: Option<GridSize>,
|
||||
) -> CmdResult<TabCreated> {
|
||||
let session = state.session(&host_id).await?;
|
||||
session
|
||||
.new_tab(&workspace_id, cwd, size)
|
||||
.new_tab(machine.as_deref(), &workspace_id, cwd, size)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
@@ -240,11 +241,15 @@ async fn tab_new(
|
||||
async fn pane_open(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
host_id: String,
|
||||
machine: Option<String>,
|
||||
pane_id: u64,
|
||||
on_output: Channel<InvokeResponseBody>,
|
||||
) -> CmdResult<u32> {
|
||||
let session = state.session(&host_id).await?;
|
||||
let (mut keys, mut screen) = session.pane(pane_id).await.map_err(err)?;
|
||||
let (mut keys, mut screen) = session
|
||||
.pane(machine.as_deref(), pane_id)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let handle = state.next_pane.fetch_add(1, Ordering::Relaxed);
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
state.panes.lock().await.insert(handle, input_tx);
|
||||
|
||||
+15
-2
@@ -42,6 +42,16 @@ export interface WorkspaceView {
|
||||
export interface Tree {
|
||||
host: string;
|
||||
workspaces: WorkspaceView[];
|
||||
/** Machines the desktop is linked to over SSH. Absent from older gateways. */
|
||||
remotes?: RemoteView[];
|
||||
}
|
||||
|
||||
export interface RemoteView {
|
||||
key: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
error?: string | null;
|
||||
workspaces: WorkspaceView[];
|
||||
}
|
||||
|
||||
export interface LinkInfo {
|
||||
@@ -78,8 +88,10 @@ export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) {
|
||||
export const refresh = (hostId: string) => invoke<void>("refresh", { hostId });
|
||||
|
||||
/** Output arrives as ArrayBuffers of raw terminal bytes, events as objects. */
|
||||
/** `machine` is a remote's key, or null for the paired machine itself. */
|
||||
export function paneOpen(
|
||||
hostId: string,
|
||||
machine: string | null,
|
||||
paneId: number,
|
||||
onOutput: (bytes: Uint8Array) => void,
|
||||
onEvent: (event: PaneEvent) => void,
|
||||
@@ -89,7 +101,7 @@ export function paneOpen(
|
||||
if (msg instanceof ArrayBuffer) onOutput(new Uint8Array(msg));
|
||||
else onEvent(msg);
|
||||
};
|
||||
return invoke<number>("pane_open", { hostId, paneId, onOutput: channel });
|
||||
return invoke<number>("pane_open", { hostId, machine, paneId, onOutput: channel });
|
||||
}
|
||||
|
||||
export interface TabCreated {
|
||||
@@ -99,10 +111,11 @@ export interface TabCreated {
|
||||
|
||||
export const tabNew = (
|
||||
hostId: string,
|
||||
machine: string | null,
|
||||
workspaceId: string,
|
||||
cwd: string | null,
|
||||
size: { cols: number; rows: number } | null,
|
||||
) => invoke<TabCreated>("tab_new", { hostId, workspaceId, cwd, size });
|
||||
) => invoke<TabCreated>("tab_new", { hostId, machine, workspaceId, cwd, size });
|
||||
|
||||
export const paneInput = (handle: number, data: string) =>
|
||||
invoke<void>("pane_input", { handle, data });
|
||||
|
||||
@@ -11,6 +11,9 @@ export const icon = {
|
||||
more: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></svg>`,
|
||||
terminal: stroke(`<path d="M5.8 8.4 9.7 12l-3.9 3.6"/><path d="M12.9 15.6h5.6"/>`),
|
||||
machine: stroke(`<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>`),
|
||||
server: stroke(
|
||||
`<rect x="2" y="3" width="20" height="7.4" rx="2.4"/><rect x="2" y="13.6" width="20" height="7.4" rx="2.4"/><path d="M6.9 6.7h.01M6.9 17.3h.01"/>`,
|
||||
),
|
||||
keyboard: stroke(
|
||||
`<rect x="2.5" y="6" width="19" height="12" rx="2.5"/><path d="M6.5 10h.01M10 10h.01M14 10h.01M17.5 10h.01M8 14h8"/>`,
|
||||
),
|
||||
|
||||
+90
-35
@@ -453,38 +453,29 @@ const STATUS_WORD: Record<AgentStatus, string> = {
|
||||
idle: "Idle",
|
||||
};
|
||||
|
||||
/** Which machine a pane or workspace is on: a remote the desktop is linked
|
||||
* to, or null for the paired machine itself. */
|
||||
type Place = { key: string; name: string } | null;
|
||||
|
||||
function renderTree(host: Host, tree: Tree, failed: (message: string) => void): Node[] {
|
||||
const out: Node[] = [];
|
||||
const remotes = tree.remotes ?? [];
|
||||
const everywhere: [Place, WorkspaceView][] = [
|
||||
...tree.workspaces.map((ws): [Place, WorkspaceView] => [null, ws]),
|
||||
...remotes.flatMap((r) => r.workspaces.map((ws): [Place, WorkspaceView] => [r, ws])),
|
||||
];
|
||||
|
||||
const waiting: Node[] = [];
|
||||
for (const ws of tree.workspaces)
|
||||
for (const [place, ws] of everywhere)
|
||||
for (const tab of ws.tabs)
|
||||
for (const pane of tab.panes)
|
||||
if (pane.agent && (pane.agent.status === "waiting" || pane.agent.status === "done"))
|
||||
waiting.push(paneRow(host, tab, pane, workspaceName(ws.name)));
|
||||
|
||||
if (pane.agent && (pane.agent.status === "waiting" || pane.agent.status === "done")) {
|
||||
const where = place ? `${place.name} › ${workspaceName(ws.name)}` : workspaceName(ws.name);
|
||||
waiting.push(paneRow(host, place, tab, pane, where));
|
||||
}
|
||||
if (waiting.length) out.push(section("Needs you", ...waiting));
|
||||
|
||||
// A workspace is one card of its tabs, named as the desktop's sidebar names
|
||||
// them; a split tab gives each of its panes a row.
|
||||
for (const ws of tree.workspaces) {
|
||||
out.push(
|
||||
h(
|
||||
"section",
|
||||
{ class: "group" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "group-head" },
|
||||
h("h2", { class: "group-title" }, workspaceName(ws.name)),
|
||||
newTabButton(host, ws, failed),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
...ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, tab, pane, null))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
out.push(...tree.workspaces.map((ws) => workspaceGroup(host, null, ws, failed)));
|
||||
if (tree.workspaces.length === 0) {
|
||||
out.push(
|
||||
h(
|
||||
@@ -496,19 +487,79 @@ function renderTree(host: Host, tree: Tree, failed: (message: string) => void):
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Then every machine the desktop reaches over SSH, as the desktop's
|
||||
// sidebar lists them: its own heading, its workspaces under it.
|
||||
for (const remote of remotes) {
|
||||
const state = remote.connected ? (remote.error ? "Not answering" : "Connected") : "Link down";
|
||||
out.push(
|
||||
h(
|
||||
"section",
|
||||
{ class: "remote" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "remote-head" },
|
||||
h("span", { class: "tile" }, ico("server")),
|
||||
h(
|
||||
"div",
|
||||
{ class: "remote-titles" },
|
||||
h("h2", { class: "remote-name" }, remote.name),
|
||||
h(
|
||||
"p",
|
||||
{ class: `link ${remote.connected && !remote.error ? "direct" : "offline"}` },
|
||||
h("span", { class: "link-dot" }),
|
||||
`SSH · ${state}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
!remote.connected &&
|
||||
h(
|
||||
"p",
|
||||
{ class: "remote-note" },
|
||||
`tty7 on ${host.name} lost its link to ${remote.name}. Reconnect it there to reach its workspaces.`,
|
||||
),
|
||||
remote.error && h("p", { class: "remote-note" }, sentence(remote.error)),
|
||||
remote.connected &&
|
||||
!remote.error &&
|
||||
remote.workspaces.length === 0 &&
|
||||
h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`),
|
||||
...remote.workspaces.map((ws) => workspaceGroup(host, remote, ws, failed)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A workspace is one card of its tabs, named as the desktop's sidebar names
|
||||
* them; a split tab gives each of its panes a row. */
|
||||
function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) {
|
||||
return h(
|
||||
"section",
|
||||
{ class: "group" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "group-head" },
|
||||
h("h2", { class: "group-title" }, workspaceName(ws.name)),
|
||||
newTabButton(host, place, ws, failed),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
...ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane, null))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts a shell in a new tab at the end of a workspace, in the directory
|
||||
* its last tab is in, sized to this screen, and opens it. */
|
||||
function newTabButton(host: Host, ws: WorkspaceView, failed: (message: string) => void) {
|
||||
function newTabButton(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) {
|
||||
const button = h("button", { class: "head-action" }, ico("plus"), "New tab");
|
||||
button.onclick = async () => {
|
||||
button.disabled = true;
|
||||
const cwd = ws.tabs.at(-1)?.panes[0]?.cwd ?? null;
|
||||
try {
|
||||
const created = await api.tabNew(host.id, ws.id, cwd, phoneGrid());
|
||||
terminalScreen(host, { id: created.pane_id, title: "shell", cwd }, "New tab");
|
||||
const created = await api.tabNew(host.id, place?.key ?? null, ws.id, cwd, phoneGrid());
|
||||
terminalScreen(host, place, { id: created.pane_id, title: "shell", cwd }, "New tab");
|
||||
} catch (e) {
|
||||
failed(errorText(e));
|
||||
button.disabled = false;
|
||||
@@ -538,7 +589,7 @@ function workspaceName(name: string) {
|
||||
/** A pane's row: its tab's name first, as on the desktop, then what the pane
|
||||
* is doing — its agent's state, or where its shell is. `where` names the
|
||||
* workspace when the row sits outside it, in "Needs you". */
|
||||
function paneRow(host: Host, tab: TabView, pane: PaneView, where: string | null) {
|
||||
function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView, where: string | null) {
|
||||
const agent = pane.agent;
|
||||
const sub: Child[] = [];
|
||||
if (agent && agent.status !== "idle")
|
||||
@@ -547,14 +598,14 @@ function paneRow(host: Host, tab: TabView, pane: PaneView, where: string | null)
|
||||
// panes apart then is what runs in them.
|
||||
const namedByPath = /^[~/]/.test(tab.name);
|
||||
const split = tab.panes.length > 1 || namedByPath ? pane.title : null;
|
||||
const place = agent || namedByPath ? null : shortPath(pane.cwd);
|
||||
const detail = [where, split, agent?.message ?? place].filter(Boolean).join(" · ");
|
||||
const dir = agent || namedByPath ? null : shortPath(pane.cwd);
|
||||
const detail = [where, split, agent?.message ?? dir].filter(Boolean).join(" · ");
|
||||
if (detail) sub.push(sub.length ? ` · ${detail}` : detail);
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: tab.hibernated ? "row asleep" : "row",
|
||||
onclick: () => terminalScreen(host, pane, tab.name),
|
||||
onclick: () => terminalScreen(host, place, pane, tab.name),
|
||||
},
|
||||
avatar(agent),
|
||||
h(
|
||||
@@ -720,13 +771,16 @@ const CELL_EM = 0.602;
|
||||
/** The smallest font a pane is read at before it pans instead of shrinking. */
|
||||
const READABLE_PX = 11;
|
||||
|
||||
function terminalScreen(host: Host, pane: PaneView, title: string) {
|
||||
function terminalScreen(host: Host, place: Place, pane: PaneView, title: string) {
|
||||
go("push", () => {
|
||||
// What the pane is doing and whether keystrokes will land, in words: the
|
||||
// one line under the title.
|
||||
const stateWord = h("span", {}, "Connecting…");
|
||||
const state = h("span", { class: "term-state connecting" }, h("span", { class: "link-dot" }), stateWord);
|
||||
const cwd = h("span", { class: "term-cwd" }, shortPath(pane.cwd));
|
||||
// On a remote, the path says which machine it is on, the way a prompt does.
|
||||
const where = (path: string | null | undefined) =>
|
||||
place && path ? `${place.name}:${shortPath(path)}` : shortPath(path);
|
||||
const cwd = h("span", { class: "term-cwd" }, where(pane.cwd));
|
||||
const sub = h("span", { class: "term-sub" }, state, cwd);
|
||||
const zoom = h("button", { class: "nav-icon", ariaLabel: "Fit the whole width" }, ico("fit"));
|
||||
let head = avatar(pane.agent, "avatar small");
|
||||
@@ -866,6 +920,7 @@ function terminalScreen(host: Host, pane: PaneView, title: string) {
|
||||
try {
|
||||
handle = await api.paneOpen(
|
||||
host.id,
|
||||
place?.key ?? null,
|
||||
pane.id,
|
||||
(bytes) => term.write(bytes),
|
||||
(event) => {
|
||||
@@ -883,7 +938,7 @@ function terminalScreen(host: Host, pane: PaneView, title: string) {
|
||||
break;
|
||||
}
|
||||
case "cwd":
|
||||
cwd.textContent = shortPath(event.path);
|
||||
cwd.textContent = where(event.path);
|
||||
break;
|
||||
case "exited":
|
||||
handle = null;
|
||||
|
||||
@@ -338,6 +338,41 @@ code {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.remote-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 20px 16px;
|
||||
}
|
||||
|
||||
.remote-titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.014em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.remote-head .link {
|
||||
margin-top: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remote-note {
|
||||
margin: -4px 20px 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-2);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.row.asleep > .avatar {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user