diff --git a/Cargo.lock b/Cargo.lock index 1f8c2e92..768e0a15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11638,6 +11638,7 @@ dependencies = [ name = "tty7-gateway" version = "26.9.4" dependencies = [ + "alacritty_terminal", "anyhow", "clap", "getrandom 0.3.4", diff --git a/crates/tty7-gateway/Cargo.toml b/crates/tty7-gateway/Cargo.toml index b0383fd9..7b91bb25 100644 --- a/crates/tty7-gateway/Cargo.toml +++ b/crates/tty7-gateway/Cargo.toml @@ -35,6 +35,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" getrandom = "0.3" # iroh takes its proxy as a `Url`. url = "2" +# Reads a pane's output the way the desktop's window does, so a phone is sent +# the screen the desktop shows rather than bytes its own emulator re-wraps. +alacritty_terminal.workspace = true # Draws the pairing code as a QR code right in the terminal. qrcode = { version = "0.14", default-features = false } diff --git a/crates/tty7-gateway/src/daemon.rs b/crates/tty7-gateway/src/daemon.rs index 637c5d18..cc042239 100644 --- a/crates/tty7-gateway/src/daemon.rs +++ b/crates/tty7-gateway/src/daemon.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use tty7_core::client::{ControlClient, PaneClient, PaneInput, PaneOutput}; -use tty7_core::core::machine::Machine; +use tty7_core::core::machine::{Machine, TabId}; use tty7_core::daemon::control::{ ControlHello, ControlRequest, PaneAgentState, PaneSeed, ReplyOk, RouteInfo, WorkspaceId, }; @@ -257,21 +257,37 @@ impl Backend for Daemon { format!("no workspace {workspace_id}"), ) })?; + let for_phone = size.is_some(); let size = size.map_or(NEW_TAB_SIZE, |s| WinSize { cols: s.cols.clamp(20, 500), rows: s.rows.clamp(5, 300), ..NEW_TAB_SIZE }); let owner = workspace.to_string(); - let session = self.panes_on(machine)?.spawn( - cwd.as_deref().map(PathBuf::from), - size, - None, - Some(owner.clone()), - Some(owner), - )?; + // A tab asked for with nowhere in mind starts at home, as a new + // window's does — not in whatever directory the server started in. + // A remote's own server knows its home; this one only knows this. + let start = match (cwd.as_deref(), machine) { + (Some(dir), _) => Some(PathBuf::from(dir)), + (None, None) => std::env::home_dir(), + (None, Some(_)) => None, + }; + let session = + self.panes_on(machine)? + .spawn(start, size, None, Some(owner.clone()), Some(owner))?; let pane = session.pane_id(); session.detach()?; + // The tab is about to reach the desktop, which lays every tab out at + // its window's size; the shell's first screen — a banner, a prompt — + // would be drawn that wide and then squeezed onto the phone. Held + // at the phone's size from before the desktop hears of it, until + // the phone's own view of it takes over. + if for_phone { + match self.observe(machine, pane) { + Ok(feed) => hold_for_phone(feed, size), + Err(e) => log::debug!("mobile gateway: holding new pane {pane}: {e}"), + } + } let seed = PaneSeed { pane, cwd, @@ -293,6 +309,52 @@ impl Backend for Daemon { other => Err(unexpected("TabCreate", &other)), } } + + fn running_panes(&self) -> Option> { + let panes = PaneClient::local().list().ok()?; + Some( + panes + .into_iter() + .filter(|p| p.alive) + .map(|p| p.pane_id) + .collect(), + ) + } + + /// As the desktop closes a tab: onto the workspace's recently-closed + /// list, its panes stopped with their screens kept, so it can be put + /// back. A machine that keeps no such list closes it for good, and its + /// panes are ended from here, as `tty7 tab close` does. + fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()> { + let invalid = + |what: &str| io::Error::new(io::ErrorKind::InvalidInput, format!("no {what}")); + let workspace: WorkspaceId = workspace_id + .parse() + .map_err(|_| invalid(&format!("workspace {workspace_id}")))?; + // A tab id is a UUID on the wire; it has no `FromStr` of its own. + let tab: TabId = serde_json::from_value(serde_json::Value::String(tab_id.to_string())) + .map_err(|_| invalid(&format!("tab {tab_id}")))?; + let remembered = ControlRequest::TabCloseRemembered { + workspace, + tab, + panes: Vec::new(), + }; + let ended = match self.request_on(machine, remembered) { + Ok(_) => return Ok(()), + Err(e) if e.kind() == io::ErrorKind::Unsupported => { + match self.request_on(machine, ControlRequest::TabClose { workspace, tab })? { + ReplyOk::Panes(panes) => panes, + other => return Err(unexpected("TabClose", &other)), + } + } + Err(e) => return Err(e), + }; + let panes = self.panes_on(machine)?; + for pane in ended { + panes.kill(pane)?; + } + Ok(()) + } } struct Observed { @@ -322,6 +384,38 @@ impl PaneFeed for Observed { } } +/// How long a new tab is held at the phone's size for the phone to open it. +const PHONE_HOLD: Duration = Duration::from_secs(10); + +/// Takes the pane at `size` over `feed` and keeps it so on a thread of its +/// own, until the phone's stream takes the lease from it (the daemon tells +/// the one displaced), the pane ends, or [`PHONE_HOLD`] passes. +fn hold_for_phone(mut feed: Box, size: WinSize) { + let Some(mut leases) = feed.leases() else { + return; + }; + let take = LeaseRequest::Take { + size, + by: "a phone".to_string(), + }; + if leases.send(take).is_err() { + return; + } + std::thread::spawn(move || { + let until = std::time::Instant::now() + PHONE_HOLD; + let mut held = false; + while std::time::Instant::now() < until { + match feed.recv(Duration::from_millis(250)) { + Ok(Some(DaemonMsg::Lease(Some(_)))) => held = true, + Ok(Some(DaemonMsg::Lease(None))) if held => return, + Ok(Some(DaemonMsg::Exited { .. })) | Err(_) => return, + _ => {} + } + } + drop(leases); + }); +} + /// The observer connection's writing half, for leases. struct Leases { input: PaneInput, diff --git a/crates/tty7-gateway/src/lib.rs b/crates/tty7-gateway/src/lib.rs index 1d0e91ce..d4bbc6fd 100644 --- a/crates/tty7-gateway/src/lib.rs +++ b/crates/tty7-gateway/src/lib.rs @@ -7,6 +7,7 @@ //! never speaks the daemon's own protocols. pub mod daemon; +mod mirror; pub mod poller; pub mod route; pub mod serve; diff --git a/crates/tty7-gateway/src/mirror.rs b/crates/tty7-gateway/src/mirror.rs new file mode 100644 index 00000000..27d0ab3d --- /dev/null +++ b/crates/tty7-gateway/src/mirror.rs @@ -0,0 +1,356 @@ +//! A pane's screen as the desktop has it, drawn afresh for a phone. +//! +//! What the daemon hands an observer is the pane's raw output, ring segment by +//! ring segment, each at the size the pane was then. A desktop window plays +//! that back through alacritty and gets the screen it left. The phone plays it +//! through xterm.js, which wraps and reflows across those resizes in its own +//! way, so a shell's SIGWINCH redraws land on rows they were not meant for: a +//! prompt drawn twice, a program's output under the next prompt, half a logo +//! over yesterday's `ls`. The bytes are right; only the emulator that reads +//! them differs. +//! +//! So the gateway reads them with the desktop's own emulator, and the phone is +//! sent what that makes of them — the scrollback and the screen as plain +//! text and colours, the cursor, the modes that matter — at the moments its +//! own reading would part from the desktop's: on opening, after the pane +//! changes size, and when a full-screen program gives the main screen back. +//! Between those, output goes straight through, as before. + +use alacritty_terminal::event::VoidListener; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; +use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor}; +use std::fmt::Write as _; + +/// How much history a fresh drawing carries: what the phone keeps itself +/// (main.ts `scrollback`). +const HISTORY: usize = 5000; + +struct Size { + cols: usize, + rows: usize, +} + +impl Dimensions for Size { + fn total_lines(&self) -> usize { + self.rows + } + fn screen_lines(&self) -> usize { + self.rows + } + fn columns(&self) -> usize { + self.cols + } +} + +pub struct Mirror { + term: Term, + parser: Processor, +} + +impl Mirror { + pub fn new(cols: u16, rows: u16) -> Mirror { + let config = Config { + scrolling_history: HISTORY, + ..Config::default() + }; + Mirror { + term: Term::new(config, &size(cols, rows), VoidListener), + parser: Processor::new(), + } + } + + pub fn resize(&mut self, cols: u16, rows: u16) { + self.term.resize(size(cols, rows)); + } + + /// Reads output. True when it brought a full-screen program's exit: the + /// main screen is back, and the phone's copy of it is the one that went + /// stale while the program ran. + pub fn feed(&mut self, bytes: &[u8]) -> bool { + let was_alt = self.alt(); + self.parser.advance(&mut self.term, bytes); + was_alt && !self.alt() + } + + fn alt(&self) -> bool { + self.term.mode().contains(TermMode::ALT_SCREEN) + } + + /// The whole picture, for a terminal of the same width to read from a + /// reset: history, screen, cursor and modes. The screen is written last + /// and the cursor placed relative to its end, so a terminal taller than + /// the pane shows it at the bottom with history above. + pub fn draw(&self) -> Vec { + let mut out = String::new(); + // Everything back to the start, scrollback too. + out.push_str("\x1bc\x1b[3J"); + let alt = self.alt(); + if alt { + out.push_str("\x1b[?1049h"); + } + let grid = self.term.grid(); + let cols = grid.columns(); + let rows = grid.screen_lines() as i32; + // The alternate screen keeps no history. + let history = if alt { 0 } else { grid.history_size() as i32 }; + let mut pen = Pen::default(); + for line in -history..rows { + let row = &grid[Line(line)]; + let wrapped = row[Column(cols - 1)].flags.contains(Flags::WRAPLINE); + // Blank cells at the end of a line are left unwritten unless they + // carry a colour; a wrapped line is written whole, so the reader + // wraps it too and knows the two rows are one line. + let end = if wrapped { + cols + } else { + (0..cols) + .rposition(|c| !blank(&row[Column(c)])) + .map_or(0, |c| c + 1) + }; + for c in 0..end { + let cell = &row[Column(c)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + pen.to(cell, &mut out); + out.push(cell.c); + if let Some(marks) = cell.zerowidth() { + out.extend(marks); + } + } + if line < rows - 1 && !wrapped { + pen.reset(&mut out); + out.push_str("\r\n"); + } + } + pen.reset(&mut out); + + let cursor = grid.cursor.point; + let up = rows - 1 - cursor.line.0; + if up > 0 { + let _ = write!(out, "\x1b[{up}A"); + } + let _ = write!(out, "\x1b[{}G", cursor.column.0 + 1); + + let mode = self.term.mode(); + for (flag, set) in [ + (TermMode::APP_CURSOR, "?1"), + (TermMode::BRACKETED_PASTE, "?2004"), + (TermMode::FOCUS_IN_OUT, "?1004"), + (TermMode::MOUSE_REPORT_CLICK, "?1000"), + (TermMode::MOUSE_DRAG, "?1002"), + (TermMode::MOUSE_MOTION, "?1003"), + (TermMode::SGR_MOUSE, "?1006"), + ] { + if mode.contains(flag) { + let _ = write!(out, "\x1b[{set}h"); + } + } + if mode.contains(TermMode::APP_KEYPAD) { + out.push_str("\x1b="); + } + if !mode.contains(TermMode::SHOW_CURSOR) { + out.push_str("\x1b[?25l"); + } + out.into_bytes() + } +} + +fn size(cols: u16, rows: u16) -> Size { + // A zero-sized grid panics inside alacritty's index arithmetic. + Size { + cols: usize::from(cols).max(2), + rows: usize::from(rows).max(1), + } +} + +fn blank(cell: &Cell) -> bool { + cell.c == ' ' + && cell.bg == Color::Named(NamedColor::Background) + && !cell + .flags + .intersects(Flags::INVERSE | Flags::ALL_UNDERLINES | Flags::STRIKEOUT) + && cell.zerowidth().is_none() +} + +/// The SGR state written so far, so a run of like cells costs one sequence. +#[derive(Default, PartialEq)] +struct Pen { + set: Option<(Flags, Color, Color)>, +} + +impl Pen { + fn to(&mut self, cell: &Cell, out: &mut String) { + let flags = cell.flags + & (Flags::BOLD + | Flags::DIM + | Flags::ITALIC + | Flags::ALL_UNDERLINES + | Flags::INVERSE + | Flags::HIDDEN + | Flags::STRIKEOUT); + let want = (flags, cell.fg, cell.bg); + if self.set == Some(want) { + return; + } + out.push_str("\x1b[0"); + for (flag, code) in [ + (Flags::BOLD, "1"), + (Flags::DIM, "2"), + (Flags::ITALIC, "3"), + (Flags::INVERSE, "7"), + (Flags::HIDDEN, "8"), + (Flags::STRIKEOUT, "9"), + ] { + if flags.contains(flag) { + out.push(';'); + out.push_str(code); + } + } + if flags.contains(Flags::DOUBLE_UNDERLINE) { + out.push_str(";21"); + } else if flags.intersects(Flags::ALL_UNDERLINES) { + out.push_str(";4"); + } + colour(cell.fg, false, out); + colour(cell.bg, true, out); + out.push('m'); + self.set = Some(want); + } + + fn reset(&mut self, out: &mut String) { + if self.set.is_some() { + out.push_str("\x1b[0m"); + self.set = None; + } + } +} + +fn colour(color: Color, bg: bool, out: &mut String) { + let base = if bg { 40 } else { 30 }; + match color { + Color::Spec(rgb) => { + let _ = write!(out, ";{};2;{};{};{}", base + 8, rgb.r, rgb.g, rgb.b); + } + Color::Indexed(i) => { + let _ = write!(out, ";{};5;{i}", base + 8); + } + Color::Named(named) => { + let index = match named { + NamedColor::Foreground | NamedColor::Background => return, + NamedColor::DimBlack => 0, + NamedColor::DimRed => 1, + NamedColor::DimGreen => 2, + NamedColor::DimYellow => 3, + NamedColor::DimBlue => 4, + NamedColor::DimMagenta => 5, + NamedColor::DimCyan => 6, + NamedColor::DimWhite => 7, + NamedColor::BrightForeground | NamedColor::DimForeground | NamedColor::Cursor => { + return; + } + other => other as usize, + }; + let code = if index < 8 { + base + index + } else { + base + 60 + index - 8 + }; + let _ = write!(out, ";{code}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What a terminal shows after reading `bytes`, as text, through the same + /// emulator: the drawing has to read back as the screen it was made from. + fn screen(mirror: &Mirror) -> Vec { + let grid = mirror.term.grid(); + let history = grid.history_size() as i32; + (-history..grid.screen_lines() as i32) + .map(|l| { + let row = &grid[Line(l)]; + (0..grid.columns()) + .map(|c| row[Column(c)].c) + .collect::() + .trim_end() + .to_string() + }) + .collect() + } + + fn redrawn(mirror: &Mirror) -> Mirror { + let grid = mirror.term.grid(); + let mut copy = Mirror::new(grid.columns() as u16, grid.screen_lines() as u16); + copy.feed(&mirror.draw()); + copy + } + + #[test] + fn a_drawing_reads_back_as_the_screen_it_was_made_from() { + let mut m = Mirror::new(20, 4); + m.feed(b"one\r\ntwo\r\n\x1b[31mred\x1b[0m three\r\nfour\r\nfive $ "); + let copy = redrawn(&m); + assert_eq!(screen(©), screen(&m)); + assert_eq!(copy.term.grid().cursor.point, m.term.grid().cursor.point); + // Colour survives. + let red = |m: &Mirror| { + let grid = m.term.grid(); + let history = grid.history_size() as i32; + (-history..grid.screen_lines() as i32) + .flat_map(|l| (0..grid.columns()).map(move |c| (l, c))) + .find(|&(l, c)| { + grid[Line(l)][Column(c)].c == 'r' && grid[Line(l)][Column(c + 1)].c == 'e' + }) + .map(|(l, c)| grid[Line(l)][Column(c)].fg) + }; + assert_eq!(red(©), Some(Color::Named(NamedColor::Red))); + } + + #[test] + fn a_line_longer_than_the_pane_stays_one_line() { + let mut m = Mirror::new(10, 3); + m.feed(b"abcdefghijklmno\r\n$ "); + let copy = redrawn(&m); + assert_eq!(screen(©), screen(&m)); + let grid = copy.term.grid(); + let first = grid.history_size() as i32; + let wrapped = (-first..grid.screen_lines() as i32) + .any(|l| grid[Line(l)][Column(9)].flags.contains(Flags::WRAPLINE)); + assert!(wrapped, "the long line is still marked as wrapping"); + } + + #[test] + fn a_full_screen_program_is_drawn_on_the_alternate_screen() { + let mut m = Mirror::new(20, 4); + m.feed(b"$ less\r\n\x1b[?1049h\x1b[2J\x1b[1;1Hpage one\x1b[4;1H:"); + let copy = redrawn(&m); + assert!(copy.alt()); + assert_eq!(screen(©), screen(&m)); + assert_eq!(copy.term.grid().cursor.point, m.term.grid().cursor.point); + } + + #[test] + fn leaving_a_full_screen_program_is_told() { + let mut m = Mirror::new(20, 4); + assert!(!m.feed(b"\x1b[?1049hhi")); + assert!(m.feed(b"\x1b[?1049l$ ")); + } + + #[test] + fn wide_characters_take_their_two_cells_once() { + let mut m = Mirror::new(10, 2); + m.feed("你好 ok".as_bytes()); + let copy = redrawn(&m); + assert_eq!(screen(©), screen(&m)); + } +} diff --git a/crates/tty7-gateway/src/serve.rs b/crates/tty7-gateway/src/serve.rs index 05a27327..9933a977 100644 --- a/crates/tty7-gateway/src/serve.rs +++ b/crates/tty7-gateway/src/serve.rs @@ -7,10 +7,13 @@ //! channels are bounded: a phone that stops reading stalls its own thread, and //! through it only its own daemon connection, never the gateway. +use std::collections::HashSet; use std::io; use std::sync::Arc; use std::sync::mpsc as std_mpsc; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use crate::mirror::Mirror; use iroh::Endpoint; use iroh::endpoint::{Connection, RecvStream, SendStream}; @@ -63,6 +66,13 @@ pub trait Backend: Send + Sync + 'static { cwd: Option, size: Option, ) -> io::Result; + /// Closes a tab and ends its panes, keeping it to reopen where the + /// machine can. + fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()>; + /// The panes this machine's server has running, when it can say. + fn running_panes(&self) -> Option> { + None + } } /// One linked machine, as [`Backend::remotes`] reports it. @@ -203,6 +213,26 @@ async fn serve_stream( finish(send).await; Ok(()) } + Open::CloseTab { + workspace_id, + tab_id, + machine, + } => { + let closed = { + let backend = backend.clone(); + tokio::task::spawn_blocking(move || { + backend.close_tab(machine.as_deref(), &workspace_id, &tab_id) + }) + .await + .map_err(io::Error::other)? + }; + match closed { + Ok(()) => write_msg(&mut send, &ok).await?, + Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?, + } + finish(send).await; + Ok(()) + } Open::Diff { cwd, machine } => { let read = if machine.is_some() { Err(io::Error::other( @@ -313,10 +343,13 @@ fn read_diff(cwd: &str) -> io::Result { let truncated = out.stdout.len() > MAX_DIFF; let mut patch = out.stdout; patch.truncate(MAX_DIFF); + // A new directory is one entry, as `git status` shows it, not every + // file under it: a generated folder would bury the rest. let untracked = git(&[ "ls-files", "--others", "--exclude-standard", + "--directory", "--full-name", ":/", ])?; @@ -461,7 +494,18 @@ fn watch_tree( let event = match backend.snapshot() { Ok((machine, agents)) => { failing = false; - let mut tree = tree::build(&host, &machine, &agents); + // This machine's directories are this process's to read. + let running = backend.running_panes(); + let mut tree = tree::build_with( + &host, + &machine, + &agents, + |cwd| { + tty7_core::core::git::head::read_head(std::path::Path::new(cwd)) + .map(|h| h.home) + }, + running.as_ref(), + ); tree.remotes = backend.remotes().into_iter().map(remote_view).collect(); if forced || last.as_ref() != Some(&tree) { last = Some(tree.clone()); @@ -647,18 +691,34 @@ async fn pane_stream( } } +/// How long a fresh drawing of the screen waits for the output that made it +/// stale to stop, and the longest it waits while output keeps coming. +const DRAW_SETTLE: Duration = Duration::from_millis(30); +const DRAW_LIMIT: Duration = Duration::from_millis(200); + fn read_pane(mut feed: Box, down: mpsc::Sender) { // The daemon reports the agent and its status in separate messages; the // phone gets both together. let mut agent: Option = None; let mut status: Option = None; + // The pane as the desktop reads it (`mirror`), and since when the phone's + // copy has been out of date: from the start, while the replay comes in, + // then after a resize or a full-screen program's exit. Output that + // arrives meanwhile goes into the drawing rather than past it. + let mut mirror: Option = None; + let mut size = (80, 24); + let mut stale: Option = Some(Instant::now()); loop { if down.is_closed() { return; } - let msg = match feed.recv(FEED_TICK) { - Ok(Some(msg)) => msg, - Ok(None) => continue, + let wait = if stale.is_some() { + DRAW_SETTLE + } else { + FEED_TICK + }; + let msg = match feed.recv(wait) { + Ok(msg) => msg, Err(e) => { let _ = down.blocking_send(Down::Event(PaneEvent::Error { message: e.to_string(), @@ -666,12 +726,48 @@ fn read_pane(mut feed: Box, down: mpsc::Sender) { return; } }; + // Drawn once the burst is over, or anything other than output is + // about to be said, or it has waited long enough. + let burst = matches!( + msg, + Some(DaemonMsg::Size(_) | DaemonMsg::Snapshot(_) | DaemonMsg::Output(_)) + ); + if stale.is_some_and(|since| !burst || since.elapsed() >= DRAW_LIMIT) { + stale = None; + if let Some(mirror) = &mirror { + let (cols, rows) = size; + let drawn = [ + Down::Event(PaneEvent::Size { cols, rows }), + Down::Bytes(mirror.draw()), + ]; + for item in drawn { + if down.blocking_send(item).is_err() { + return; + } + } + } + } + let Some(msg) = msg else { continue }; let item = match msg { - DaemonMsg::Size(size) => Down::Event(PaneEvent::Size { - cols: size.cols, - rows: size.rows, - }), - DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => Down::Bytes(bytes), + DaemonMsg::Size(new) => { + size = (new.cols, new.rows); + match &mut mirror { + Some(mirror) => mirror.resize(new.cols, new.rows), + None => mirror = Some(Mirror::new(new.cols, new.rows)), + } + stale.get_or_insert_with(Instant::now); + continue; + } + DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => { + let mirror = mirror.get_or_insert_with(|| Mirror::new(size.0, size.1)); + if mirror.feed(&bytes) { + stale.get_or_insert_with(Instant::now); + } + if stale.is_some() { + continue; + } + Down::Bytes(bytes) + } DaemonMsg::Cwd(path) => Down::Event(PaneEvent::Cwd { path: path.to_string_lossy().into_owned(), }), diff --git a/crates/tty7-gateway/src/tree.rs b/crates/tty7-gateway/src/tree.rs index 9fe4bf87..931c7d39 100644 --- a/crates/tty7-gateway/src/tree.rs +++ b/crates/tty7-gateway/src/tree.rs @@ -7,6 +7,9 @@ //! goes through `tab_view` so a tab reads the same here as in the CLI and the //! desktop's switcher. +use std::collections::HashSet; +use std::path::PathBuf; + use tty7_core::core::cli_agent::{AgentSessionState, AgentStatus as CoreStatus, CLIAgent}; use tty7_core::core::group_key::{AutoKey, GroupKey, auto_names, pinned_names, place}; use tty7_core::core::machine::{Machine, PaneRecord, Workspace}; @@ -17,6 +20,20 @@ use tty7_mobile_proto::{ }; pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { + build_with(host, machine, agents, |_| None, None) +} + +/// [`build`], placing a tab the desktop has not filed yet by the repository +/// `repo_of` finds its directory in. The desktop files a tab as its sidebar +/// draws it, so one opened from the phone while that window is out of sight +/// would sit in Ungrouped until someone looked. +pub fn build_with( + host: &str, + machine: &Machine, + agents: &[PaneAgentState], + repo_of: impl Fn(&str) -> Option, + running: Option<&HashSet>, +) -> Tree { let mut workspaces: Vec<_> = machine.workspaces.iter().collect(); // Most recently used first: on a phone the one you were just in is the // one you are coming back to. @@ -46,11 +63,18 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { .root .pane_ids() .into_iter() - .map(|id| pane_view(id, &machine.panes, agents)) + .map(|id| { + let mut view = pane_view(id, &machine.panes, agents); + // An asleep tab's panes are stopped on purpose, + // and say so as the tab. + view.stopped = !tab.hibernated + && running.is_some_and(|running| !running.contains(&id)); + view + }) .collect(), }) .collect(), - groups: groups_of(ws), + groups: groups_of(ws, &machine.panes, &repo_of), active_tab: ws.active_tab.map(|t| t.to_string()), } }) @@ -68,11 +92,22 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { /// writes the answer down so other readers need not. Auto grouping is taken /// to be on, as it is by default; a desktop that turned it off still keeps /// these hints, so its phone shows the repo groups its sidebar does not. -fn groups_of(ws: &Workspace) -> Vec { +fn groups_of( + ws: &Workspace, + panes: &[PaneRecord], + repo_of: &dyn Fn(&str) -> Option, +) -> Vec { let keys: Vec> = ws .tabs .iter() - .map(|tab| place(tab.group, &ws.groups, true, tab.last_auto.clone())) + .map(|tab| { + let auto = tab.last_auto.clone().or_else(|| { + let first = *tab.root.pane_ids().first()?; + let cwd = panes.iter().find(|p| p.id == first)?.cwd.as_deref()?; + repo_of(cwd).map(AutoKey::Repo) + }); + place(tab.group, &ws.groups, true, auto) + }) .collect(); let members = |key: Option<&GroupKey>| -> Vec { ws.tabs @@ -167,6 +202,7 @@ fn pane_view(id: u64, panes: &[PaneRecord], agents: &[PaneAgentState]) -> PaneVi title, cwd: record.and_then(|p| p.cwd.clone()), agent, + stopped: false, } } @@ -337,6 +373,53 @@ mod tests { assert_eq!(view.active_tab.as_deref(), Some(ids[3].as_str())); } + #[test] + fn a_tab_the_desktop_has_not_filed_goes_by_its_directorys_repo() { + let mut ws = workspace("w", 1, vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)]); + ws.tabs[0].last_auto = Some(AutoKey::Repo("/w/k6".into())); + let ids: Vec = ws.tabs.iter().map(|t| t.id.to_string()).collect(); + let machine = Machine { + workspaces: vec![ws], + panes: vec![ + record(1, "zsh", None, "/w/k6"), + record(2, "zsh", None, "/w/k6/src"), + record(3, "zsh", None, "/tmp"), + ], + }; + let repo_of = |cwd: &str| cwd.starts_with("/w/k6").then(|| PathBuf::from("/w/k6")); + let view = &build_with("h", &machine, &[], repo_of, None).workspaces[0]; + let groups: Vec<_> = view + .groups + .iter() + .map(|g| (g.name.as_deref(), g.tabs.clone())) + .collect(); + assert_eq!( + groups, + [ + (Some("k6"), vec![ids[0].clone(), ids[1].clone()]), + (Some("Ungrouped"), vec![ids[2].clone()]), + ] + ); + } + + #[test] + fn a_pane_the_server_is_not_running_is_marked_stopped() { + let mut asleep = Tab::leaf(3); + asleep.hibernated = true; + let machine = Machine { + workspaces: vec![workspace("w", 1, vec![Tab::leaf(1), Tab::leaf(2), asleep])], + panes: Vec::new(), + }; + let running = HashSet::from([1]); + let view = &build_with("h", &machine, &[], |_| None, Some(&running)).workspaces[0]; + let stopped: Vec = view.tabs.iter().map(|t| t.panes[0].stopped).collect(); + // The asleep tab says so as itself, not as a pane that stopped. + assert_eq!(stopped, [false, true, false]); + // A server that could not be asked marks nothing. + let view = &build("h", &machine, &[]).workspaces[0]; + assert!(view.tabs.iter().all(|t| !t.panes[0].stopped)); + } + #[test] fn a_workspace_without_groups_is_one_headless_list() { let machine = Machine { diff --git a/crates/tty7-gateway/tests/bridge.rs b/crates/tty7-gateway/tests/bridge.rs index cdafd001..75aaf56d 100644 --- a/crates/tty7-gateway/tests/bridge.rs +++ b/crates/tty7-gateway/tests/bridge.rs @@ -152,6 +152,19 @@ impl Backend for FakeMachine { pane_id: 2, }) } + + fn close_tab( + &self, + _machine: Option<&str>, + workspace_id: &str, + tab_id: &str, + ) -> io::Result<()> { + let ws = &self.machine.workspaces[0]; + if workspace_id != ws.id.to_string() || ws.tabs.iter().all(|t| t.id.to_string() != tab_id) { + return Err(io::Error::other(format!("no tab {tab_id}"))); + } + Ok(()) + } } struct FakeFeed { @@ -207,6 +220,15 @@ impl PaneFeed for FakeFeed { } } +/// A fresh drawing of a pane's screen (`mirror::Mirror::draw`), as text. +fn drawing(item: Option) -> String { + let Some(PaneItem::Output(bytes)) = item else { + panic!("expected the screen drawn, got {item:?}"); + }; + assert!(bytes.starts_with(b"\x1bc"), "a drawing starts from a reset"); + String::from_utf8_lossy(&bytes).into_owned() +} + struct Rig { _dir: tempfile::TempDir, machine: Arc, @@ -322,10 +344,8 @@ async fn a_paired_phone_reads_the_tree_and_drives_a_pane() { rows: 30 })) ); - assert_eq!( - within(screen.next()).await.unwrap(), - Some(PaneItem::Output(b"$ ".to_vec())) - ); + // The screen comes drawn afresh, as the desktop's emulator reads it. + assert!(drawing(within(screen.next()).await.unwrap()).contains("$")); keys.input(b"ls\r").await.unwrap(); assert_eq!( within(screen.next()).await.unwrap(), @@ -351,7 +371,7 @@ async fn typing_that_fails_is_reported_not_dropped() { let session = within(rig.paired()).await; let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap(); within(screen.next()).await.unwrap(); // size - within(screen.next()).await.unwrap(); // prompt + within(screen.next()).await.unwrap(); // the screen, drawn keys.input(b"refuse").await.unwrap(); let Some(PaneItem::Event(PaneEvent::Error { message })) = within(screen.next()).await.unwrap() @@ -377,7 +397,7 @@ async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() { let session = within(rig.paired()).await; let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap(); within(screen.next()).await.unwrap(); // size - within(screen.next()).await.unwrap(); // prompt + within(screen.next()).await.unwrap(); // the screen, drawn let size = GridSize { cols: 44, rows: 31 }; keys.request(&PaneRequest::TakeOver { size }).await.unwrap(); @@ -385,6 +405,8 @@ async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() { within(screen.next()).await.unwrap(), Some(PaneItem::Event(PaneEvent::Size { cols: 44, rows: 31 })) ); + // Redrawn at the new size rather than left to the phone to reflow. + assert!(drawing(within(screen.next()).await.unwrap()).contains("$")); assert_eq!( within(screen.next()).await.unwrap(), Some(PaneItem::Event(PaneEvent::Lease { @@ -414,7 +436,7 @@ async fn a_take_over_the_pane_cannot_do_is_refused_and_the_pane_stays_up() { let session = within(rig.paired()).await; let (mut keys, mut screen) = within(session.pane(Some("old"), 1)).await.unwrap(); within(screen.next()).await.unwrap(); // size - within(screen.next()).await.unwrap(); // prompt + within(screen.next()).await.unwrap(); // the screen, drawn let size = GridSize { cols: 44, rows: 31 }; keys.request(&PaneRequest::TakeOver { size }).await.unwrap(); @@ -451,7 +473,7 @@ async fn a_revoked_phone_is_cut_off_at_its_next_stream() { async fn a_paired_phone_opens_a_tab() { let rig = Rig::new().await; let session = within(rig.paired()).await; - let (_, mut tree) = within(session.control()).await.unwrap().split(); + let (_asks, mut tree) = within(session.control()).await.unwrap().split(); let Some(ControlEvent::Tree(first)) = within(tree.next()).await.unwrap() else { panic!("expected a tree first"); }; @@ -487,6 +509,24 @@ async fn a_paired_phone_opens_a_tab() { assert!(err.to_string().contains("no workspace nope"), "{err}"); } +#[tokio::test(flavor = "multi_thread")] +async fn a_paired_phone_closes_a_tab() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + let (_asks, mut tree) = within(session.control()).await.unwrap().split(); + let Some(ControlEvent::Tree(first)) = within(tree.next()).await.unwrap() else { + panic!("expected a tree first"); + }; + let ws = &first.workspaces[0]; + within(session.close_tab(None, &ws.id, &ws.tabs[0].id)) + .await + .unwrap(); + let err = within(session.close_tab(None, &ws.id, "nope")) + .await + .unwrap_err(); + assert!(err.to_string().contains("no tab nope"), "{err}"); +} + #[tokio::test(flavor = "multi_thread")] async fn a_paired_phone_sends_a_file_and_learns_where_it_went() { let rig = Rig::new().await; @@ -543,12 +583,19 @@ async fn a_paired_phone_reads_a_working_trees_changes() { run(&["commit", "-qm", "first"]); std::fs::write(repo.join("src/a.txt"), "one\ntwo\n").unwrap(); std::fs::write(repo.join("new.txt"), "hi\n").unwrap(); + std::fs::create_dir_all(repo.join("made/deep")).unwrap(); + std::fs::write(repo.join("made/deep/x.txt"), "x\n").unwrap(); + std::fs::write(repo.join("made/y.txt"), "y\n").unwrap(); // Asked from a directory inside the repository, as a pane's cwd often is. let cwd = repo.join("src").to_string_lossy().into_owned(); let diff = within(session.diff(None, &cwd)).await.unwrap(); assert!(diff.patch.contains("+two"), "{}", diff.patch); - assert_eq!(diff.untracked, vec!["new.txt".to_string()]); + // A new directory comes as itself, not as each file in it. + assert_eq!( + diff.untracked, + vec!["made/".to_string(), "new.txt".to_string()] + ); assert!(!diff.truncated); let err = within(session.diff(None, &std::env::temp_dir().to_string_lossy())) @@ -584,7 +631,7 @@ async fn an_unpaired_phone_cannot_open_a_tab() { 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 (_asks, mut tree) = within(session.control()).await.unwrap().split(); let Some(ControlEvent::Tree(tree)) = within(tree.next()).await.unwrap() else { panic!("expected a tree first"); }; @@ -597,15 +644,13 @@ async fn linked_machines_come_with_the_tree_and_their_panes_open() { 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 (_keys, mut screen) = within(session.pane(Some(&up.key), 1)).await.unwrap(); + let first = within(screen.next()).await.unwrap(); + assert!( + matches!(first, Some(PaneItem::Event(PaneEvent::Size { .. }))), + "{first:?}" ); + assert!(drawing(within(screen.next()).await.unwrap()).contains("me@build-box:22$")); let err = within(session.pane(Some(&down.key), 1)) .await diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index 203fe376..16c2da3e 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -9,7 +9,7 @@ use std::str::FromStr as _; use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; -use iroh::endpoint::{Connection, RecvStream, SendStream, presets}; +use iroh::endpoint::{Connection, QuicTransportConfig, RecvStream, SendStream, presets}; use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey}; use iroh_mdns_address_lookup::MdnsAddressLookup; use serde::{Deserialize, Serialize}; @@ -66,6 +66,7 @@ pub async fn bind(secret: SecretKey) -> Result { .advertise(false); match Endpoint::builder(presets::N0) .secret_key(secret.clone()) + .transport_config(transport()) .address_lookup(mdns) .bind() .await @@ -73,12 +74,25 @@ pub async fn bind(secret: SecretKey) -> Result { Ok(endpoint) => Ok(endpoint), Err(_) => Endpoint::builder(presets::N0) .secret_key(secret) + .transport_config(transport()) .bind() .await .context("could not start the connection"), } } +/// How long a machine may go unheard before its connection counts as gone. +/// Keep-alives go every few seconds, so this is a few of them missed: a +/// laptop that went to sleep shows as unreachable within seconds, not after +/// half a minute of the app saying the link is fine. +const IDLE_TIMEOUT: Duration = Duration::from_secs(12); + +fn transport() -> QuicTransportConfig { + QuicTransportConfig::builder() + .max_idle_timeout(IDLE_TIMEOUT.try_into().ok()) + .build() +} + /// Trades a pairing code for a [`Host`] the app can store and dial later. pub async fn pair(endpoint: &Endpoint, code: &str, device_name: &str) -> Result { let code = PairCode::decode(code).map_err(|e| anyhow!(e))?; @@ -210,6 +224,31 @@ impl Session { } } +impl Session { + /// Closes a tab on `machine`, its panes with it. + pub async fn close_tab( + &self, + machine: Option<&str>, + workspace_id: &str, + tab_id: &str, + ) -> Result<()> { + let ask = Open::CloseTab { + workspace_id: workspace_id.to_string(), + tab_id: tab_id.to_string(), + machine: machine.map(str::to_string), + }; + let (mut send, _) = open(&self.conn, &ask).await.map_err(|e| { + if e.to_string().contains("without answering") { + anyhow!("tty7 on this computer is too old to close tabs — update it") + } else { + e + } + })?; + let _ = send.finish(); + Ok(()) + } +} + impl Session { /// Sends a file to the machine for a pane to be handed, returning where /// it landed. `machine` is where that pane runs, as on [`Session::pane`]. diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index a44ee066..aea32f50 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -96,13 +96,19 @@ pub enum Open { #[serde(default, skip_serializing_if = "Option::is_none")] machine: Option, }, - /// Put a file from the phone on the machine, for a pane to be handed its - /// path. After `Ok` the phone sends the file as bytes frames and finishes - /// its side; the gateway answers [`Uploaded`] once the file is written. - /// `Denied` before any bytes: too big, or a machine files cannot go to. + /// Close a tab, its panes with it. One-shot: `Ok` once it is closed, or + /// `Denied` with why not. Where the machine keeps closed tabs, it goes on + /// the workspace's recently-closed list, for the desktop to reopen. /// /// A gateway older than this variant cannot parse it and drops the stream /// unanswered. + CloseTab { + workspace_id: String, + tab_id: String, + /// As on [`Open::Pane`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, + }, /// What has changed in the git working tree `cwd` is in, against its last /// commit: `Ok`, then a [`Diff`]. `Denied` when it is not in a repository. /// @@ -114,6 +120,13 @@ pub enum Open { #[serde(default, skip_serializing_if = "Option::is_none")] machine: Option, }, + /// Put a file from the phone on the machine, for a pane to be handed its + /// path. After `Ok` the phone sends the file as bytes frames and finishes + /// its side; the gateway answers [`Uploaded`] once the file is written. + /// `Denied` before any bytes: too big, or a machine files cannot go to. + /// + /// A gateway older than this variant cannot parse it and drops the stream + /// unanswered. Upload { /// The file's name on the phone; the gateway keeps what it safely can. name: String, @@ -320,6 +333,11 @@ pub struct PaneView { pub cwd: Option, #[serde(default)] pub agent: Option, + /// Nothing is running in it: the machine's server restarted, and no + /// window has opened the tab since to start it again. Absent from an + /// older gateway, which cannot tell. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stopped: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 4291c5ec..202a9e11 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -11,6 +11,7 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", "@tauri-apps/plugin-biometric": "^2.4.0", + "@tauri-apps/plugin-haptics": "^2.4.1", "@tauri-apps/plugin-opener": "^2.6.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.19.0", @@ -539,6 +540,15 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-haptics": { + "version": "2.4.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-haptics/-/plugin-haptics-2.4.1.tgz", + "integrity": "sha512-y1t8CyXX4KWeJePamKwC6p/fpk5CV9DbvfcKiAgOHZWj8Gq4iILSxOp/KH4rleWz3A51oiDXbl+HdOugp5doww==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.6.0", "resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-opener/-/plugin-opener-2.6.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index c5dc7c0e..ac472604 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,7 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", "@tauri-apps/plugin-biometric": "^2.4.0", + "@tauri-apps/plugin-haptics": "^2.4.1", "@tauri-apps/plugin-opener": "^2.6.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index 55181ac5..8aacb981 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -948,7 +948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 3.0.6", ] [[package]] @@ -1080,7 +1080,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1301,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1644,8 +1644,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -2080,7 +2080,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3268,7 +3268,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3302,7 +3302,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -4233,7 +4233,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4291,7 +4291,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4951,7 +4951,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -5246,6 +5245,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-haptics" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f19d31ed9ea0775948e47104916565b9c9bb422c9bfdabaedfaa47c2494fd9b" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-opener" version = "2.6.0" @@ -5375,10 +5388,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5809,6 +5822,7 @@ name = "tty7-mobile" version = "0.1.0" dependencies = [ "anyhow", + "block2", "iroh", "jni 0.21.1", "objc2", @@ -5819,6 +5833,7 @@ dependencies = [ "tauri-build", "tauri-plugin-barcode-scanner", "tauri-plugin-biometric", + "tauri-plugin-haptics", "tauri-plugin-opener", "tokio", "tty7-mobile-client", @@ -6244,7 +6259,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6690,8 +6705,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.21", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/mobile/src-tauri/Cargo.toml b/mobile/src-tauri/Cargo.toml index e47dc3d9..ba6a1c5a 100644 --- a/mobile/src-tauri/Cargo.toml +++ b/mobile/src-tauri/Cargo.toml @@ -37,15 +37,20 @@ tauri-plugin-opener = "2" tauri-plugin-barcode-scanner = "2" # Face ID / passcode before the app shows anything, when turned on. tauri-plugin-biometric = "2" +# A tap felt under the finger for the keys and answers, as the phone's own +# keyboard gives. +tauri-plugin-haptics = "2" # Reading the system bars' size and leaving for the background (`insets`, # `to_background`), which the Tauri API does not reach. [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" -# Setting one WKWebView property the Tauri API does not reach (`edge_to_edge`). +# What the Tauri API does not reach: the WKWebView's insets (`edge_to_edge`), +# the keyboard's height and the bar WebKit puts over it (`keyboard`). [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" +block2 = "0.6" # The Android library ships as built: stripping takes it from 30 MB to 20, and # the APK from 32 to 23. iOS strips at export either way. diff --git a/mobile/src-tauri/Info.ios.plist b/mobile/src-tauri/Info.ios.plist index b2c9a55a..56d0312c 100644 --- a/mobile/src-tauri/Info.ios.plist +++ b/mobile/src-tauri/Info.ios.plist @@ -24,5 +24,18 @@ compliance question on every TestFlight upload. --> ITSAppUsesNonExemptEncryption + + CFBundleURLTypes + + + CFBundleURLName + dev.tty7.mobile.pair + CFBundleURLSchemes + + tty7pair + + + diff --git a/mobile/src-tauri/capabilities/mobile.json b/mobile/src-tauri/capabilities/mobile.json index 5cac1044..f598c3d2 100644 --- a/mobile/src-tauri/capabilities/mobile.json +++ b/mobile/src-tauri/capabilities/mobile.json @@ -9,6 +9,7 @@ ], "permissions": [ "barcode-scanner:default", - "biometric:default" + "biometric:default", + "haptics:default" ] } \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 12e2393e..8c4d452b 100644 --- a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,13 @@ + + + + + + + val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / view.resources.displayMetrics.density webView.evaluateJavascript( - "window.dispatchEvent(new CustomEvent('android-keyboard', { detail: $keyboard }))", + "window.dispatchEvent(new CustomEvent('native-keyboard', { detail: { height: $keyboard } }))", null, ) ViewCompat.onApplyWindowInsets(view, insets) diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index d27efe2d..b2465ee7 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -53,6 +53,17 @@ enum Up { #[cfg(test)] mod tests { + #[test] + fn an_error_says_each_thing_once() { + let e = anyhow::anyhow!("aborted by peer: refused") + .context("aborted by peer: refused") + .context("could not reach studio"); + assert_eq!( + super::err(e), + "could not reach studio: aborted by peer: refused" + ); + } + #[test] fn names_come_back_from_their_header_encoding() { assert_eq!( @@ -65,8 +76,17 @@ mod tests { fn err(e: impl std::fmt::Display) -> String { // `{:#}` walks anyhow's chain, so "could not reach studio: no route" - // reaches the user rather than just the outermost context. - format!("{e:#}") + // reaches the user rather than just the outermost context. A chain can + // say the same thing at several layers — QUIC's close reason is repeated + // by each wrapper around it — and once is enough. + let full = format!("{e:#}"); + let mut said: Vec<&str> = Vec::new(); + for part in full.split(": ") { + if !said.contains(&part) { + said.push(part); + } + } + said.join(": ") } impl AppState { @@ -289,6 +309,22 @@ async fn tab_new( .map_err(err) } +/// Closes a tab and its panes; the machine keeps it to reopen where it can. +#[tauri::command] +async fn tab_close( + state: State<'_, Arc>, + host_id: String, + machine: Option, + workspace_id: String, + tab_id: String, +) -> CmdResult<()> { + let session = state.session(&host_id).await?; + session + .close_tab(machine.as_deref(), &workspace_id, &tab_id) + .await + .map_err(err) +} + /// Sends a file to the machine for a pane, returning its path there. The /// file is the request's raw body, not JSON (a 20 MB photo would be 80 MB as /// a number array); which machine and the file's name ride in headers, the @@ -511,6 +547,8 @@ pub fn run() { // unless a process-wide provider is installed first. `ring` is the one // already in the tree. An `Err` means one is installed, which is fine. let _ = rustls::crypto::ring::default_provider().install_default(); + #[cfg(target_os = "ios")] + launch_link::catch(); // Links tapped in a pane open in the phone's browser. let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init()); @@ -519,13 +557,15 @@ pub fn run() { #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_barcode_scanner::init()) - .plugin(tauri_plugin_biometric::init()); + .plugin(tauri_plugin_biometric::init()) + .plugin(tauri_plugin_haptics::init()); builder .setup(|app| { #[cfg(target_os = "ios")] if let Some(window) = app.get_webview_window("main") { edge_to_edge(&window); + keyboard(&window); } let dir = app.path().app_data_dir()?; app.manage(Arc::new(AppState { @@ -547,6 +587,7 @@ pub fn run() { unwatch, refresh, tab_new, + tab_close, upload, diff, pane_open, @@ -555,10 +596,161 @@ pub fn run() { pane_close, appearance, insets, - to_background + to_background, + opened_link ]) - .run(tauri::generate_context!()) - .expect("error while running tty7"); + .build(tauri::generate_context!()) + .expect("error while building tty7") + .run(|_app, _event| { + #[cfg(any(target_os = "ios", target_os = "android"))] + if let tauri::RunEvent::Opened { urls } = _event { + opened(_app, urls); + } + }); +} + +/// A link the app was opened with that the page has not taken yet: one that +/// launched the app arrives before the page is there to hear it. +static OPENED: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// The desktop's pairing code is a `tty7pair:` link, so a phone's camera +/// pointed at its QR code opens the app with it (`Info.ios.plist`, the +/// Android manifest). Kept for the page to ask for, and told to it if it is +/// already listening. +#[cfg(any(target_os = "ios", target_os = "android"))] +fn opened(app: &tauri::AppHandle, urls: Vec) { + use tauri::Emitter as _; + let Some(link) = urls + .into_iter() + .map(String::from) + .find(|u| u.starts_with("tty7pair:")) + else { + return; + }; + *OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link.clone()); + let _ = app.emit("opened-link", link); +} + +/// A link that launches the app on iOS. UIKit hands it to the app's first +/// scene as that connects, in the connection options, and the event loop +/// (tao) passes on only the links that come once the scene is up. So its +/// scene delegate's connect is wrapped to keep the link on the way. The +/// delegate class exists once the app delegate does, which is installed as +/// UIKit starts: the wrap goes on then. +#[cfg(target_os = "ios")] +mod launch_link { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::sel; + use std::ffi::{CStr, c_char}; + use std::sync::OnceLock; + + static SET_DELEGATE: OnceLock = OnceLock::new(); + static CONNECT: OnceLock = OnceLock::new(); + + type SetDelegate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject); + type Connect = unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut AnyObject, + *mut AnyObject, + ); + + /// Swaps `selector`'s implementation on `class` for `imp`, keeping the + /// one it had in `previous`. + unsafe fn wrap(class: *const AnyClass, selector: Sel, imp: Imp, previous: &OnceLock) { + unsafe { + let method = objc2::ffi::class_getInstanceMethod(class, selector); + if method.is_null() || previous.get().is_some() { + return; + } + if let Some(original) = objc2::ffi::method_setImplementation(method as *mut _, imp) { + let _ = previous.set(original as usize); + } + } + } + + pub fn catch() { + if let Some(app) = AnyClass::get(c"UIApplication") { + unsafe { + let imp: Imp = std::mem::transmute(set_delegate as SetDelegate); + wrap(app, sel!(setDelegate:), imp, &SET_DELEGATE); + } + } + } + + unsafe extern "C-unwind" fn set_delegate( + this: *mut AnyObject, + cmd: Sel, + delegate: *mut AnyObject, + ) { + unsafe { + if let Some(&previous) = SET_DELEGATE.get() { + let previous: SetDelegate = std::mem::transmute(previous); + previous(this, cmd, delegate); + } + if let Some(scene) = AnyClass::get(c"TaoSceneDelegate") { + let imp: Imp = std::mem::transmute(connect as Connect); + wrap( + scene, + sel!(scene:willConnectToSession:options:), + imp, + &CONNECT, + ); + } + } + } + + unsafe extern "C-unwind" fn connect( + this: *mut AnyObject, + cmd: Sel, + scene: *mut AnyObject, + session: *mut AnyObject, + options: *mut AnyObject, + ) { + unsafe { + if !options.is_null() { + let contexts: *mut AnyObject = msg_send![options, URLContexts]; + let context: *mut AnyObject = if contexts.is_null() { + std::ptr::null_mut() + } else { + msg_send![contexts, anyObject] + }; + let url: *mut AnyObject = if context.is_null() { + std::ptr::null_mut() + } else { + msg_send![context, URL] + }; + let text: *mut AnyObject = if url.is_null() { + std::ptr::null_mut() + } else { + msg_send![url, absoluteString] + }; + let utf8: *const c_char = if text.is_null() { + std::ptr::null() + } else { + msg_send![text, UTF8String] + }; + if !utf8.is_null() { + let link = CStr::from_ptr(utf8).to_string_lossy().into_owned(); + if link.starts_with("tty7pair:") { + *super::OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link); + } + } + } + if let Some(&previous) = CONNECT.get() { + let previous: Connect = std::mem::transmute(previous); + previous(this, cmd, scene, session, options); + } + } + } +} + +/// The link the app was opened with, once. +#[tauri::command] +fn opened_link() -> Option { + OPENED.lock().unwrap_or_else(|e| e.into_inner()).take() } /// Light, dark or the system's, for what the page does not draw itself: the @@ -612,6 +804,119 @@ fn edge_to_edge(window: &tauri::WebviewWindow) { }); } +/// The keyboard, told to the page. The WebView runs edge to edge, and so it +/// says the keyboard's size unreliably — not at all while WebKit's form bar +/// is up, and halfway through its move otherwise: the page would go on under +/// it, the message box with it. Where its top edge lands, and how long it +/// takes to get there, are sent as a `native-keyboard` event as it starts to +/// move, as Android's MainActivity sends its height. And WebKit's bar over it — the +/// previous, next and Done of a web form — goes: a terminal has no form to +/// step through, and the room is the pane's. +#[cfg(target_os = "ios")] +fn keyboard(window: &tauri::WebviewWindow) { + use block2::RcBlock; + use objc2::encode::{Encode, Encoding}; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::{msg_send, sel}; + use std::ffi::{CStr, CString}; + use std::ptr::{NonNull, null_mut}; + + #[repr(C)] + #[derive(Clone, Copy)] + struct Pair(f64, f64); + /// A CGRect: its origin, then its size, laid out alike. + #[repr(C)] + #[derive(Clone, Copy)] + struct Rect(Pair, Pair); + unsafe impl Encode for Rect { + const ENCODING: Encoding = Encoding::Struct( + "CGRect", + &[ + Encoding::Struct("CGPoint", &[f64::ENCODING, f64::ENCODING]), + Encoding::Struct("CGSize", &[f64::ENCODING, f64::ENCODING]), + ], + ); + } + + unsafe extern "C-unwind" fn no_bar(_this: *mut AnyObject, _cmd: Sel) -> *mut AnyObject { + null_mut() + } + unsafe fn string(s: &CStr) -> *mut AnyObject { + match AnyClass::get(c"NSString") { + Some(class) => msg_send![class, stringWithUTF8String: s.as_ptr()], + None => null_mut(), + } + } + + // Set up on the main thread, where the WebView is handed over; it lives + // as long as the app, to measure against and to tell. Told directly, not + // through Tauri's `eval`, which waits on the main thread the keyboard is + // announced on. + let _ = window.with_webview(|webview| unsafe { + let wk = webview.inner() as *mut AnyObject; + if wk.is_null() { + return; + } + if let Some(content) = AnyClass::get(c"WKContentView") { + let imp: Imp = std::mem::transmute( + no_bar as unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> *mut AnyObject, + ); + objc2::ffi::class_replaceMethod( + content as *const AnyClass as *mut AnyClass, + sel!(inputAccessoryView), + imp, + c"@@:".as_ptr(), + ); + } + + let (Some(center), Some(queue)) = (AnyClass::get(c"NSNotificationCenter"), AnyClass::get(c"NSOperationQueue")) + else { + return; + }; + let center: *mut AnyObject = msg_send![center, defaultCenter]; + let queue: *mut AnyObject = msg_send![queue, mainQueue]; + let end_key: *mut AnyObject = msg_send![string(c"UIKeyboardFrameEndUserInfoKey"), retain]; + let duration_key: *mut AnyObject = msg_send![string(c"UIKeyboardAnimationDurationUserInfoKey"), retain]; + let changed = RcBlock::new(move |note: NonNull| { + let info: *mut AnyObject = msg_send![note.as_ref(), userInfo]; + if info.is_null() { + return; + } + let end: *mut AnyObject = msg_send![info, objectForKey: end_key]; + if end.is_null() { + return; + } + let duration: *mut AnyObject = msg_send![info, objectForKey: duration_key]; + let frame: Rect = msg_send![end, CGRectValue]; + let seconds: f64 = if duration.is_null() { 0.25 } else { msg_send![duration, doubleValue] }; + // Where its top edge will be, in the page's own pixels: what is + // above it is what the page has. + let local: Rect = msg_send![wk, convertRect: frame, fromView: null_mut::()]; + let Ok(script) = CString::new(format!( + "window.dispatchEvent(new CustomEvent('native-keyboard',{{detail:{{top:{},duration:{}}}}}))", + local.0 .1.round(), + (seconds * 1000.0).round() + )) else { + return; + }; + let _: () = msg_send![ + wk, + evaluateJavaScript: string(&script), + completionHandler: null_mut::() + ]; + }); + let token: *mut AnyObject = msg_send![ + center, + addObserverForName: string(c"UIKeyboardWillChangeFrameNotification"), + object: null_mut::(), + queue: queue, + usingBlock: &*changed + ]; + // Watched for as long as the app runs. + let _: *mut AnyObject = msg_send![token, retain]; + }); +} + /// The system bars and display cutout the page is drawn under, in CSS pixels. /// Android runs the WebView edge to edge, yet WebViews before 140 report /// `env(safe-area-inset-*)` as 0, so the page asks here. Zero elsewhere: iOS's diff --git a/mobile/src-tauri/tauri.conf.json b/mobile/src-tauri/tauri.conf.json index f9391b30..b7e23422 100644 --- a/mobile/src-tauri/tauri.conf.json +++ b/mobile/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ } ], "security": { - "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src ipc: http://ipc.localhost" } }, "bundle": { diff --git a/mobile/src/api.ts b/mobile/src/api.ts index e08bd2a2..ef3452e2 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -24,6 +24,8 @@ export interface PaneView { title: string; cwd?: string | null; agent?: AgentView | null; + /** Nothing runs in it: the machine's server restarted since. */ + stopped?: boolean; } export interface TabView { @@ -109,6 +111,9 @@ export interface Insets { /** What the system bars cover, in CSS pixels. Android only: zero elsewhere. */ export const insets = () => invoke("insets"); +/** The `tty7pair:` link the app was opened with and has not handled, once. */ +export const openedLink = () => invoke("opened_link"); + /** Sends the app to the background, as Back from the first screen does. */ export const toBackground = () => invoke("to_background"); @@ -153,6 +158,10 @@ export const tabNew = ( size: { cols: number; rows: number } | null, ) => invoke("tab_new", { hostId, machine, workspaceId, cwd, size }); +/** Closes a tab and its panes; the machine keeps it to reopen where it can. */ +export const tabClose = (hostId: string, machine: string | null, workspaceId: string, tabId: string) => + invoke("tab_close", { hostId, machine, workspaceId, tabId }); + export const paneInput = (handle: number, data: string) => invoke("pane_input", { handle, data }); diff --git a/mobile/src/assets/fonts/LICENSE-NotoSansSymbols b/mobile/src/assets/fonts/LICENSE-NotoSansSymbols new file mode 100644 index 00000000..7c95767f --- /dev/null +++ b/mobile/src/assets/fonts/LICENSE-NotoSansSymbols @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/mobile/src/assets/fonts/LICENSE-SymbolsNerdFont b/mobile/src/assets/fonts/LICENSE-SymbolsNerdFont new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/mobile/src/assets/fonts/LICENSE-SymbolsNerdFont @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mobile/src/assets/fonts/NotoEmoji-subset.woff2 b/mobile/src/assets/fonts/NotoEmoji-subset.woff2 new file mode 100644 index 00000000..fcf6e426 Binary files /dev/null and b/mobile/src/assets/fonts/NotoEmoji-subset.woff2 differ diff --git a/mobile/src/assets/fonts/NotoSansSymbols-subset.woff2 b/mobile/src/assets/fonts/NotoSansSymbols-subset.woff2 new file mode 100644 index 00000000..0c9853b7 Binary files /dev/null and b/mobile/src/assets/fonts/NotoSansSymbols-subset.woff2 differ diff --git a/mobile/src/assets/fonts/NotoSansSymbols2-subset.woff2 b/mobile/src/assets/fonts/NotoSansSymbols2-subset.woff2 new file mode 100644 index 00000000..c4d62b38 Binary files /dev/null and b/mobile/src/assets/fonts/NotoSansSymbols2-subset.woff2 differ diff --git a/mobile/src/assets/fonts/SymbolsNerdFontMono.woff2 b/mobile/src/assets/fonts/SymbolsNerdFontMono.woff2 new file mode 100644 index 00000000..94aaeb48 Binary files /dev/null and b/mobile/src/assets/fonts/SymbolsNerdFontMono.woff2 differ diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 0af15b8a..acb2c333 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -10,6 +10,8 @@ import type { ITheme } from "@xterm/xterm"; import * as scanner from "@tauri-apps/plugin-barcode-scanner"; import { getVersion, onBackButtonPress } from "@tauri-apps/api/app"; +import { listen } from "@tauri-apps/api/event"; +import { impactFeedback, selectionFeedback } from "@tauri-apps/plugin-haptics"; import * as api from "./api"; import { MAX_UPLOAD } from "./api"; @@ -34,17 +36,20 @@ const android = /Android/.test(navigator.userAgent); // The keyboard. The WebView runs edge to edge and is never resized for it // (lib.rs `edge_to_edge`): the keyboard simply covers the bottom of the page. -// What is left is the visual viewport, so the app is sized to that, and the -// dock and the message box sit on top of the keyboard. Screens that lay out -// by size hear it as a window resize. Android's WebView leaves the visual -// viewport whole, so the keyboard's height comes from the app (MainActivity). +// What is left is sized to by the app, so the dock and the message box sit on +// top of the keyboard. Screens that lay out by size hear it as a window +// resize. Neither phone's WebView says reliably how much the keyboard covers +// — iOS's leaves the visual viewport whole when edge to edge, Android's +// always — so the app's native side says it (lib.rs `keyboard`, MainActivity) +// as the keyboard starts to move; the visual viewport stands in until it has. { const view = window.visualViewport; let last = 0; - let androidKeyboard = 0; + // The native side's word: how far down the page the keyboard leaves room. + let room: (() => number) | null = null; const fitView = () => { if (!view) return; - const height = Math.round(android ? window.innerHeight - androidKeyboard : view.height); + const height = Math.round(room ? Math.min(room(), window.innerHeight) : view.height); // iOS scrolls the page to show a focused field; the app does its own. if (window.scrollY) window.scrollTo(0, 0); if (height === last) return; @@ -58,8 +63,10 @@ const android = /Android/.test(navigator.userAgent); }; view?.addEventListener("resize", fitView); view?.addEventListener("scroll", fitView); - window.addEventListener("android-keyboard", (e) => { - androidKeyboard = (e as CustomEvent).detail; + // iOS says where the keyboard's top edge lands, Android how tall it is. + window.addEventListener("native-keyboard", (e) => { + const { top, height } = (e as CustomEvent<{ top?: number; height?: number }>).detail; + room = top !== undefined ? () => top : () => window.innerHeight - (height ?? 0); fitView(); }); } @@ -113,6 +120,23 @@ function sentence(text: string) { return `${t[0].toUpperCase()}${t.slice(1)}${/[.!?]$/.test(t) ? "" : "."}`; } +/** A tap felt under the finger: `key` for a key or a button that types, + * `tick` for a control passing a point (a swipe opening, a toggle). */ +function feel(kind: "key" | "tick") { + (kind === "key" ? impactFeedback("light") : selectionFeedback()).catch(() => {}); +} + +/** Why a machine could not be reached, in words, for under a title that + * already names it: the transport's own phrasing is for logs. */ +function why(message: string, name: string) { + const cause = message.replace(new RegExp(`^could not reach ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: `, "i"), ""); + if (/refused to accept|connection refused|aborted by peer/i.test(cause)) + return "It turned the connection away — tty7 there may be restarting."; + if (/timed? ?out|no route|unreachable|network is down/i.test(cause)) return "It didn't answer."; + if (/connection (was )?(lost|closed)/i.test(cause)) return "The connection dropped."; + return sentence(cause); +} + function errorText(e: unknown) { return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); } @@ -197,6 +221,15 @@ function keepSent(text: string) { /** Past messages for what is being written: those it begins, then those * that contain it. */ +function wordStarts(text: string, q: string) { + // Chinese and Japanese leave no spaces between words: anywhere will do. + if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(q)) return text.includes(q); + for (let at = text.indexOf(q); at >= 0; at = text.indexOf(q, at + 1)) { + if (at === 0 || !/[\p{L}\p{N}]/u.test(text[at - 1])) return true; + } + return false; +} + function suggestions(typed: string, limit = 12) { const q = typed.trim().toLowerCase(); if (!q) return []; @@ -206,7 +239,9 @@ function suggestions(typed: string, limit = 12) { const low = past.toLowerCase(); if (low === q) continue; if (low.startsWith(q)) starts.push(past); - else if (low.includes(q)) within.push(past); + // Elsewhere only where a word starts: "ls" finds "git ls-files", not + // every message that mentions tools. + else if (wordStarts(low, q)) within.push(past); } return [...starts, ...within].slice(0, limit); } @@ -220,11 +255,22 @@ function suggestions(typed: string, limit = 12) { let onResume: (() => void) | null = null; let onLeave: (() => void) | null = null; +// Locked, the app is covered as it goes, so the picture the phone keeps for +// its app switcher shows nothing of the panes. +let shade: HTMLElement | null = null; document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { + shade?.remove(); + shade = null; if (prefs.lock && Date.now() - hiddenAt > LOCK_AFTER_MS) lock(); onResume?.(); - } else hiddenAt = Date.now(); + } else { + hiddenAt = Date.now(); + if (prefs.lock && !shade) { + shade = h("div", { class: "lock-cover" }, h("img", { class: "empty-mark", src: logoUrl, alt: "" })); + document.body.append(shade); + } + } }); // The app lock: a cover over everything until Face ID or the passcode says @@ -342,6 +388,22 @@ document.addEventListener( ); document.addEventListener("touchcancel", () => (edgeSwipe = null), { capture: true, passive: true }); +// A tap on nothing in particular puts the keyboard away, as it does in the +// phone's own apps: there is no Done bar over it (lib.rs `keyboard`). Taps +// on controls keep it — the key row and Send write into the focused field — +// and a pane decides for itself (`terminalScreen`). +document.addEventListener( + "pointerdown", + (e) => { + const field = document.activeElement; + if (!(field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)) return; + const target = e.target as Element; + if (target.closest?.("button, input, textarea, select, label, a, .term")) return; + field.blur(); + }, + { capture: true, passive: true }, +); + // Android's Back button and gesture. Left to the WebView, they would leave the // app, since it has no history. Back closes what is open over the screen // first, then goes back a screen, and from the first one puts the app away. @@ -433,14 +495,24 @@ function floatingBar(placeholder: string, onSearch: (query: string) => void, add ariaLabel: placeholder, }); field.setAttribute("autocorrect", "off"); - field.oninput = () => onSearch(field.value.trim().toLowerCase()); + const clear = h("button", { class: "search-clear", ariaLabel: "Clear", hidden: true }, ico("close")); + const changed = () => { + clear.hidden = !field.value; + onSearch(field.value.trim().toLowerCase()); + }; + field.oninput = changed; field.onkeydown = (e) => { if (e.key === "Enter") field.blur(); }; + clear.onpointerdown = (e) => e.preventDefault(); + clear.onclick = () => { + field.value = ""; + changed(); + }; return h( "div", { class: "float-bar" }, - h("label", { class: "search" }, ico("search"), field), + h("label", { class: "search" }, ico("search"), field, clear), h("button", { class: "fab", ariaLabel: add.label, onclick: add.run }, ico("plus")), ); } @@ -460,11 +532,16 @@ function section(title: Child, ...rows: Child[]) { /** What the last visit to each machine saw: its link and how many tabs it * had. The list shows it rather than holding a stream open per machine. */ const seen = new Map(); +/** Each machine's tree as last reported, drawn while a new watch comes up. */ +const trees = new Map(); function hostMeta(hostId: string): { tone: string; text: string } | null { const last = seen.get(hostId); if (!last) return null; - const tabs = last.tabs === null ? "" : ` · ${last.tabs} ${last.tabs === 1 ? "tab" : "tabs"}`; + const waiting = trees.has(hostId) ? waitingCount(trees.get(hostId)!) : 0; + const tabs = + (last.tabs === null ? "" : ` · ${last.tabs} ${last.tabs === 1 ? "tab" : "tabs"}`) + + (waiting ? ` · ${waiting} waiting` : ""); if (last.link === null) return { tone: "offline", text: "Offline" }; if (last.link.path === "connecting") return { tone: "connecting", text: `Connecting…${tabs}` }; const path = last.link.path === "direct" ? "Direct" : "Relay"; @@ -472,6 +549,7 @@ function hostMeta(hostId: string): { tone: string; text: string } | null { } function hostsScreen(direction: "push" | "pop" = "pop") { + remember("last.host", ""); go(direction, () => { const body = h("div", { class: "stack" }); let all: Host[] = []; @@ -508,7 +586,7 @@ function hostsScreen(direction: "push" | "pop" = "pop") { h( "p", { class: "empty-body" }, - "Reach the panes open in tty7 on your computer, and type into them from here.", + "Reach the panes open in tty7 on your computer, and type into them from here. In tty7, open Settings → Mobile and point this phone's camera at the code.", ), h("button", { class: "button primary", onclick: () => pairScreen() }, "Pair a machine"), ), @@ -516,6 +594,8 @@ function hostsScreen(direction: "push" | "pop" = "pop") { return; } dock.hidden = false; + // A few machines are found by eye; the search comes with more. + dock.querySelector(".search")!.hidden = hosts.length < 6; render(); }); return view; @@ -565,7 +645,7 @@ function settingsScreen() { o.value === chosen ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }), ), ); - const note = (group: HTMLElement, text: string) => (group.append(h("p", { class: "group-note" }, text)), group); + const note = (group: HTMLElement, ...text: Child[]) => (group.append(h("p", { class: "group-note" }, ...text)), group); const lockSetting = () => note( @@ -577,13 +657,14 @@ function settingsScreen() { class: "row choice", onclick: async () => { // Turning it on proves it works first, so no one locks - // themselves out. - if (!prefs.lock) { - try { - await authenticate("Turn on the tty7 lock", { allowDeviceCredential: true }); - } catch { - return; - } + // themselves out; turning it off takes the owner too, not + // whoever holds the phone. + try { + await authenticate(prefs.lock ? "Turn off the tty7 lock" : "Turn on the tty7 lock", { + allowDeviceCredential: true, + }); + } catch { + return; } setPref("lock", !prefs.lock); draw(); @@ -603,7 +684,9 @@ function settingsScreen() { h("span", { class: saved ? "row-title danger" : "row-title" }, "Clear message history"), h("span", { class: "row-meta" }, saved ? `${saved} saved` : "Empty"), ); - clear.onclick = () => { + clear.onclick = async () => { + if (!(await confirmSheet("Clear message history?", `The ${saved} messages kept on this phone go for good.`, "Clear"))) + return; remember("history", "[]"); draw(); }; @@ -622,7 +705,10 @@ function settingsScreen() { ), note( section("Panes wider than the phone", ...choices(WIDE, prefs.wide, (v) => setPref("wide", v))), - "How such a pane first shows. Switch any time from its ⋯ menu.", + "How such a pane first shows. Switch any time from its ", + // The menu's own mark: the phone's fonts have no ⋯ of their own. + ico("more", "icon inline-icon"), + " menu.", ), note( section( @@ -850,7 +936,9 @@ async function scanCode(): Promise { } } -function pairScreen() { +/** `linked`, when given, is a pairing code that arrived as a link: it is + * filled in and paired straight away, as a scanned one is. */ +function pairScreen(linked?: string) { go("push", () => { const code = h("textarea", { class: "field-input code", @@ -860,6 +948,9 @@ function pairScreen() { spellcheck: false, ariaLabel: "Pairing code", }); + // A code, not words: no suggestions over the keyboard, no "fixing" it. + code.setAttribute("autocorrect", "off"); + code.setAttribute("autocomplete", "off"); const name = h("input", { class: "field-input", value: guessDeviceName(), @@ -911,6 +1002,11 @@ function pairScreen() { code.oninput = sync; sync(); + if (linked) { + code.value = linked; + sync(); + queueMicrotask(() => submit.click()); + } submit.onclick = async () => { submit.disabled = true; @@ -920,7 +1016,14 @@ function pairScreen() { const host = await api.pair(code.value.trim(), name.value.trim() || "phone"); hostScreen(host, "push"); } catch (e) { - error.textContent = errorText(e); + const text = errorText(e); + error.textContent = /not a tty7 pairing code/i.test(text) + ? "That isn't a pairing code. On your computer, open Settings → Mobile and click Show code." + : /used up or expired/i.test(text) + ? "That code has expired or was already used. Make a new one with New code on your computer." + : /^could not reach /i.test(text) + ? `${why(text, text.replace(/^could not reach ([^:]+):.*$/i, "$1"))} Check that tty7 is running there with phone access on.` + : sentence(text); submit.classList.remove("busy"); submit.textContent = "Pair"; submit.disabled = false; @@ -957,7 +1060,9 @@ function pairScreen() { {}, "Click ", h("strong", {}, "Show code"), - canScan ? ", then scan it here, or copy the code and paste it below." : ", copy the code and paste it below.", + canScan + ? ", then point this phone's Camera at it, or scan it here, or paste the code below." + : ", copy the code and paste it below.", ), ), ), @@ -1029,6 +1134,7 @@ function retrier(run: () => void) { const SLOW_CONNECT_MS = 10_000; function hostScreen(host: Host, direction: "push" | "pop" = "pop") { + remember("last.host", host.id); go(direction, () => { const link = h("p", { class: "link" }); // Shown here and remembered for the machine list. @@ -1041,10 +1147,12 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { const lastLink = () => seen.get(host.id)?.link ?? { path: "connecting" as const, rtt_ms: 0 }; showLink(lastLink()); const notice = h("div", { class: "notice-slot" }); - const body = h("div", { class: "stack" }, skeleton()); + const body = h("div", { class: "stack" }); let alive = true; - let lastTree: Tree | null = null; + // Coming back, the tree last seen is drawn at once and brought up to + // date when the new watch reports, rather than a skeleton every time. + let lastTree: Tree | null = trees.get(host.id) ?? null; let slow: number | undefined; let query = ""; // The one workspace on screen, kept per machine across visits. @@ -1055,7 +1163,13 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { draw(); }; const draw = () => { - if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query, picked, pick)); + if (lastTree) + body.replaceChildren( + ...renderTree(host, lastTree, query, picked, pick, (key) => { + picked = key; + remember(`workspace.${host.id}`, key); + }), + ); }; const dock = floatingBar("Search tabs", (q) => { query = q; @@ -1081,6 +1195,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { ) ) { await api.forget(host.id); + trees.delete(host.id); hostsScreen(); } }, @@ -1135,10 +1250,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { notice.replaceChildren( noticeCard({ title: `Can't reach ${host.name}`, - body: [ - sentence(message), - ` Check that tty7 is running on ${host.name} with phone access on.`, - ], + body: [why(message, host.name), ` Check that tty7 is running on ${host.name} with phone access on.`], actions: [ { label: "Try now", run: start }, { label: "Pair again", run: () => pairScreen() }, @@ -1186,6 +1298,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { if (!lastTree || dropped) notice.replaceChildren(); dropped = false; lastTree = msg.tree; + trees.set(host.id, msg.tree); seen.set(host.id, { link: seen.get(host.id)?.link ?? null, tabs: tabCount(msg.tree) }); draw(); break; @@ -1217,6 +1330,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { // behind: watching again covers both, since the gateway sends the whole // tree on every new watch. onResume = start; + if (lastTree) draw(); start(); return view; }); @@ -1255,6 +1369,15 @@ const STATUS_WORD: Record = { * to, or null for the paired machine itself. */ type Place = { key: string; name: string } | null; +/** Agents on a machine that are waiting for a reply. */ +function waitingCount(tree: Tree) { + const spaces = [...tree.workspaces, ...(tree.remotes ?? []).flatMap((r) => r.workspaces)]; + return spaces.reduce( + (n, ws) => n + ws.tabs.reduce((m, tab) => m + tab.panes.filter((p) => p.agent?.status === "waiting").length, 0), + 0, + ); +} + function tabCount(tree: Tree) { const count = (list: WorkspaceView[]) => list.reduce((n, ws) => n + ws.tabs.length, 0); return count(tree.workspaces) + (tree.remotes ?? []).reduce((n, r) => n + count(r.workspaces), 0); @@ -1287,6 +1410,7 @@ function renderTree( query: string, picked: string | null, pick: (key: string) => void, + keep: (key: string) => void, ): Node[] { if (query) { const groups = [ @@ -1336,6 +1460,9 @@ function renderTree( }), ]; const on = spaces.find((sp) => sp.key === picked) ?? spaces[0]; + // What is on screen stays on screen: a workspace the desktop opens or + // turns to later does not take its place under the reader's thumb. + if (on.key !== picked) keep(on.key); const out: Node[] = []; if (spaces.length > 1) { @@ -1348,6 +1475,8 @@ function renderTree( { class: sp === on ? "ws-chip on" : "ws-chip", role: "tab", onclick: () => pick(sp.key) }, sp.remote && ico("server"), h("span", { class: "ws-chip-name" }, sp.label), + // An agent waiting or at work in a workspace not on screen. + sp !== on && sp.ws && urgentDot(sp.ws.tabs), sp.count !== null && h("span", { class: "ws-chip-count" }, String(sp.count)), ); chip.setAttribute("aria-selected", String(sp === on)); @@ -1420,7 +1549,7 @@ function workspaceGroup( searching = false, ) { const rowsOf = (tabs: TabView[]) => - tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane, tab.id === ws.active_tab))); + tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, ws, tab, pane, tab.id === ws.active_tab))); // The desktop sidebar's groups, each with the tabs still in it (a search // leaves some out). An older desktop sends none: one list, as before. const byId = new Map(ws.tabs.map((tab) => [tab.id, tab])); @@ -1455,6 +1584,11 @@ function mostUrgent(tabs: TabView[]): AgentStatus | null { return order.find((s) => states.has(s)) ?? null; } +function urgentDot(tabs: TabView[]) { + const urgent = mostUrgent(tabs.map((tab) => ({ ...tab, panes: tab.panes.filter((p) => !p.stopped) }))); + return urgent && h("span", { class: `status-dot ${urgent}` }); +} + /** One sidebar group: a header that folds it, over its rows. It starts folded * as the desktop has it; a fold here is this phone's own and is remembered, * and a search shows every match whatever is folded. */ @@ -1472,13 +1606,12 @@ function tabGroup( const key = `fold.${host.id}.${spaceKey(place, ws)}.${group.pinned ? "pin" : "auto"}.${group.name}`; const saved = remembered(key); let folded = !searching && (saved === null ? !!group.collapsed : saved === "1"); - const urgent = mostUrgent(tabs); const head = h( "button", { class: "tgroup-head" }, ico("chevron", "icon tgroup-chevron"), h("span", { class: "tgroup-name" }, group.name), - urgent && h("span", { class: `status-dot ${urgent}` }), + urgentDot(tabs), h("span", { class: "tgroup-count" }, String(tabs.length)), ); const section = h("div", { class: "tgroup" }, head, card); @@ -1518,9 +1651,9 @@ function remember(key: string, value: string) { } } -/** The sheet "+" opens on a machine: pick what runs, pick the workspace, Open. - * The tab starts in the directory its workspace's last tab is in, sized to - * this screen, and the agent's command is typed into it once it is live. */ +/** The sheet "+" opens on a machine: pick what runs, where, Open. The tab is + * sized to this screen, and the agent's command is typed into it once it is + * live. */ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, picked: string | null) { type Target = { place: Place; ws: WorkspaceView }; const targets: Target[] = [ @@ -1533,8 +1666,26 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, // The workspace on screen, unless it cannot take a new tab. let target = Math.max(0, targets.findIndex((t) => spaceKey(t.place, t.ws) === picked)); + // Where it starts: one of the folders the workspace has tabs in, the + // tab in front on the desktop first. + const foldersOf = (ws: WorkspaceView) => { + const tabs = [...ws.tabs].sort((a, b) => Number(b.id === ws.active_tab) - Number(a.id === ws.active_tab)); + const used = [...new Set(tabs.flatMap((tab) => tab.panes.map((pane) => pane.cwd ?? "")).filter(Boolean))]; + // Then home, where a shell starts when given nowhere: "" here, none sent. + return [...used, ""]; + }; + let folder = 0; + const agents = h("div", { class: "agent-grid" }); const places = h("div", { class: "card" }); + const folders = h("div", { class: "card" }); + const folderGroup = h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Folder"), folders); + const placeGroup = h( + "section", + { class: "sheet-group" }, + h("h3", { class: "group-title" }, "Workspace"), + targets.length ? places : h("p", { class: "group-empty" }, `Open a workspace in tty7 on ${host.name} first.`), + ); const open = h("button", { class: "button primary wide sheet-open" }, "Open"); const error = h("p", { class: "field-error", role: "alert" }); @@ -1553,13 +1704,28 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, ...targets.map((t, i) => h( "button", - { class: "row choice", onclick: () => ((target = i), draw()) }, + { class: "row choice", onclick: () => ((target = i), (folder = 0), draw()) }, h("span", { class: "row-title" }, workspaceName(t.ws.name)), h("span", { class: "row-meta" }, t.place?.name ?? `${t.ws.tabs.length} ${t.ws.tabs.length === 1 ? "tab" : "tabs"}`), i === target ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }), ), ), ); + const dirs = targets[target] ? foldersOf(targets[target].ws) : []; + folders.replaceChildren( + ...dirs.map((dir, i) => + h( + "button", + { class: "row choice", onclick: () => ((folder = i), draw()) }, + h("span", { class: "row-title" }, dir ? baseName(dir) : "Home"), + h("span", { class: "row-meta" }, dir ? parentPath(dir) : "~"), + i === folder ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }), + ), + ), + ); + // Only a choice when there is one to make. + folderGroup.hidden = dirs.length < 2; + placeGroup.hidden = targets.length === 1; open.disabled = targets.length === 0; }; draw(); @@ -1568,12 +1734,8 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, "New tab", h("div", { class: "sheet-body" }, h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Agent"), agents), - h( - "section", - { class: "sheet-group" }, - h("h3", { class: "group-title" }, "Workspace"), - targets.length ? places : h("p", { class: "group-empty" }, `Open a workspace in tty7 on ${host.name} first.`), - ), + folderGroup, + placeGroup, error, ), open, @@ -1584,12 +1746,14 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, remember("newtab.agent", s.kind ?? "shell"); open.disabled = true; error.textContent = ""; - const cwd = ws.tabs.at(-1)?.panes[0]?.cwd ?? null; + const cwd = foldersOf(ws)[folder] || null; try { const created = await api.tabNew(host.id, place?.key ?? null, ws.id, cwd, phoneGrid()); remove(); - const title = s.kind ? agentLook(s.kind).name : "shell"; - terminalScreen(host, place, { id: created.pane_id, title, cwd }, title, s.command ?? undefined); + // Named as the list will name it: by its agent, or by its folder. + const title = s.kind ? agentLook(s.kind).name : cwd ? baseName(cwd) : "Shell"; + const tab = { workspace: ws.id, id: created.tab_id, name: title, busy: false }; + terminalScreen(host, place, { id: created.pane_id, title, cwd }, title, tab, s.command ?? undefined, true); } catch (e) { error.textContent = sentence(errorText(e)); failed(errorText(e)); @@ -1617,6 +1781,48 @@ function openSheet(title: string, ...content: Child[]) { scrim.classList.add("leaving"); setTimeout(() => scrim.remove(), still.matches ? 0 : 220); }; + // Pulled down, it goes, as the phone's own sheets do: from its top, or + // from anywhere while what it holds is scrolled to the start. Let go + // short of the way, it settles back. + let pull: { y: number; t: number; dy: number; claimed: boolean } | null = null; + sheet.addEventListener( + "touchstart", + (e) => { + const target = e.target as Element; + const scroller = target.closest?.(".sheet-body, .diff-code, input, textarea"); + if (e.touches.length !== 1 || (scroller && scroller.scrollTop > 0) || target.closest?.("input, textarea")) return; + pull = { y: e.touches[0].clientY, t: e.timeStamp, dy: 0, claimed: false }; + }, + { passive: true }, + ); + sheet.addEventListener( + "touchmove", + (e) => { + if (!pull) return; + const dy = e.touches[0].clientY - pull.y; + if (!pull.claimed) { + if (Math.abs(dy) < 8) return; + if (dy < 0) return void (pull = null); + pull.claimed = true; + sheet.style.transition = "none"; + } + e.preventDefault(); + pull.dy = Math.max(0, dy); + sheet.style.transform = `translateY(${pull.dy}px)`; + }, + { passive: false }, + ); + sheet.addEventListener("touchend", (e) => { + const done = pull; + pull = null; + if (!done?.claimed) return; + const fast = done.dy / Math.max(1, e.timeStamp - done.t) > 0.5; + if (done.dy > Math.min(140, sheet.offsetHeight / 3) || fast) close(); + else { + sheet.style.transition = "transform 0.25s var(--ease)"; + sheet.style.transform = ""; + } + }); document.body.append(scrim); return { close, remove: () => scrim.remove() }; } @@ -1628,15 +1834,23 @@ function changesSheet(host: Host, place: Place, cwd: string) { openSheet("Changes", body); api.diff(host.id, place?.key ?? null, cwd).then( (d) => body.replaceChildren(...renderDiff(d)), - (e) => body.replaceChildren(h("p", { class: "group-empty" }, sentence(errorText(e)))), + (e) => { + const text = errorText(e); + // The usual case is no failure at all: a folder outside any repository. + const said = /not in a git repository/.test(text) + ? `${baseName(cwd)} is not in a Git repository, so there are no changes to show.` + : sentence(text); + body.replaceChildren(h("p", { class: "group-empty" }, said)); + }, ); } function renderDiff(d: api.Diff): Node[] { const files = d.patch.split(/^(?=diff --git )/m).filter((f) => f.startsWith("diff --git ")); - const out: Node[] = [ - h("p", { class: "diff-root" }, shortPath(d.root), files.length || d.untracked.length ? "" : " · no changes"), - ]; + const root = h("p", { class: "diff-root" }, shortPath(d.root), files.length || d.untracked.length ? "" : " · no changes"); + const out: Node[] = [root]; + let added = 0; + let removed = 0; for (const f of files) { const lines = f.split("\n"); const name = @@ -1653,10 +1867,15 @@ function renderDiff(d: api.Diff): Node[] { if (kind === "del") del++; code.append(h("div", { class: `diff-line ${kind}` }, line || " ")); } + added += add; + removed += del; const binary = !inHunk && /^Binary files/m.test(f); + // What a tool wrote rather than a person starts folded: a lock file's + // hundreds of lines would bury the change that matters. + const generated = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|Gemfile\.lock|poetry\.lock|composer\.lock|go\.sum)$/.test(name); const block = h( "details", - { class: "diff-file", open: files.length <= 6 }, + { class: "diff-file", open: files.length <= 6 && !generated }, h( "summary", {}, @@ -1668,12 +1887,32 @@ function renderDiff(d: api.Diff): Node[] { ); out.push(block); } + if (files.length) + root.append( + ` · ${files.length} ${files.length === 1 ? "file" : "files"} `, + h("span", { class: "diff-add" }, `+${added}`), + " ", + h("span", { class: "diff-del" }, `−${removed}`), + ); if (d.truncated) out.push(h("p", { class: "group-empty" }, "Cut short: the rest is too long to show here.")); if (d.untracked.length) { out.push( section( `Not tracked yet · ${d.untracked.length}`, - ...d.untracked.slice(0, 200).map((p) => h("div", { class: "row choice static" }, h("span", { class: "row-title diff-name" }, p))), + ...d.untracked.slice(0, 200).map((p) => { + // A name to read and where it is, as the folder lists say it; a + // new directory keeps its slash. + const dir = p.endsWith("/"); + const parts = p.replace(/\/$/, "").split("/"); + const leaf = parts.pop() + (dir ? "/" : ""); + return h( + "div", + { class: "row choice static" }, + h("span", { class: "row-title" }, leaf), + // The end of the folder is the part that tells them apart. + parts.length > 0 && h("span", { class: "row-meta" }, parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : parts.join("/")), + ); + }), ), ); } @@ -1717,9 +1956,16 @@ function confirmSheet(title: string, text: string, action: string): Promise p.agent && (p.agent.status === "working" || p.agent.status === "waiting")); + return { workspace: ws.id, id: tab.id, name, busy }; +} + +/** Closes a tab, asking first when an agent in it is at work. False when it + * was not closed: declined, or refused, which `failed` is told. */ +async function closeTab(host: Host, place: Place, tab: TabRef, failed: (message: string) => void) { + if ( + tab.busy && + !(await confirmSheet( + `Close ${tab.name}?`, + "Its agent is still at work and stops with it. You can reopen the tab from tty7 on your computer.", + "Close tab", + )) + ) + return false; + try { + await api.tabClose(host.id, place?.key ?? null, tab.workspace, tab.id); + return true; + } catch (e) { + failed(sentence(errorText(e))); + return false; + } +} + +/** The row a swipe has opened, so opening another closes it. */ +let swiped: { close: () => void } | null = null; + +/** A row that slides left to show an action behind it, as a list on the + * phone does. A swipe far enough opens it; a tap anywhere then closes it. */ +function swipeable(row: HTMLElement, label: string, spoken: string, run: () => Promise) { + const WIDTH = 88; + const action = h("button", { class: "swipe-action", ariaLabel: spoken }, label); + const wrap = h("div", { class: "swipe" }, action, row); + let at = 0; + let drag: { x: number; y: number; from: number; claimed: boolean } | null = null; + const move = (x: number) => { + at = x; + row.style.transform = x ? `translateX(${x}px)` : ""; + }; + const me = { + close: () => { + move(0); + if (swiped === me) swiped = null; + }, + }; + row.addEventListener( + "touchstart", + (e) => { + const t = e.touches[0]; + drag = { x: t.clientX, y: t.clientY, from: at, claimed: false }; + }, + { passive: true }, + ); + row.addEventListener( + "touchmove", + (e) => { + if (!drag) return; + const t = e.touches[0]; + const dx = t.clientX - drag.x; + const dy = Math.abs(t.clientY - drag.y); + if (!drag.claimed) { + if (Math.max(Math.abs(dx), dy) < 8) return; + // Mostly sideways it is the row's; anything else scrolls the list. + if (dy >= Math.abs(dx)) return (drag = null); + drag.claimed = true; + wrap.classList.add("dragging"); + if (swiped && swiped !== me) swiped.close(); + } + e.preventDefault(); + const x = drag.from + dx; + const was = at < -WIDTH / 2; + // Past the action's width it gives, but grudgingly. + move(Math.min(0, x < -WIDTH ? -WIDTH + (x + WIDTH) / 3 : x)); + // Felt as it passes the point where letting go opens it. + if (was !== at < -WIDTH / 2) feel("tick"); + }, + { passive: false }, + ); + row.addEventListener("touchend", () => { + if (!drag?.claimed) return void (drag = null); + drag = null; + wrap.classList.remove("dragging"); + if (at < -WIDTH / 2) { + move(-WIDTH); + swiped = me; + } else me.close(); + }); + // Open, a tap on the row closes it rather than opening the pane. + row.addEventListener( + "click", + (e) => { + if (at === 0) return; + e.stopImmediatePropagation(); + e.preventDefault(); + me.close(); + }, + { capture: true }, + ); + action.onclick = async () => { + action.disabled = true; + if (await run()) { + wrap.classList.add("gone"); + } else { + action.disabled = false; + me.close(); + } + }; + return wrap; +} + +function paneRow(host: Host, place: Place, ws: WorkspaceView, tab: TabView, pane: PaneView, current = false) { const agent = pane.agent; const sub: Child[] = []; - if (agent && agent.status !== "idle") + // A pane that is not running has no agent at work, whatever it last said. + if (agent && agent.status !== "idle" && !pane.stopped) sub.push(h("span", { class: `status-word ${agent.status}` }, STATUS_WORD[agent.status])); - // A tab named after its directory says the cwd already; what tells its - // panes apart then is what runs in them. + // A tab named after its directory goes by the directory's own name, the + // end a narrow row would cut off. What tells a split's panes apart is + // what runs in them. const namedByPath = /^[~/]/.test(tab.name); - const split = tab.panes.length > 1 || namedByPath ? pane.title : null; - const dir = agent || namedByPath ? null : shortPath(pane.cwd); + const name = namedByPath ? baseName(tab.name) : tab.name; + const pathTitle = (t: string) => t === tab.name || t === pane.cwd || /^[~/]/.test(t); + const split = tab.panes.length > 1 && !pathTitle(pane.title) ? pane.title : null; + // Where it is, said the same way on every row, however the tab is named. + const dir = shortPath(pane.cwd ?? (namedByPath ? tab.name : null)); const detail = [split, agent?.message ?? dir].filter(Boolean).join(" · "); if (detail) sub.push(sub.length ? ` · ${detail}` : detail); - return h( + const row = h( "button", { - class: tab.hibernated ? "row asleep" : "row", - onclick: () => terminalScreen(host, place, pane, tab.name), + class: tab.hibernated || pane.stopped ? "row asleep" : "row", + onclick: () => terminalScreen(host, place, pane, name, tabRef(ws, tab, name)), }, avatar(agent), h( @@ -1757,17 +2129,149 @@ function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView, current { class: "row-text" }, h( "span", - { class: "row-title" }, - tab.name, + { class: "row-title tagged" }, + h("span", { class: "row-name" }, name), // The tab in front on the desktop: where you were. current && h("span", { class: "tag current" }, "Current"), tab.hibernated && h("span", { class: "tag" }, "Asleep"), + pane.stopped && h("span", { class: "tag" }, "Not running"), ), sub.length > 0 && h("span", { class: "row-sub" }, ...sub), ), - agent && agent.status !== "idle" && agent.status !== "done" && h("span", { class: `status-dot ${agent.status}` }), + agent && !pane.stopped && agent.status !== "idle" && agent.status !== "done" && h("span", { class: `status-dot ${agent.status}` }), ico("chevron", "icon row-chevron"), ); + rowActions(row, () => { + const actions: { label: string; icon: keyof typeof icon; danger?: boolean; run: () => void }[] = [ + { label: "Open", icon: "terminal", run: () => terminalScreen(host, place, pane, name, tabRef(ws, tab, name)) }, + { + label: "Open at phone size", + icon: "phone", + run: () => { + remember(`take.${host.id}.${place?.key ?? ""}.${pane.id}`, "1"); + terminalScreen(host, place, pane, name, tabRef(ws, tab, name)); + }, + }, + ]; + const cwd = pane.cwd; + if (cwd) { + actions.push( + { label: "Changes", icon: "compose", run: () => changesSheet(host, place, cwd) }, + { + label: "New tab here", + icon: "plus", + run: async () => { + try { + const made = await api.tabNew(host.id, place?.key ?? null, ws.id, cwd, phoneGrid()); + const title = baseName(cwd); + terminalScreen(host, place, { id: made.pane_id, title, cwd }, title, { workspace: ws.id, id: made.tab_id, name: title, busy: false }, undefined, true); + } catch (e) { + closeFailed(name, sentence(errorText(e))); + } + }, + }, + { + label: "Copy folder path", + icon: "copy", + run: () => void navigator.clipboard?.writeText(cwd).then(() => feel("tick"), () => {}), + }, + ); + } + actions.push({ + label: "Close tab", + icon: "close", + danger: true, + run: () => void closeTab(host, place, tabRef(ws, tab, name), (message) => closeFailed(name, message)), + }); + return { title: name, actions }; + }); + // A split tab's panes each have a row; closing is the tab's, on its first. + if (pane.id !== tab.panes[0]?.id) return row; + return swipeable(row, "Close", `Close ${name}`, () => closeTab(host, place, tabRef(ws, tab, name), (message) => closeFailed(name, message))); +} + +/** Holding a row brings up what can be done with it, as a list's rows do + * on the phone; the tap the press would end in is swallowed. */ +function rowActions( + row: HTMLElement, + menu: () => { title: string; actions: { label: string; icon: keyof typeof icon; danger?: boolean; run: () => void }[] }, +) { + let timer = 0; + let start: { x: number; y: number } | null = null; + let held = false; + const cancel = () => { + clearTimeout(timer); + start = null; + }; + row.addEventListener( + "touchstart", + (e) => { + if (e.touches.length !== 1) return cancel(); + held = false; + start = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + timer = window.setTimeout(() => { + start = null; + held = true; + feel("key"); + const { title, actions } = menu(); + const { remove } = openSheet( + title, + h( + "div", + { class: "sheet-body" }, + h( + "div", + { class: "card" }, + ...actions.map((a) => + h( + "button", + { + class: "row choice", + onclick: () => { + remove(); + a.run(); + }, + }, + h("span", { class: a.danger ? "row-title danger" : "row-title" }, a.label), + ico(a.icon, a.danger ? "icon row-icon danger" : "icon row-icon"), + ), + ), + ), + ), + ); + }, 480); + }, + { passive: true }, + ); + row.addEventListener( + "touchmove", + (e) => { + if (!start) return; + const t = e.touches[0]; + if (Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) cancel(); + }, + { passive: true }, + ); + row.addEventListener("touchend", cancel); + row.addEventListener("touchcancel", cancel); + row.addEventListener( + "click", + (e) => { + if (!held) return; + held = false; + e.stopImmediatePropagation(); + e.preventDefault(); + }, + { capture: true }, + ); + // No text callout or link preview of its own over the menu. + row.addEventListener("contextmenu", (e) => e.preventDefault()); +} + +/** A close that did not go through, said in a sheet: the row it came from + * has no room for it. */ +function closeFailed(name: string, message: string) { + openSheet(`Couldn't close ${name}`, h("div", { class: "sheet-body" }, h("p", { class: "sheet-text" }, message))); } /** A pane's avatar, as the desktop's tab strip draws it: the agent's mark on @@ -1791,6 +2295,18 @@ function avatar(agent: AgentView | null | undefined, cls = "avatar") { return el; } +/** A path's last segment: a directory's own name. */ +function baseName(path: string) { + return path.split("/").filter(Boolean).pop() ?? path; +} + +/** Where a directory is: the path up to its own name, its far end kept. */ +function parentPath(path: string) { + const parts = path.split("/").filter(Boolean); + if (parts.length <= 1) return ""; + return shortPath(`${path.startsWith("/") ? "/" : ""}${parts.slice(0, -1).join("/")}`); +} + /** The last two segments of a path: the part that tells panes apart. */ function shortPath(path: string | null | undefined) { if (!path) return ""; @@ -1834,11 +2350,17 @@ interface MenuItem { /** A trailing ⋯ button with a small menu that drops from it. Items can be * given as a function, to be read afresh each time it opens. */ +/** When a menu was last put away by a press outside it: that press is the + * menu's, not whatever lies under it. */ +let menuDismissedAt = 0; + function menuButton(items: MenuItem[] | (() => MenuItem[]), cls = "nav-icon") { const wrap = h("div", { class: "menu-wrap" }); const list = h("div", { class: "menu", role: "menu", hidden: true }); const outside = (e: Event) => { - if (!wrap.contains(e.target as Node)) close(); + if (wrap.contains(e.target as Node)) return; + menuDismissedAt = performance.now(); + close(); }; const close = () => { list.hidden = true; @@ -2037,7 +2559,9 @@ const drafts = new Map(); /** `run` is typed into the pane, then Enter, once it is first live: the * agent a new tab was opened for. */ -function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, run?: string) { +/** `made` is a tab this phone just opened: it runs at the phone's size from + * the start, since nobody is reading it anywhere else yet. */ +function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, tab?: TabRef, run?: string, made = false) { go("push", () => { // What the pane is doing and whether keystrokes will land, in words: the // one line under the title. @@ -2070,6 +2594,27 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, icon: "phone" as const, run: toggleTake, }, + ...(tab + ? [ + { + label: "Close tab", + icon: "close" as const, + danger: true, + run: async () => { + // Asked here whatever runs in it: this is the screen it is + // closed from, not a list it can be seen to leave. + const busy = tab.busy || agentWaiting || paneAgentWorking; + if ( + !busy && + !(await confirmSheet(`Close ${tab.name}?`, "You can reopen it from tty7 on your computer.", "Close tab")) + ) + return; + if (await closeTab(host, place, { ...tab, busy }, (message) => showBanner(message))) + hostScreen(host); + }, + }, + ] + : []), ], "round", ); @@ -2078,7 +2623,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const bar = h( "header", { class: "term-nav" }, - h("button", { class: "round", ariaLabel: `Back to ${host.name}`, onclick: back }, ico("back")), + h("button", { class: "round term-back", ariaLabel: `Back to ${host.name}`, onclick: back }, ico("back")), h("div", { class: "term-titles" }, h("span", { class: "term-title" }, title), sub), menu, ); @@ -2089,10 +2634,27 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // way — handles, magnifier, Copy. const copyText = h("pre", { class: "term-copy-text" }); const copyDone = h("button", { class: "button tinted small" }, "Done"); + // The whole pane in one tap, for pasting into a note or a message. + const copyAll = h("button", { class: "button tinted small" }, "Copy all"); + copyAll.onclick = () => { + navigator.clipboard?.writeText(copyText.textContent ?? "").then( + () => { + feel("tick"); + copyAll.textContent = "Copied"; + setTimeout(() => (copyAll.textContent = "Copy all"), 1500); + }, + () => {}, + ); + }; const copyView = h( "div", { class: "term-copy", hidden: true }, - h("div", { class: "term-copy-bar" }, h("span", {}, "Select text to copy"), copyDone), + h( + "div", + { class: "term-copy-bar" }, + h("span", {}, "Select text to copy"), + h("div", { class: "term-copy-actions" }, copyAll, copyDone), + ), copyText, ); // Find in the pane's scrollback: the match is selected and scrolled to. @@ -2129,10 +2691,19 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, class: "compose-input", rows: 1, value: drafts.get(draftKey) ?? "", - placeholder: pane.agent ? `Message ${agentLook(pane.agent.kind).name}…` : "Type a command…", enterKeyHint: "send", ariaLabel: "Message", }); + // Prose for an agent, so the keyboard helps as it does in a chat: + // capitals, corrections. A command for a shell, where it only gets in + // the way: `ls` must not become `Ls`. + const writeFor = (agent: AgentView | null | undefined) => { + field.placeholder = agent ? `Message ${agentLook(agent.kind).name}…` : "Type a command…"; + field.autocapitalize = agent ? "sentences" : "off"; + field.spellcheck = !!agent; + field.setAttribute("autocorrect", agent ? "on" : "off"); + }; + writeFor(pane.agent); const sendKey = h("button", { class: "round send", ariaLabel: "Send" }, ico("send")); // Typing straight into the terminal, key by key, for what a message box // cannot do: a full-screen program, a password prompt. @@ -2145,11 +2716,43 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const attachKey = h("button", { class: "round", ariaLabel: "Attach a photo or file" }, ico("attach")); const picker = h("input", { type: "file", multiple: true, hidden: true }); const compose = h("div", { class: "compose" }, attachKey, historyKey, field, sendKey, keyboard, picker); + // Files sent to the machine, waiting to go with the message: shown by + // name and picture, their paths written in only when it is sent. + const attached: { path: string; name: string; thumb: string | null }[] = []; + const chips = h("div", { class: "attach-chips", hidden: true }); + const drawChips = () => { + chips.replaceChildren( + ...attached.map((a, i) => { + const drop = h("button", { class: "attach-drop", ariaLabel: `Remove ${a.name}` }, ico("close")); + drop.onpointerdown = (e) => e.preventDefault(); + drop.onclick = () => { + if (a.thumb) URL.revokeObjectURL(a.thumb); + attached.splice(i, 1); + drawChips(); + edited(); + }; + return h( + "div", + { class: "attach-chip" }, + a.thumb ? h("img", { class: "attach-thumb", src: a.thumb, alt: "" }) : h("span", { class: "attach-thumb" }, ico("attach")), + h("span", { class: "attach-name" }, a.name), + drop, + ); + }), + ); + chips.hidden = attached.length === 0; + }; + const clearAttached = () => { + for (const a of attached) if (a.thumb) URL.revokeObjectURL(a.thumb); + attached.length = 0; + drawChips(); + }; const suggest = h("div", { class: "suggest", hidden: true }); // An agent's numbered choices — a permission to grant, a question — as // buttons over the key row while it waits for an answer. const answers = h("div", { class: "answers", hidden: true, role: "group", ariaLabel: "Answers" }); let agentWaiting = pane.agent?.status === "waiting"; + let paneAgentWorking = pane.agent?.status === "working"; // What typing straight into the terminal goes through. xterm's own hidden // textarea is not: iOS input methods never commit into it (a pinyin // candidate stays unwritten), so this one, a plain field the keyboard @@ -2165,19 +2768,33 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, typing.setAttribute("autocorrect", "off"); typing.setAttribute("autocomplete", "off"); + // Until the pane's screen first arrives, a sign that it is on its way, + // not an empty pane; held back a moment so a quick link never flashes it. + const loading = h("div", { class: "term-loading", hidden: true }, h("span"), h("span"), h("span")); + const loadingSoon = window.setTimeout(() => (loading.hidden = false), 250); + const loaded = () => { + clearTimeout(loadingSoon); + loading.remove(); + }; + // Back in the history, the way down to what is happening now is one tap. + const latest = h("button", { class: "to-latest", ariaLabel: "Jump to the latest output", hidden: true }, ico("down")); const view = h( "div", { class: "screen term-screen" }, bar, - h("div", { class: "term-wrap" }, screenEl, typing, copyView, findBar, banner), - h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, answers, suggest), dots, compose), + h("div", { class: "term-wrap" }, screenEl, loading, typing, copyView, findBar, latest, banner), + h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, answers, suggest), dots, chips, compose), ); const term = new Terminal({ cols: 80, rows: 24, fontSize: readablePx(), - fontFamily: "Hack, Menlo, ui-monospace, monospace", + // CJK named outright: drawing into the glyph atlas, WebKit does not + // fall back past the web fonts to the system's, and Chinese, Japanese + // and Korean come out as boxes. + fontFamily: + 'Hack, "Symbols Nerd Font Mono", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Emoji", "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo", Menlo, ui-monospace, monospace', scrollback: 5000, cursorBlink: false, theme: terminalTheme(), @@ -2209,6 +2826,9 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }); const finding = (on: boolean) => { findBar.hidden = !on; + // Searching, the keys and the message box are of no use: the matches + // get their room, up to the keyboard. + view.classList.toggle("finding", on); if (on) findInput.focus(); else { search.clearDecorations(); @@ -2217,15 +2837,40 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, findCount.textContent = ""; } }; + // The find bar floats over the pane's top rows; a match scrolled to the + // top of the view would sit under it, so the view comes down a little. + const clearOfBar = () => + requestAnimationFrame(() => { + const at = term.getSelectionPosition(); + const buf = term.buffer.active; + if (!at) return; + const h = rowHeight(); + const barBottom = findBar.offsetTop + findBar.offsetHeight + 4; + const box = screenEl.querySelector(".xterm"); + const pad = + (parseFloat(getComputedStyle(screenEl).paddingTop) || 0) + (box ? parseFloat(getComputedStyle(box).marginTop) || 0 : 0); + // Where the match's row is in the pane's box, which itself scrolls + // when the keyboard leaves it short. + let top = pad + (at.start.y - buf.viewportY) * h; + if (top - screenEl.scrollTop < barBottom && buf.viewportY > 0) { + const back = Math.min(buf.viewportY, Math.ceil((barBottom - (top - screenEl.scrollTop)) / h) + 1); + term.scrollLines(-back); + top += back * h; + } + if (top - screenEl.scrollTop < barBottom) screenEl.scrollTop = Math.max(0, top - barBottom); + else if (top + h > screenEl.scrollTop + screenEl.clientHeight) screenEl.scrollTop = top + h - screenEl.clientHeight; + }); // Up is back through the scrollback, as the pane reads. const find = (back: boolean) => { if (!findInput.value) return; if (back) search.findPrevious(findInput.value, findOptions); else search.findNext(findInput.value, findOptions); + clearOfBar(); }; findInput.oninput = () => { if (findInput.value) { search.findPrevious(findInput.value, { ...findOptions, incremental: true }); + clearOfBar(); } else { search.clearDecorations(); term.clearSelection(); @@ -2274,7 +2919,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, let ended = false; // The phone's size: `wanted` is what the user asked for, `leased` what // the daemon confirmed, `sent` the grid last asked for. - let wanted = false; + // Kept per pane: one taken over last time is taken over again on return. + const takeKey = `take.${host.id}.${place?.key ?? ""}.${pane.id}`; + // A tab opened here is the phone's: it keeps the phone's size on later + // visits too, so it follows the keyboard rather than hiding under it. + if (made) remember(takeKey, "1"); + let wanted = remembered(takeKey) === "1"; let leased = false; let releasing = false; let sent = ""; @@ -2285,10 +2935,45 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // A drop is retried on its own, sooner at first; the pane stays on // screen as it was until the new stream replaces it. const retry = retrier(() => reopen()); + // A pane its server no longer has — restarted since, and not started + // again — is not coming back by retrying: said so, with a way on. + const gone = () => { + loaded(); + ended = true; + live = false; + retry.cancel(); + setState("offline", "Not running"); + // Nothing typed here would go anywhere: the box and keys say so + // rather than taking it and doing nothing. + field.blur(); + typing.blur(); + field.disabled = true; + field.placeholder = "This pane isn't running"; + view.classList.add("ended"); + const fresh = tab && { + label: "New tab here", + run: async () => { + try { + const made = await api.tabNew(host.id, place?.key ?? null, tab.workspace, paneCwd, phoneGrid()); + const name = paneCwd ? baseName(paneCwd) : "Shell"; + terminalScreen(host, place, { id: made.pane_id, title: name, cwd: paneCwd }, name, { + workspace: tab.workspace, + id: made.tab_id, + name, + busy: false, + }, undefined, true); + } catch (e) { + showBanner(sentence(errorText(e))); + } + }, + }; + showBanner(`This pane isn't running. tty7 on ${host.name} starts it again when it next opens the tab.`, fresh || undefined); + }; const offline = (message: string) => { + if (/no such pane/i.test(message)) return gone(); live = false; setState("connecting", "Reconnecting"); - showBanner(`${sentence(message)} Reconnecting…`, { label: "Try now", run: reopen }); + showBanner(`${why(message, host.name)} Reconnecting…`, { label: "Try now", run: reopen }); retry.schedule(); }; // Only the first refusal speaks: a key typed just before it fails on its @@ -2348,6 +3033,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // so the soft keyboard stays as it is. key.onpointerdown = (e) => e.preventDefault(); key.onclick = () => { + feel("key"); if (k.latch) { ctrl = !ctrl; key.classList.toggle("on", ctrl); @@ -2376,9 +3062,10 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (field.value) drafts.set(draftKey, field.value); else drafts.delete(draftKey); // Written, the round button sends; empty, it is the keyboard's. - sendKey.hidden = !field.value; - keyboard.hidden = !!field.value; - historyKey.hidden = !!field.value; + const ready = !!field.value || attached.length > 0; + sendKey.hidden = !ready; + keyboard.hidden = ready; + historyKey.hidden = ready; grow(); offer(); }; @@ -2401,6 +3088,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, dots.classList.toggle("covered", found.length > 0 || !answers.hidden); }; field.addEventListener("focus", offer); + // Typing goes where the cursor is: a view panned away comes back to it. + field.addEventListener("focus", () => follow()); field.addEventListener("blur", offer); historyKey.onpointerdown = (e) => e.preventDefault(); @@ -2469,13 +3158,16 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (!files.length) return; attachKey.classList.add("busy"); attachKey.disabled = true; - const paths: string[] = []; + let added = 0; try { for (const file of files) { if (file.size > MAX_UPLOAD) { throw new Error(`${file.name} is ${Math.ceil(file.size / 2 ** 20)} MB; files up to ${MAX_UPLOAD / 2 ** 20} MB can be sent`); } - paths.push(await api.upload(host.id, null, file.name, new Uint8Array(await file.arrayBuffer()))); + const path = await api.upload(host.id, null, file.name, new Uint8Array(await file.arrayBuffer())); + const thumb = file.type.startsWith("image/") ? URL.createObjectURL(file) : null; + attached.push({ path, name: baseName(path), thumb }); + added++; } } catch (e) { showBanner(`Couldn't send the file: ${sentence(errorText(e))}`, { label: "OK", run: clearBanner }); @@ -2483,11 +3175,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, attachKey.classList.remove("busy"); attachKey.disabled = false; } - if (!paths.length) return; - // Its own word, before and after, so it reads as a path whatever is - // written around it. - const before = field.value && !/\s$/.test(field.value) ? `${field.value} ` : field.value; - field.value = `${before}${paths.join(" ")} `; + if (!added) return; + drawChips(); edited(); field.focus({ preventScroll: true }); }; @@ -2500,7 +3189,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, return /pass(word|phrase)|\bpin\b|密码|口令/i.test(line); }; const submit = async () => { - const body = field.value.replace(/\r?\n/g, "\r"); + // What was written, then the files, each its own word — quoted where + // a name has a space — so a shell takes them as a command's arguments + // and an agent as what the message is about. + const paths = attached.map((a) => (/\s/.test(a.path) ? `'${a.path.replace(/'/g, "'\\''")}'` : a.path)); + const text = [field.value.trimEnd(), ...paths].filter(Boolean).join(" "); + const body = text.replace(/\r?\n/g, "\r"); // Several lines go in as one paste where the program asked for that, // so an agent takes them as one message and a shell does not run each. const data = body.includes("\r") && term.modes.bracketedPasteMode ? `\x1b[200~${body}\x1b[201~` : body; @@ -2509,18 +3203,44 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // how the bytes arrive would otherwise take it as part of the text. // An empty box sends Enter alone. if (!(await input("\r"))) return; - if (!answersSecret()) keepSent(field.value); + if (!answersSecret() && field.value) keepSent(field.value); field.value = ""; + clearAttached(); edited(); }; + // Return sends; Shift-Return starts a line, on a keyboard with a real + // Shift. The phone's own keyboard says Shift whenever it has armed a + // capital — at the start, after a full stop — so there it is ignored. + const hardKeys = matchMedia("(any-pointer: fine)"); + let newLine = false; field.addEventListener("keydown", (e) => { + const shifted = e.shiftKey && hardKeys.matches; + newLine = e.key === "Enter" && shifted; // Enter that confirms an IME's candidate is the IME's, not a send. - if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.keyCode === 229) return; + if (e.key !== "Enter" || shifted || e.isComposing || e.keyCode === 229) return; e.preventDefault(); void submit(); }); + // A Return that comes as text with no key behind it sends too. The + // on-screen keyboard delivers it that way — as a line break, or, with + // corrections on, as a typed newline once it has settled the word — + // and not always cancellably, so the newline is taken back out. + field.addEventListener("input", (e) => { + const wasShift = newLine; + newLine = false; + const typed = e as InputEvent; + const newline = + typed.inputType === "insertLineBreak" || typed.inputType === "insertParagraph" || typed.data === "\n"; + if (!newline || typed.isComposing || wasShift) return; + const at = field.selectionStart; + if (field.value[at - 1] === "\n") { + field.value = field.value.slice(0, at - 1) + field.value.slice(at); + edited(); + } + void submit(); + }); sendKey.onpointerdown = (e) => e.preventDefault(); - sendKey.onclick = () => void submit(); + sendKey.onclick = () => (feel("key"), void submit()); keyboard.onpointerdown = (e) => e.preventDefault(); keyboard.onclick = () => { @@ -2589,6 +3309,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, } const fitted = fittedSize(); cramped = fitted < readablePx(); + if (cramped) hintPhoneSize(); term.options.fontSize = cramped && readable ? readablePx() : Math.max(4, fitted); screenEl.classList.toggle("panning", cramped && readable); follow(); @@ -2733,7 +3454,18 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, let ownScroll = false; term.onScroll(() => { if (!ownScroll && frac) setFrac(0); + const buf = term.buffer.active; + latest.hidden = buf.baseY - buf.viewportY < 3; }); + latest.onpointerdown = (e) => e.preventDefault(); + latest.onclick = () => { + shield(); + feel("tick"); + setFrac(0); + term.scrollToBottom(); + latest.hidden = true; + requestAnimationFrame(showCursor); + }; // Moves the view by a distance in pixels. Dragging down goes back in the // scrollback. const scrollBy = (dy: number) => { @@ -2815,10 +3547,15 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // focus to its own textarea. if (lifted && !lifted.axis && e.cancelable) { e.preventDefault(); + // A tap that closed an open menu does only that. + if (performance.now() - menuDismissedAt < 500) return; // A link under the finger opens in the browser instead. const at = e.changedTouches[0]; const url = at && linkAt(at.clientX, at.clientY); if (url) void openUrl(url).catch(() => {}); + // Writing a message, a tap above it puts the keyboard away, as + // tapping outside a field does anywhere on the phone. + else if (document.activeElement === field) field.blur(); else typing.focus({ preventScroll: true }); return; } @@ -2891,6 +3628,27 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (x < screenEl.scrollLeft + cell * 4 || x > screenEl.scrollLeft + view - cell * 4) screenEl.scrollLeft = Math.max(0, x - view / 2); }; + // The first few panes too wide to read whole say once that the pane can + // run at the phone's size: otherwise only the ⋯ menu knows. + let hinted = false; + const hintPhoneSize = () => { + const seen = Number(remembered("hint.phoneSize") ?? 0); + if (hinted || wanted || leased || seen >= 3 || !live || banner.hasChildNodes()) return; + hinted = true; + remember("hint.phoneSize", String(seen + 1)); + showBanner("Wider than this phone.", { + label: "Use phone size", + run: () => { + remember("hint.phoneSize", "3"); + banner.replaceChildren(); + toggleTake(); + }, + }); + const shown = banner.firstChild; + setTimeout(() => { + if (banner.firstChild === shown) banner.replaceChildren(); + }, 8000); + }; const toggleZoom = () => { readable = !readable; fit(); @@ -2927,6 +3685,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const liveLabel = () => (leased ? "Live · phone size" : "Live"); const toggleTake = () => { wanted = !wanted; + remember(takeKey, wanted ? "1" : ""); if (wanted) { sent = ""; askLease(); @@ -2953,6 +3712,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, releasing = false; if (refused) { wanted = false; + remember(takeKey, ""); showBanner(sentence(refused)); } else if (was && !ours) { // Nobody here let go: the desktop took it back, or another device @@ -3035,12 +3795,49 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // Codex draw them ("❯ 1. Yes", "2. No, and tell Claude…"), box edges and // the cursor mark stripped. const readChoices = () => { - const found: { n: string; label: string }[] = []; - if (agentWaiting) { + // `n` is typed to pick it; `keys`, where given, is sent instead. + const found: { n: string; label: string; keys?: string }[] = []; + // A menu picked with the arrows, no numbers — an agent's "trust this + // folder?" — said by the hint under it. Its options are the lines + // above, at the selected one's indent; picking one moves to it and + // presses Enter. + { + const buf = term.buffer.active; + const rows: string[] = []; + for (let y = buf.baseY; y < buf.baseY + term.rows; y++) rows.push(buf.getLine(y)?.translateToString(true) ?? ""); + let hint = -1; + for (let y = rows.length - 1; y >= 0 && hint < 0; y--) if (/Enter to (confirm|select)/i.test(rows[y])) hint = y; + if (hint > 0) { + let y = hint - 1; + while (y >= 0 && !rows[y].trim()) y--; + const block: string[] = []; + for (; y >= 0 && rows[y].trim(); y--) block.unshift(rows[y]); + const sel = block.findIndex((l) => /^\s*❯\s/.test(l)); + if (sel >= 0 && !/^\s*❯\s+\d{1,2}[.)]\s/.test(block[sel])) { + const col = block[sel].indexOf("❯"); + const options = block + .map((l, i) => ({ l, i })) + .filter(({ l, i }) => i === sel || (l.slice(0, col + 2).trim() === "" && l[col + 2] !== " ")); + const at = options.findIndex((o) => o.i === sel); + if (options.length >= 2 && options.length <= 9) + options.forEach((o, k) => { + const step = k > at ? "\x1b[B" : "\x1b[A"; + found.push({ + n: String(k + 1), + label: o.l.replace(/^\s*❯?\s*/, ""), + keys: step.repeat(Math.abs(k - at)) + "\r", + }); + }); + } + } + } + if (agentWaiting && !found.length) { + // The whole screen: the terminal here can be taller than the pane, + // with the agent's menu well above its bottom rows. const buf = term.buffer.active; const bottom = buf.baseY + term.rows; let run: typeof found = []; - for (let y = Math.max(0, bottom - 24); y < bottom; y++) { + for (let y = buf.baseY; y < bottom; y++) { const text = (buf.getLine(y)?.translateToString(true) ?? "") .replace(/^[\s│┃|]+|[\s│┃|]+$/g, "") .replace(/^[❯›>▸●◉○]\s*/, ""); @@ -3059,7 +3856,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, ...found.map((c) => { const b = h("button", { class: "answer" }, h("b", {}, c.n), h("span", {}, c.label)); b.onpointerdown = (e) => e.preventDefault(); - b.onclick = () => send(c.n); + b.onclick = () => (feel("key"), send(c.keys ?? c.n)); return b; }), ); @@ -3070,19 +3867,39 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }; let choicesFrame = 0; term.onWriteParsed(() => { - if (!agentWaiting || choicesFrame) return; + if (choicesFrame) return; choicesFrame = requestAnimationFrame(() => { choicesFrame = 0; readChoices(); }); }); + // A button over the pane that takes itself away when tapped leaves the + // tap to land again on the pane beneath, which brings up its keyboard; + // a clear cover takes that tap instead, for a moment. + const shield = () => { + const cover = h("div", { class: "term-shield" }); + view.querySelector(".term-wrap")?.append(cover); + window.setTimeout(() => cover.remove(), 400); + }; const showBanner = (text: string, action?: { label: string; run: () => void }) => banner.replaceChildren( h( "div", { class: "term-banner" }, h("span", {}, text), - action && h("button", { class: "button tinted small", onclick: action.run }, action.label), + action && + h( + "button", + { + class: "button tinted small", + onpointerdown: (e: Event) => e.preventDefault(), + onclick: () => { + shield(); + action.run(); + }, + }, + action.label, + ), ), ); @@ -3098,6 +3915,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const replay = () => { if (replayed) return; replayed = true; + loaded(); term.reset(); }; live = false; @@ -3112,6 +3930,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (!current()) return; replay(); term.write(bytes); + if (run) runSoon(); }, (event) => { if (!current()) return; @@ -3125,10 +3944,9 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, break; case "agent": agentWaiting = event.agent?.status === "waiting"; + paneAgentWorking = event.agent?.status === "working"; readChoices(); - field.placeholder = event.agent - ? `Message ${agentLook(event.agent.kind).name}…` - : "Type a command…"; + writeFor(event.agent); break; case "cwd": paneCwd = event.path; @@ -3167,16 +3985,32 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, retry.reset(); banner.replaceChildren(); setState("live", liveLabel()); - // A new tab's agent, started once: not again on a reconnect. - if (run) { - const command = run; - run = undefined; - void input(`${command}\r`); - } + if (cramped) hintPhoneSize(); + runSoon(); } catch (e) { if (current()) offline(errorText(e)); } }; + // A new tab's agent is typed in once its shell has started: a key sent + // while the shell is still printing its greeting is eaten, and the + // command would sit at the prompt unrun. Started is when the output + // has gone quiet, or after a while whatever it is doing. Once: not + // again on a reconnect. + let runTimer: number | undefined; + const runBy = performance.now() + 6000; + const runSoon = () => { + if (!run) return; + clearTimeout(runTimer); + runTimer = window.setTimeout( + () => { + if (!run || !live || !alive) return; + const command = run; + run = undefined; + void input(`${command}\r`); + }, + performance.now() > runBy ? 0 : 700, + ); + }; const reopen = () => { if (handle !== null) api.paneClose(handle); handle = null; @@ -3187,9 +4021,97 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }; window.addEventListener("online", online); + // The machine's other agents, heard while this pane is open: one that + // stops for an answer or finishes its turn says so over the pane, with + // a way there. Only changes count; what was already so when the pane + // opened is old news. + const peek = h("div", { class: "peek-slot" }); + view.querySelector(".term-wrap")?.append(peek); + let others = new Map(); + let heard = false; + let peekTimer: number | undefined; + // The pane the card on screen is about, so it goes once that is settled. + let peekKey: string | null = null; + const ownKey = `${place?.key ?? ""}/${pane.id}`; + const dropPeek = () => { + clearTimeout(peekTimer); + peekKey = null; + peek.replaceChildren(); + }; + const showPeek = (key: string, where: Place, ws: WorkspaceView, tab: TabView, other: PaneView) => { + const agent = other.agent!; + const name = /^[~/]/.test(tab.name) ? baseName(tab.name) : tab.name; + const close = h("button", { class: "peek-close", ariaLabel: "Dismiss" }, ico("close")); + close.onpointerdown = (e) => e.preventDefault(); + // Faded, then gone: taken away under the finger at once, the tap + // lands again on the pane beneath and brings up its keyboard. + close.onclick = (e) => { + e.stopPropagation(); + card.classList.add("leaving"); + peekKey = null; + clearTimeout(peekTimer); + peekTimer = window.setTimeout(dropPeek, 400); + }; + const card = h( + "div", + { + class: `peek ${agent.status}`, + role: "button", + onclick: () => terminalScreen(host, where, other, name, tabRef(ws, tab, name)), + }, + avatar(agent), + h( + "span", + { class: "peek-text" }, + h("span", { class: "peek-title" }, agent.status === "waiting" ? `${agentLook(agent.kind).name} needs you` : `${agentLook(agent.kind).name} is done`), + h("span", { class: "peek-sub" }, agent.message || name), + ), + close, + ); + peek.replaceChildren(card); + peekKey = key; + feel("tick"); + clearTimeout(peekTimer); + // One waiting stays until it is answered or put away; one that is + // done is news for a moment. + if (agent.status !== "waiting") peekTimer = window.setTimeout(dropPeek, 15_000); + }; + // While another agent waits, the way back carries a dot, card or no card. + const backButton = view.querySelector(".term-back"); + let peeking: number | null = null; + api + .watch(host.id, (msg) => { + if (msg.type !== "tree" || !alive) return; + const now = new Map(); + const spaces: [Place, WorkspaceView][] = [ + ...msg.tree.workspaces.map((ws): [Place, WorkspaceView] => [null, ws]), + ...(msg.tree.remotes ?? []).flatMap((r) => r.workspaces.map((ws): [Place, WorkspaceView] => [{ key: r.key, name: r.name }, ws])), + ]; + for (const [where, ws] of spaces) + for (const tab of ws.tabs) + for (const other of tab.panes) { + if (!other.agent) continue; + const key = `${where?.key ?? ""}/${other.id}`; + now.set(key, other.agent.status); + const was = others.get(key); + const news = other.agent.status === "waiting" || other.agent.status === "done"; + if (heard && key !== ownKey && news && was !== other.agent.status) showPeek(key, where, ws, tab, other); + } + if (peekKey && now.get(peekKey) === "working") dropPeek(); + backButton?.classList.toggle("attention", [...now].some(([key, status]) => key !== ownKey && status === "waiting")); + others = now; + heard = true; + }) + .then( + (id) => (alive ? (peeking = id) : void api.unwatch(id).catch(() => {})), + () => {}, + ); + onLeave = () => { alive = false; retry.cancel(); + clearTimeout(peekTimer); + if (peeking !== null) api.unwatch(peeking).catch(() => {}); window.removeEventListener("online", online); window.removeEventListener("resize", fit); window.removeEventListener("resize", regridSoon); @@ -3204,9 +4126,16 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, onResume = reopen; // Hack has to be loaded before xterm measures a cell, or the first fit is - // taken with the fallback face's metrics. + // taken with the fallback face's metrics; the symbols before a glyph is + // drawn, or the GPU's glyph cache keeps the empty box it got instead. requestAnimationFrame(() => { - document.fonts.load("12px Hack").finally(() => { + Promise.allSettled([ + document.fonts.load("12px Hack"), + document.fonts.load('12px "Symbols Nerd Font Mono"', "\ue0a0"), + document.fonts.load('12px "Noto Sans Symbols"', "\u23bf"), + document.fonts.load('12px "Noto Sans Symbols 2"', "\u23fa"), + document.fonts.load('12px "Noto Emoji"', "\u23f0"), + ]).finally(() => { if (!alive) return; term.open(screenEl); // Drawn on the GPU: the DOM renderer lays every row out again on each @@ -3229,4 +4158,37 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, } if (prefs.lock) lock(); -hostsScreen("push"); + +// A pairing code opened as a link — the desktop's QR code, read by the +// phone's camera — goes straight to pairing. The link is taken once, from +// the native side, which holds it until asked: one that launched the app +// can arrive before the page, or between the page's first look and its +// first screen. +let started = false; +const takeLink = () => + api.openedLink().then( + (code) => code && pairScreen(code), + () => {}, + ); +const listening = listen("opened-link", () => started && void takeLink()).catch(() => {}); + +// Back where it was left: the machine last open, unless it was left for the +// list of machines; or pairing, when that is what opened the app. +void listening.then(() => + Promise.all([api.hosts(), api.openedLink().catch(() => null)]).then( + ([hosts, code]) => { + const last = hosts.find((host) => host.id === remembered("last.host")); + if (code) { + hostsScreen("push"); + pairScreen(code); + } else if (last) hostScreen(last, "push"); + else hostsScreen("push"); + started = true; + void takeLink(); + }, + () => { + hostsScreen("push"); + started = true; + }, + ), +); diff --git a/mobile/src/style.css b/mobile/src/style.css index 8e9e5f48..58b7c525 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -16,6 +16,38 @@ font-display: block; } +/* The icons a prompt draws (Powerline arrows, a branch, a folder): Nerd + Fonts' symbols alone, one cell wide, for the code points Hack lacks. A + phone has no fonts of its own to fall back on. */ +@font-face { + font-family: "Symbols Nerd Font Mono"; + src: url("./assets/fonts/SymbolsNerdFontMono.woff2") format("woff2"); + font-display: block; +} + +/* What agents and TUIs draw their marks with — Claude Code's ⏺ and ⎿, ✔, + ⚠, braille spinners — which the phone's own fonts leave as empty boxes + in a canvas. Noto's symbols, cut down to those blocks. */ +@font-face { + font-family: "Noto Sans Symbols"; + src: url("./assets/fonts/NotoSansSymbols-subset.woff2") format("woff2"); + font-display: block; +} + +@font-face { + font-family: "Noto Sans Symbols 2"; + src: url("./assets/fonts/NotoSansSymbols2-subset.woff2") format("woff2"); + font-display: block; +} + +/* The few of those that are emoji by default (⏰ ✅ ❌ ✨), in outline, as + text: a canvas leaves them as boxes rather than reach for colour ones. */ +@font-face { + font-family: "Noto Emoji"; + src: url("./assets/fonts/NotoEmoji-subset.woff2") format("woff2"); + font-display: block; +} + :root { --canvas: #f3f3f1; --cell: #fcfcfb; @@ -34,6 +66,8 @@ --accent-ink: #fcfcfb; --tint: var(--fill); --danger: #c43c43; + /* A filled control's red: the system's own. */ + --danger-fill: #ff3b30; --warn-ink: #b7791f; --ok-ink: #248a3d; --term: var(--cell); @@ -50,7 +84,7 @@ --done: #22c55e; --ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", Roboto, "Segoe UI", system-ui, sans-serif; - --mono: Hack, "SF Mono", Menlo, ui-monospace, monospace; + --mono: Hack, "Symbols Nerd Font Mono", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Emoji", "SF Mono", "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo", Menlo, ui-monospace, monospace; --ease: cubic-bezier(0.32, 0.72, 0, 1); /* The home indicator's gap; none while the keyboard is up, which covers @@ -90,6 +124,7 @@ --line: rgb(255 255 255 / 0.16); --accent-ink: #18181a; --danger: #f07878; + --danger-fill: #ff453a; --warn-ink: #e0a345; --ok-ink: #5fd07a; --bar: rgb(30 30 32 / 0.82); @@ -252,6 +287,12 @@ code { transform: none; } +/* Until it folds in, the title takes no room, so the way back is named in + full; once it shows, the back pill gives way to it. */ +.nav:not(.folded) .nav-title { + max-width: 0; +} + /* Back is a floating pill that names where it goes. */ .nav-back { display: flex; @@ -278,6 +319,18 @@ code { height: 20px; } +/* Once the title shows, the pill would be cut to "Machin…" beside it: it + gives way to the round chevron alone, as the system's own bar does. */ +.nav.folded .nav-back { + width: 38px; + padding: 0; + justify-content: center; +} + +.nav.folded .nav-back span:last-child { + display: none; +} + .nav-back:active, .nav-icon:active, .round:active { @@ -644,7 +697,10 @@ code { opacity: 0.55; } -.row + .row::before { +.row + .row::before, +.swipe + .swipe > .row::before, +.swipe + .row::before, +.row + .swipe > .row::before { content: ""; position: absolute; top: 0; @@ -947,7 +1003,7 @@ code { } .button.danger { - background: var(--danger); + background: var(--danger-fill); color: #fff; } @@ -1422,6 +1478,11 @@ html.scanning #app { color: var(--ink-2); } +.term-copy-actions { + display: flex; + gap: 8px; +} + .term-copy-text { flex: 1; min-height: 0; @@ -1431,10 +1492,9 @@ html.scanning #app { overscroll-behavior: contain; font: 12.5px/1.45 var(--mono); color: var(--ink); - /* Wrapped to the phone, not the desktop's width, so nothing needs panning - to select. */ - white-space: pre-wrap; - overflow-wrap: anywhere; + /* Laid out as the pane has it: rewrapping to the phone breaks every + table, column and indented block into a jumble. A wide line pans. */ + white-space: pre; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; @@ -1471,6 +1531,16 @@ html.scanning #app { /* The dock under the terminal: a row of keys a page at a time, then the message box and the keyboard. */ +.term-screen.finding .term-dock { + display: none; +} + +/* Room above the first row for the find bar, so a match there can be + scrolled out from under it. */ +.term-screen.finding .term .xterm { + margin-top: 60px; +} + .term-dock { flex: none; display: flex; @@ -1626,9 +1696,28 @@ html.scanning #app { bottom: calc(12px + var(--safe-bottom)); display: flex; align-items: center; + justify-content: flex-end; gap: 10px; } +.search-clear { + flex: none; + display: grid; + place-items: center; + width: 22px; + height: 22px; + margin-right: -4px; + border: 0; + border-radius: 11px; + background: var(--ink-3, #8e8e93); + color: var(--canvas); +} + +.search .search-clear .icon { + width: 12px; + height: 12px; +} + /* The list softens into the canvas as it runs under the floating bar, as the system's own bars do, so no row shows through sharp and cut in half. */ .screen:has(> .float-bar)::after { @@ -2258,6 +2347,25 @@ html[data-nav="pop"]::view-transition-new(root) { gap: 10px; } +/* Each block keeps its height; the sheet scrolls rather than squeezing a + file to nothing under a long list of untracked ones. */ +.diff-body > * { + flex-shrink: 0; +} + +.diff-body > .group { + margin: 10px 0 0; +} + +.diff-body .row { + min-height: 0; + padding: 9px 14px; +} + +.diff-body .row-title { + font-size: 13px; +} + .diff-root { margin: 0 4px; font: 13px var(--mono); @@ -2338,3 +2446,394 @@ html[data-nav="pop"]::view-transition-new(root) { color: var(--ink-2); background: var(--fill); } + +/* A row that slides to show an action behind it. */ +.swipe { + position: relative; + overflow: hidden; + transition: + max-height 0.25s var(--ease), + opacity 0.2s; + max-height: 160px; +} + +.swipe > .row { + background: var(--cell); + transition: transform 0.25s var(--ease); +} + +.swipe.dragging > .row { + transition: none; +} + +.swipe-action { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 88px; + border: 0; + background: var(--danger-fill); + color: #fff; + font: inherit; + font-size: 15px; + font-weight: 600; +} + +.swipe.gone { + max-height: 0; + opacity: 0; +} + +/* A phone on its side has height to spare for nothing but the pane: the bar + slims, and the keys and the message box share one row. */ +@media (orientation: landscape) and (max-height: 500px) { + .term-nav { + height: calc(44px + var(--inset-top)); + } + + .term-dock { + flex-direction: row; + align-items: center; + gap: 10px; + padding-top: 6px; + padding-bottom: calc(6px + var(--safe-bottom)); + } + + .term-dock > .key-slot { + flex: 1.3; + min-width: 0; + } + + .term-dock > .compose { + flex: 1; + min-width: 0; + } + + .term-dock > .key-dots { + display: none; + } + + .key { + height: 38px; + } + + .compose-input { + min-height: 38px; + padding-top: 8px; + padding-bottom: 8px; + } +} + +/* A hint is one line; it never makes the box taller than what is typed. */ +.compose-input::placeholder { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A control's mark, named in running text. */ +.inline-icon { + width: 1.15em; + height: 1.15em; + vertical-align: -0.2em; +} + +/* Another agent on the machine wants a look: a card that drops over the + top of the pane, tapped to go there. */ +.peek-slot { + position: absolute; + z-index: 6; + top: 8px; + right: max(10px, var(--inset-right)); + left: max(10px, var(--inset-left)); + pointer-events: none; +} + +.peek { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: 22px; + background: var(--bar); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + box-shadow: var(--shadow); + color: var(--ink); + font: inherit; + text-align: left; + pointer-events: auto; + animation: peek-in 0.32s var(--ease); +} + +.peek-text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.peek-title { + font-size: 15px; + font-weight: 600; +} + +.peek-sub { + overflow: hidden; + font-size: 13px; + color: var(--ink-2); + white-space: nowrap; + text-overflow: ellipsis; +} + +@keyframes peek-in { + from { + opacity: 0; + transform: translateY(-16px); + } +} + +/* The way back down from the history (main.ts `latest`). */ +.to-latest { + position: absolute; + right: max(14px, var(--inset-right)); + bottom: 12px; + z-index: 2; + width: 44px; + height: 44px; + border: 0; + border-radius: 22px; + display: grid; + place-items: center; + background: var(--raised); + color: var(--ink); + box-shadow: var(--shadow); +} + +.to-latest[hidden] { + display: none; +} + +.to-latest .icon { + width: 20px; + height: 20px; +} + +.to-latest:active { + background: var(--cell-press); +} + +/* Files waiting to go with the message (main.ts `attached`). */ +.attach-chips { + display: flex; + gap: 8px; + overflow-x: auto; + scrollbar-width: none; +} + +.attach-chips[hidden] { + display: none; +} + +.attach-chip { + flex: none; + display: flex; + align-items: center; + gap: 8px; + max-width: 220px; + height: 44px; + padding: 4px 4px 4px 4px; + border-radius: 12px; + background: var(--cell); + box-shadow: 0 0 0 0.5px var(--hair); +} + +.attach-thumb { + flex: none; + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + display: grid; + place-items: center; + background: var(--fill); + color: var(--ink-2); +} + +.attach-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--ink); +} + +.attach-drop { + flex: none; + width: 28px; + height: 28px; + border: 0; + border-radius: 14px; + display: grid; + place-items: center; + background: transparent; + color: var(--ink-2); +} + +.attach-drop .icon { + width: 14px; + height: 14px; +} + +/* A row's held-down menu (main.ts `rowActions`). */ +.row .row-title.danger, +.row .row-icon.danger { + color: var(--danger); +} + +.row .row-icon { + margin-left: auto; + width: 20px; + height: 20px; + color: var(--ink-2); +} + +.row { + -webkit-touch-callout: none; +} + +/* Three dots while a pane's screen is on its way (main.ts `loading`). */ +.term-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + pointer-events: none; +} + +.term-loading[hidden] { + display: none; +} + +.term-loading span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ink-3); + animation: loading-dot 1s ease-in-out infinite; +} + +.term-loading span:nth-child(2) { + animation-delay: 0.15s; +} + +.term-loading span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes loading-dot { + 0%, + 80%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: none; + } +} + +/* A pane that is not running: its keys and box are there but out of use. */ +.term-screen.ended .key-slot, +.term-screen.ended .compose > button { + opacity: 0.35; + pointer-events: none; +} + +.peek-close { + flex: none; + width: 30px; + height: 30px; + border: 0; + border-radius: 15px; + display: grid; + place-items: center; + background: var(--fill); + color: var(--ink-2); +} + +.peek-close .icon { + width: 14px; + height: 14px; +} + +/* Another agent is waiting: a dot on the way back to the list. */ +.term-back { + position: relative; +} + +.term-back.attention::after { + content: ""; + position: absolute; + top: 2px; + right: 2px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--waiting); + box-shadow: 0 0 0 2px var(--canvas); +} + +/* A row's name gives way before its tags do: "Current" stays whole. */ +.row-title.tagged { + display: flex; + align-items: center; +} + +.row-title.tagged .row-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-title.tagged .tag { + flex: none; +} + +.peek.leaving { + opacity: 0; + transform: translateY(-8px); + transition: + opacity 0.2s, + transform 0.2s; +} + +.term-shield { + position: absolute; + inset: 0; + z-index: 8; +} + +/* The round bar buttons are drawn at 38pt but answer a 44pt square, the + least a finger should have to hit. */ +.round, +.nav-icon, +.nav-back { + position: relative; +} + +.round::before, +.nav-icon::before, +.nav-back::before { + content: ""; + position: absolute; + inset: -3px; +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b32066f4..99198a80 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -63,7 +63,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsMobilePairDesc => "Shows a one-time code for the tty7 app on your phone.", L10nKey::SettingsMobilePairNeedsAccess => "Turn on phone access first.", L10nKey::SettingsMobilePairScan => { - "Scan this with the tty7 app on your phone, or copy the code and paste it there." + "Point your phone's camera at this, or scan it in the tty7 app, or copy the code and paste it there." } L10nKey::SettingsMobilePairValid => "Expires in {time}. Works for one phone.", L10nKey::SettingsMobileNewCode => "New code", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index bafbae68..2dbaccb3 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -71,7 +71,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "先にスマートフォンからのアクセスをオンにしてください。" } L10nKey::SettingsMobilePairScan => { - "スマートフォンの tty7 アプリでスキャンするか、コードをコピーして貼り付けてください。" + "スマートフォンのカメラを向けるか、tty7 アプリでスキャンするか、コードをコピーして貼り付けてください。" } L10nKey::SettingsMobilePairValid => { "あと {time} で失効します。1 台のスマートフォンに限ります。" diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 9e1343e8..610200a0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -58,7 +58,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsMobileShowCode => "显示配对码", L10nKey::SettingsMobilePairDesc => "生成一个一次性配对码,给手机上的 tty7 app 用。", L10nKey::SettingsMobilePairNeedsAccess => "请先打开手机访问。", - L10nKey::SettingsMobilePairScan => "用手机上的 tty7 app 扫描,或者复制配对码粘贴过去。", + L10nKey::SettingsMobilePairScan => { + "用手机相机对准它,或在 tty7 app 里扫描,或者复制配对码粘贴过去。" + } L10nKey::SettingsMobilePairValid => "{time} 后失效,只能配对一台手机。", L10nKey::SettingsMobileNewCode => "换一个配对码", L10nKey::SettingsMobilePairExpired => "这个配对码已过期。生成一个新的再配对。",