Merge branch 'main' into feat/macos-in-app-updater

This commit is contained in:
l0ng-ai
2026-08-03 14:46:21 +08:00
committed by GitHub
14 changed files with 1024 additions and 34 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ pub struct Cli {
#[arg(
value_name = "PATH",
help = "Launch or activate the GUI, at PATH if given (not wired up yet)"
help = "Launch or activate the GUI, opening a new tab at PATH if given"
)]
pub path: Option<String>,
+141 -15
View File
@@ -1,4 +1,4 @@
use anyhow::{Result, bail};
use anyhow::{Context as _, Result, bail};
use serde_json::{Value, json};
use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace};
use tty7_core::core::session::WorkspaceId;
@@ -46,14 +46,13 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
None => match cli.path {
// clap has no subcommand for this word, so it landed in [PATH].
// Treat a word that is not a path as the typo it almost certainly
// is: answering "launching the GUI is not wired up yet" to
// `tty7 statu` helps nobody.
// is: launching the GUI for `tty7 statu` would hide the typo.
Some(word) if !looks_like_a_path(&word) => bail!(
"unknown subcommand '{word}' — run `tty7 --help` for the list. \
(A path in this position would open the GUI there, but \
'{word}' does not name one.)"
),
path => launch_gui(path),
path => launch_gui(path, machine.as_deref(), backend),
},
Some(Command::Ls) | Some(Command::Ws(WsCmd::Ls)) => ws_ls(backend),
Some(Command::Ws(WsCmd::Tree { ws })) => ws_tree(ws.as_deref(), ctx, backend),
@@ -140,11 +139,84 @@ fn looks_like_a_path(s: &str) -> bool {
|| std::path::Path::new(s).exists()
}
fn launch_gui(path: Option<String>) -> Result<Outcome> {
match path {
Some(p) => bail!("launching the GUI is not wired up yet (would open {p})"),
None => bail!("launching the GUI is not wired up yet"),
fn launch_gui(
path: Option<String>,
machine: Option<&str>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
if let Some(machine) = machine {
bail!(
"`tty7 [PATH]` controls the GUI on this machine and cannot be combined with -m {machine}"
);
}
let path = path.map(resolve_gui_path).transpose()?;
let wire_path = path.as_deref().map(gui_wire_path).transpose()?;
// A live GUI receives the request through the daemon. If the daemon itself
// is absent, the same fallback as "no GUI registered" starts the app, which
// will start its daemon during normal initialization.
let delivered = match backend.control(ControlRequest::GuiOpen {
path: wire_path.clone(),
}) {
Ok(ReplyOk::Bool(delivered)) => delivered,
Ok(other) => bail!("the server answered GuiOpen with {other:?}"),
Err(_) => false,
};
if !delivered {
crate::gui::launch(path.as_deref())?;
}
report(
"",
json!({
"path": wire_path,
"delivered": delivered,
"launched": !delivered,
}),
)
}
fn gui_wire_path(path: &std::path::Path) -> Result<String> {
let Some(path_text) = path.to_str() else {
bail!(
"cannot open {} through the GUI protocol because the path is not valid UTF-8",
path.display()
);
};
Ok(path_text.to_owned())
}
fn resolve_gui_path(raw: String) -> Result<std::path::PathBuf> {
let expanded = expand_home(&raw).unwrap_or_else(|| std::path::PathBuf::from(&raw));
// Do not canonicalize here: preserving the caller's junction or symlink
// spelling keeps shell cwd reporting and tab labels consistent.
let path = if expanded.is_absolute() {
expanded
} else {
std::env::current_dir()
.context("reading the current directory")?
.join(expanded)
};
let metadata =
std::fs::metadata(&path).with_context(|| format!("opening {}", path.display()))?;
if !metadata.is_dir() {
bail!("{} is not a directory", path.display());
}
Ok(path)
}
fn expand_home(raw: &str) -> Option<std::path::PathBuf> {
let rest = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\"));
if raw != "~" && rest.is_none() {
return None;
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(std::path::PathBuf::from)?;
Some(match rest {
Some(rest) => home.join(rest),
None => home,
})
}
fn fetch_machine(backend: &mut dyn Backend) -> Result<Machine> {
@@ -1651,14 +1723,68 @@ mod tests {
assert!(msg.contains("unknown subcommand"), "{typo}: {msg}");
assert!(msg.contains(typo), "{typo}: {msg}");
}
}
// Things that do look like paths still reach the GUI launcher, and fail
// there for the honest reason.
for path in ["/tmp", "./src", "~/proj", "a/b"] {
let err = execute(cli(&["tty7", path]), &Context::default(), &mut mock())
.expect_err("the GUI launcher is not implemented yet");
assert!(err.to_string().contains("not wired up"), "{path}: {err}");
}
#[test]
fn a_path_asks_the_running_gui_to_open_an_absolute_directory() {
let mut backend = mock();
backend.replies.push_back(ReplyOk::Bool(true));
let out = run_cli(&["tty7", "."], &Context::default(), &mut backend);
let expected = std::env::current_dir()
.unwrap()
.join(".")
.to_str()
.unwrap()
.to_owned();
assert_eq!(
backend.control_calls,
vec![ControlRequest::GuiOpen {
path: Some(expected.clone())
}]
);
let Outcome::Report(report) = out else {
panic!("GUI open is a regular report");
};
assert_eq!(report.json["path"], expected);
assert_eq!(report.json["delivered"], true);
assert_eq!(report.json["launched"], false);
}
#[test]
fn an_invalid_gui_path_fails_before_touching_the_wire() {
let mut backend = mock();
let missing =
std::env::temp_dir().join(format!("tty7-cli-missing-path-{}", std::process::id()));
let arg = missing.to_str().unwrap().to_owned();
let err = execute(cli(&["tty7", &arg]), &Context::default(), &mut backend)
.expect_err("a missing directory must be rejected");
assert!(err.to_string().contains("opening"), "{err:#}");
assert!(backend.control_calls.is_empty());
}
#[cfg(unix)]
#[test]
fn a_non_utf8_gui_path_is_rejected_instead_of_changed() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec()));
let error = gui_wire_path(&path).expect_err("the string protocol cannot preserve bytes");
assert!(error.to_string().contains("not valid UTF-8"), "{error:#}");
}
#[test]
fn the_gui_launcher_is_local_only() {
let mut backend = mock();
let err = execute(
cli(&["tty7", "-m", "devbox", "."]),
&Context::default(),
&mut backend,
)
.expect_err("a local GUI request cannot be routed to another machine");
assert!(err.to_string().contains("cannot be combined"), "{err:#}");
assert!(backend.control_calls.is_empty());
}
#[test]
+81
View File
@@ -0,0 +1,81 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context as _, Result, bail};
pub fn launch(path: Option<&Path>) -> Result<()> {
let executable = find_executable()?;
let mut command = Command::new(&executable);
if let Some(path) = path {
command.arg("--open-path").arg(path);
}
// The CLI must not keep a caller's redirected pipes alive after it exits.
// The GUI owns its own logging and never needs this console's standard IO.
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("launching {}", executable.display()))?;
Ok(())
}
fn find_executable() -> Result<PathBuf> {
if let Some(explicit) = std::env::var_os("TTY7_APP") {
let path = PathBuf::from(explicit);
if path.is_file() {
return Ok(path);
}
bail!(
"TTY7_APP points to {}, but that file does not exist",
path.display()
);
}
let name = executable_name();
if let Ok(own) = std::env::current_exe()
&& let Some(dir) = own.parent()
{
let sibling = dir.join(name);
if sibling.is_file() {
return Ok(sibling);
}
}
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(name);
if candidate.is_file() {
return Ok(candidate);
}
}
}
bail!("could not find {name} next to this CLI or on PATH — install tty7-app, or set TTY7_APP")
}
fn executable_name() -> &'static str {
if cfg!(windows) {
"tty7-app.exe"
} else {
"tty7-app"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gui_executable_name_matches_the_platform() {
assert_eq!(
executable_name(),
if cfg!(windows) {
"tty7-app.exe"
} else {
"tty7-app"
}
);
}
}
+1
View File
@@ -2,6 +2,7 @@ mod address;
mod backend;
mod cli;
mod commands;
mod gui;
mod output;
mod resolve;
mod screen;
+35 -1
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use super::protocol::{MAX_FRAME, read_frame, write_frame};
pub const CONTROL_VERSION: u32 = 4;
pub const CONTROL_VERSION: u32 = 5;
const DIALECT_MARKER: &str = "speaks control v";
@@ -152,6 +152,10 @@ pub enum ControlRequest {
id: String,
},
GuiOpen {
path: Option<String>,
},
MachineGet,
WorkspaceTree {
workspace: WorkspaceId,
@@ -285,6 +289,7 @@ impl ControlRequest {
Git { .. } | GitStream { .. } | Search { .. } => Duration::from_secs(20),
Shells => Duration::from_secs(20),
WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10),
GuiOpen { .. } => Duration::from_secs(5),
MachineGet
| WorkspaceTree { .. }
| WorkspaceCreate { .. }
@@ -467,6 +472,9 @@ pub enum ControlEvent {
workspace: String,
by: String,
},
GuiOpen {
path: Option<String>,
},
Layout {
workspace: String,
delta: LayoutDelta,
@@ -501,6 +509,8 @@ pub struct ControlHello {
pub workspace: Option<String>,
pub client_token: String,
pub client_hostname: String,
#[serde(default)]
pub gui: bool,
}
impl ControlHello {
@@ -510,6 +520,14 @@ impl ControlHello {
workspace: None,
client_token: client_token.into(),
client_hostname: client_hostname.into(),
gui: false,
}
}
pub fn gui(client_token: impl Into<String>, client_hostname: impl Into<String>) -> Self {
ControlHello {
gui: true,
..Self::host_rpc(client_token, client_hostname)
}
}
}
@@ -1399,6 +1417,9 @@ mod tests {
dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()],
},
ControlRequest::WatchClose { id: 7 },
ControlRequest::GuiOpen {
path: Some("/home/me/proj".into()),
},
ControlRequest::AgentStates,
ControlRequest::Routes,
ControlRequest::Status,
@@ -1523,6 +1544,9 @@ mod tests {
workspace: "w1".into(),
by: "other-laptop".into(),
},
ControlEvent::GuiOpen {
path: Some("/home/me/proj".into()),
},
]
}
@@ -1532,6 +1556,7 @@ mod tests {
workspace: Some("w1".into()),
client_token: "tok".into(),
client_hostname: "laptop".into(),
gui: false,
}
}
@@ -1558,6 +1583,14 @@ mod tests {
assert!(ok.features.is_empty());
}
#[test]
fn an_old_client_hello_defaults_to_a_non_gui_connection() {
let json = r#"{"control_version":5,"workspace":null,"client_token":"tok",
"client_hostname":"laptop"}"#;
let hello: ControlHello = serde_json::from_str(json).expect("decodes without gui role");
assert!(!hello.gui);
}
fn hello_ok() -> ControlHelloOk {
ControlHelloOk {
control_version: CONTROL_VERSION,
@@ -2109,6 +2142,7 @@ mod tests {
},
s(20),
),
(R::GuiOpen { path: None }, s(5)),
(R::AgentStates, s(5)),
(R::Routes, s(5)),
(R::Status, s(5)),
+96
View File
@@ -54,6 +54,7 @@ impl Services {
#[derive(Default)]
pub struct AttachRegistry {
live: Mutex<Vec<Live>>,
guis: Mutex<Vec<GuiLive>>,
handover: Mutex<()>,
}
@@ -74,6 +75,11 @@ struct Evicted {
dedicated: bool,
}
struct GuiLive {
conn: u64,
sink: Arc<Sink>,
}
impl AttachRegistry {
fn handover(&self) -> std::sync::MutexGuard<'_, ()> {
self.handover.lock().unwrap_or_else(|e| e.into_inner())
@@ -94,6 +100,41 @@ impl AttachRegistry {
self.len() == 0
}
fn register_gui(&self, conn: u64, sink: Arc<Sink>) {
let mut guis = self.guis.lock().unwrap_or_else(|e| e.into_inner());
guis.retain(|gui| gui.conn != conn);
guis.push(GuiLive { conn, sink });
}
fn unregister_gui(&self, conn: u64) {
self.guis
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|gui| gui.conn != conn);
}
fn open_gui(&self, path: Option<String>) -> bool {
// The newest GUI connection belongs to the most recently started app
// process. Window recency is resolved inside that process, where GPUI
// owns the authoritative focus state.
loop {
let target = self
.guis
.lock()
.unwrap_or_else(|e| e.into_inner())
.last()
.map(|gui| (gui.conn, Arc::clone(&gui.sink)));
let Some((conn, sink)) = target else {
return false;
};
let event = ControlServerMsg::Event(ControlEvent::GuiOpen { path: path.clone() });
if sink.send(&event).is_ok() {
return true;
}
self.unregister_gui(conn);
}
}
fn claim(
&self,
workspace: &str,
@@ -252,6 +293,14 @@ where
},
});
if hello.gui {
// GUI registration starts only after a successful version handshake,
// so an incompatible app can never be reported as a live receiver.
services
.attachments
.register_gui(conn.id, Arc::clone(&sink));
}
if let Some(workspace) = hello.workspace.as_deref()
&& let Err(e) = attach_workspace(&conn, workspace, true)
{
@@ -266,6 +315,8 @@ where
.unwrap_or_else(|e| e.into_inner())
.clear();
conn.release_all_workspaces();
// Every exit path converges here, including EOF and protocol errors.
conn.attachments.unregister_gui(conn.id);
drop(machine_sub);
sink.retire();
let _ = shutdown.shutdown_link();
@@ -610,6 +661,9 @@ fn run_request(
detach_workspace(conn, &id)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::GuiOpen { path } => {
(ReplyOk::Bool(conn.attachments.open_gui(path)), Vec::new())
}
ControlRequest::MachineGet => (
ReplyOk::MachineTree(Box::new(machine_with_live_panes(conn)?)),
@@ -1825,6 +1879,47 @@ mod pool_tests {
}
}
#[cfg(test)]
mod gui_registry_tests {
use super::*;
use std::io::Cursor;
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn gui_open_is_delivered_only_while_a_gui_is_registered() {
let registry = AttachRegistry::default();
assert!(!registry.open_gui(Some("/work".into())));
let bytes = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::new(Sink::new(SharedWriter(Arc::clone(&bytes))));
registry.register_gui(7, sink);
assert!(registry.open_gui(Some("/work".into())));
let frame = bytes.lock().unwrap().clone();
assert_eq!(
ControlServerMsg::read(&mut Cursor::new(frame)).unwrap(),
ControlServerMsg::Event(ControlEvent::GuiOpen {
path: Some("/work".into())
})
);
registry.unregister_gui(7);
assert!(!registry.open_gui(None));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
@@ -1885,6 +1980,7 @@ mod tests {
workspace: Some(workspace.to_string()),
client_token: token.to_string(),
client_hostname: hostname.to_string(),
gui: false,
}
}
+175 -2
View File
@@ -110,6 +110,91 @@ fn apply_config_dir_arg() {
}
}
fn open_path_arg() -> Option<std::path::PathBuf> {
open_path_from(std::env::args().skip(1))
}
fn open_path_from(mut args: impl Iterator<Item = String>) -> Option<std::path::PathBuf> {
while let Some(arg) = args.next() {
if let Some(path) = arg.strip_prefix("--open-path=") {
return Some(path.into());
}
if arg == "--open-path" {
return args.next().map(Into::into);
}
}
None
}
/// Offers an explicit launch path to an already running local GUI.
///
/// The dispatcher is injected so the startup decision can be tested without
/// opening a real daemon connection. Only an explicit `Bool(true)` means the
/// path reached a GUI; every other response keeps the current app alive so it
/// can perform the normal first-window startup.
fn forward_open_path_with(
open_path: Option<&std::path::Path>,
dispatch: impl FnOnce(String) -> std::io::Result<tty7_core::daemon::control::ReplyOk>,
) -> bool {
use tty7_core::daemon::control::ReplyOk;
let Some(path) = open_path else {
return false;
};
let path = if path.is_absolute() {
path.to_path_buf()
} else {
match std::env::current_dir() {
Ok(current_dir) => current_dir.join(path),
Err(error) => {
log::debug!("could not resolve the relative GUI open path: {error}");
return false;
}
}
};
let Some(wire_path) = path.to_str().map(str::to_owned) else {
// Keep the native PathBuf in this process. Returning false continues
// normal startup, which opens the path without crossing the protocol.
log::debug!("the explicit GUI open path is not valid UTF-8; opening it locally");
return false;
};
match dispatch(wire_path) {
Ok(ReplyOk::Bool(true)) => true,
Ok(ReplyOk::Bool(false)) => false,
Ok(other) => {
log::debug!("the daemon answered the early GuiOpen request with {other:?}");
false
}
Err(error) => {
log::debug!("the early GuiOpen request was not delivered: {error}");
false
}
}
}
/// Uses a transient host-RPC connection instead of a GUI connection.
///
/// A GUI connection is long-lived and registers itself as the daemon's event
/// target. This short probe must never replace the actual running GUI while it
/// asks that GUI to open the requested folder.
fn forward_open_path(open_path: Option<&std::path::Path>) -> bool {
use tty7_core::client::ControlClient;
use tty7_core::daemon::control::{ControlHello, ControlRequest};
forward_open_path_with(open_path, |path| {
let hello = ControlHello::host_rpc(
format!("tty7-app-open-{}", std::process::id()),
"this computer",
);
let client = ControlClient::connect(&hello)?;
let reply = client.request(ControlRequest::GuiOpen { path: Some(path) });
client.close();
reply
})
}
#[cfg(unix)]
fn merge_paths(primary: &str, secondary: &str) -> String {
let mut seen = std::collections::HashSet::new();
@@ -212,6 +297,10 @@ fn main() {
#[cfg(unix)]
enrich_path_from_login_shell();
let open_path = open_path_arg();
if forward_open_path(open_path.as_deref()) {
return;
}
let config = crate::core::config::Config::load();
// After the PATH enrichment above, which is what makes the candidate scan
@@ -254,8 +343,11 @@ fn main() {
keymap::init(cx);
crate::ui::local_link::LocalLink::install(cx);
let reopen = crate::core::session::WorkspaceStore::restore_one(cx);
crate::ui::windows::open(cx, reopen);
let reopen = open_path
.is_none()
.then(|| crate::core::session::WorkspaceStore::restore_one(cx))
.flatten();
crate::ui::windows::open_at(cx, reopen, open_path);
});
}
@@ -279,3 +371,84 @@ mod tests {
assert_eq!(merge_paths("", "/usr/bin"), "/usr/bin");
}
}
#[cfg(test)]
mod argument_tests {
use super::{forward_open_path_with, open_path_from};
use std::path::PathBuf;
use tty7_core::daemon::control::ReplyOk;
#[test]
fn open_path_accepts_separate_and_equals_forms() {
let separate = open_path_from(
["--config-dir", "/cfg", "--open-path", "/work"]
.into_iter()
.map(str::to_string),
);
assert_eq!(separate, Some(PathBuf::from("/work")));
let equals = open_path_from(["--open-path=C:\\work".to_string()].into_iter());
assert_eq!(equals, Some(PathBuf::from("C:\\work")));
}
#[test]
fn no_open_path_never_attempts_early_dispatch() {
let delivered = forward_open_path_with(None, |_| {
panic!("the dispatcher must not run without an explicit path")
});
assert!(!delivered);
}
#[test]
fn early_dispatch_exits_only_after_a_gui_accepts_the_path() {
let path = std::env::current_dir().unwrap().join("folder with spaces");
let expected = path.to_str().unwrap().to_owned();
let delivered = forward_open_path_with(Some(&path), |actual| {
assert_eq!(actual, expected);
Ok(ReplyOk::Bool(true))
});
assert!(delivered);
assert!(!forward_open_path_with(Some(&path), |_| Ok(ReplyOk::Bool(
false
))));
assert!(!forward_open_path_with(Some(&path), |_| Ok(ReplyOk::Unit)));
assert!(!forward_open_path_with(Some(&path), |_| {
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"daemon is not running",
))
}));
}
#[test]
fn early_dispatch_resolves_relative_paths_before_cross_process_delivery() {
let relative = std::path::Path::new("workspace");
let expected = std::env::current_dir()
.unwrap()
.join(relative)
.to_str()
.unwrap()
.to_owned();
assert!(forward_open_path_with(Some(relative), |actual| {
assert_eq!(actual, expected);
Ok(ReplyOk::Bool(true))
}));
}
#[cfg(unix)]
#[test]
fn early_dispatch_keeps_non_utf8_paths_in_the_current_process() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec()));
let delivered = forward_open_path_with(Some(&path), |_| {
panic!("a non-UTF-8 path must never be changed and sent over the string protocol")
});
assert!(!delivered);
}
}
+237 -1
View File
@@ -45,7 +45,8 @@ actions!(
FindNext,
FindPrevious,
ClearScrollback,
InsertNewline
InsertNewline,
InsertNewlineFallback
]
);
@@ -1184,6 +1185,17 @@ impl TerminalView {
}
}
fn send_shortcut_bytes(&mut self, bytes: &[u8], key: &str, cx: &mut Context<Self>) {
let shell_owns_prompt = self.shell_owns_prompt();
self.release_hold();
self.send_to_pty(bytes, cx);
if !shell_owns_prompt {
let alt = self.on_alt_screen();
self.typeahead
.observe(RawInput::Key { key, plain: false }, alt);
}
}
fn handle_cmd_shortcut(
&mut self,
ks: &gpui::Keystroke,
@@ -1222,6 +1234,8 @@ impl TerminalView {
if self.input_active() {
self.editor_move_edge(false, m.shift);
cx.notify();
} else if cfg!(target_os = "macos") && self.accepts_input(cx) {
self.send_shortcut_bytes(&[0x01], "a", cx);
}
CmdKey::Consumed
}
@@ -1229,6 +1243,8 @@ impl TerminalView {
if self.input_active() {
self.editor_move_edge(true, m.shift);
cx.notify();
} else if cfg!(target_os = "macos") && self.accepts_input(cx) {
self.send_shortcut_bytes(&[0x05], "e", cx);
}
CmdKey::Consumed
}
@@ -1240,6 +1256,8 @@ impl TerminalView {
self.close_completion();
self.cursor_visible = true;
cx.notify();
} else if cfg!(target_os = "macos") && self.accepts_input(cx) {
self.send_shortcut_bytes(&[0x15], "u", cx);
}
CmdKey::Consumed
}
@@ -1251,6 +1269,8 @@ impl TerminalView {
self.close_completion();
self.cursor_visible = true;
cx.notify();
} else if cfg!(target_os = "macos") && self.accepts_input(cx) {
self.send_shortcut_bytes(&[0x0b], "k", cx);
}
CmdKey::Consumed
}
@@ -2629,6 +2649,19 @@ impl TerminalView {
cx.notify();
}
fn insert_newline_fallback_action(&mut self, cx: &mut Context<Self>) {
if self.input_active() {
self.insert_newline_action(cx);
} else if (self.search.is_some() && self.search_focused)
|| self.kitty_flags().active()
|| !self.accepts_input(cx)
{
cx.propagate();
} else {
self.send_shortcut_bytes(b"\n", "enter", cx);
}
}
fn accept_line(&mut self, cx: &mut Context<Self>) {
if self
.completion
@@ -4346,6 +4379,9 @@ impl Render for TerminalView {
.on_action(cx.listener(|this, _: &InsertNewline, _w, cx| {
this.insert_newline_action(cx);
}))
.on_action(cx.listener(|this, _: &InsertNewlineFallback, _w, cx| {
this.insert_newline_fallback_action(cx);
}))
.on_action(cx.listener(|this, _: &SendTab, _w, cx| {
this.tab_pressed(true, cx);
}))
@@ -6608,6 +6644,82 @@ mod gpui_tests {
.unwrap();
}
#[gpui::test]
fn shift_enter_reaches_a_foreground_tui_with_kitty_encoding(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
DaemonMsg::Output(b"\x1b[>1u".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
if window
.update(cx, |view, _, _| view.kitty_flags().active())
.unwrap()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
assert!(!view.input_active(), "the foreground TUI owns input");
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("shift-enter");
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"\x1b[13;2u".to_vec())
);
}
#[gpui::test]
fn shift_enter_reaches_a_foreground_tui_as_lf_without_kitty(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
window
.update(cx, |view, window, cx| {
assert!(!view.input_active(), "the foreground TUI owns input");
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("shift-enter");
assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec()));
}
#[gpui::test]
fn alt_enter_keeps_its_legacy_encoding_in_a_foreground_tui(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
window
.update(cx, |view, window, cx| {
assert!(!view.input_active(), "the foreground TUI owns input");
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("alt-enter");
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"\x1b\r".to_vec())
);
}
#[gpui::test]
fn insert_newline_action_declines_when_the_editor_is_not_live(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
@@ -7851,6 +7963,130 @@ mod gpui_tests {
assert_eq!(text.as_deref(), Some("hello"));
}
#[cfg(target_os = "macos")]
#[gpui::test]
fn cmd_backspace_reaches_a_foreground_tui_as_ctrl_u(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, window, cx| {
assert!(
!view.input_active(),
"the foreground process, not tty7's editor, owns input"
);
view.on_key_down(
&KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: Modifiers {
platform: true,
..Modifiers::default()
},
key: "backspace".into(),
key_char: None,
},
is_held: false,
prefer_character_input: false,
},
window,
cx,
);
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x15]));
}
#[cfg(target_os = "macos")]
#[gpui::test]
fn cmd_navigation_reaches_a_foreground_tui_as_readline_controls(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
for (key, expected) in [("left", 0x01), ("right", 0x05), ("delete", 0x0b)] {
window
.update(cx, |view, window, cx| {
assert!(!view.input_active(), "the foreground TUI owns input");
view.on_key_down(
&KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: Modifiers {
platform: true,
..Modifiers::default()
},
key: key.into(),
key_char: None,
},
is_held: false,
prefer_character_input: false,
},
window,
cx,
);
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![expected]));
}
}
#[cfg(target_os = "macos")]
#[gpui::test]
fn cmd_backspace_releases_held_input_before_ctrl_u(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: false,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let gap = window
.update(cx, |view, _, _| {
view.terminal.shell_active() && !view.terminal.at_prompt()
})
.unwrap();
if gap {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
view.commit_text("ls", cx);
view.on_key_down(
&KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: Modifiers {
platform: true,
..Modifiers::default()
},
key: "backspace".into(),
key_char: None,
},
is_held: false,
prefer_character_input: false,
},
window,
cx,
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"ls".to_vec()),
"held text must reach the PTY before the line-kill"
);
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(vec![0x15]),
"Ctrl-U must follow the text it clears"
);
}
#[gpui::test]
fn paste_to_the_pty_consumes_the_selection(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
+45 -7
View File
@@ -438,6 +438,15 @@ impl Tty7App {
id: Option<WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::for_workspace_at(id, None, window, cx)
}
pub fn for_workspace_at(
id: Option<WorkspaceId>,
initial_cwd: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let restore = cx.global::<Config>().restore_session;
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
@@ -447,7 +456,7 @@ impl Tty7App {
.is_some_and(|w| w.is_remote());
let hydrate = known && (restore || is_remote);
let session = hydrate.then(Session::default);
let app = Self::with_session(Some(workspace), session, window, cx);
let app = Self::with_session_at(Some(workspace), session, initial_cwd, window, cx);
if hydrate {
crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace);
} else {
@@ -507,6 +516,16 @@ impl Tty7App {
session: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::with_session_at(workspace, session, None, window, cx)
}
fn with_session_at(
workspace: Option<WorkspaceId>,
session: Option<Session>,
initial_cwd: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let workspace = workspace.unwrap_or_default();
let pane_ws = crate::ui::remote_workspace::pane_workspace_for(cx, workspace);
@@ -594,7 +613,7 @@ impl Tty7App {
pane_ws.clone(),
Some(workspace),
font_size,
None,
initial_cwd,
None,
None,
window,
@@ -2209,20 +2228,39 @@ impl Tty7App {
self.new_tab_with_shell(None, window, cx);
}
pub(crate) fn new_tab_at(
&mut self,
cwd: std::path::PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_tab_with_cwd(Some(cwd), None, window, cx);
}
pub(crate) fn new_tab_with_shell(
&mut self,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
self.new_tab_with_cwd(cwd, shell, window, cx);
}
fn new_tab_with_cwd(
&mut self,
cwd: Option<std::path::PathBuf>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_local_spawn(window, cx) {
return;
}
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
let pane_ws = self.window_workspace(cx);
let tab = match new_terminal(
pane_ws,
+62 -2
View File
@@ -3,7 +3,8 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction};
use crate::core::actions::*;
use crate::core::config::Config;
use crate::terminal::view::{
ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, PasteText,
ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline,
InsertNewlineFallback, PasteText,
};
use crate::ui::theme::set_menus;
@@ -74,6 +75,10 @@ fn extra_keystrokes(effective: &[(String, String)]) -> Vec<(&'static str, &'stat
.collect()
}
fn is_default_insert_newline_binding(action: &str, key: &str) -> bool {
action == "InsertNewline" && key == INSERT_NEWLINE_DEFAULT
}
pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> {
extra_keystrokes(&effective_bindings(cx))
.into_iter()
@@ -97,7 +102,16 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
continue;
}
match make_binding(action, key) {
Some(b) => bindings.push(b),
Some(b) => {
bindings.push(b);
if is_default_insert_newline_binding(action, key) {
bindings.push(KeyBinding::new(
INSERT_NEWLINE_DEFAULT,
InsertNewlineFallback,
Some("Terminal"),
));
}
}
None => log::warn!("ignoring keybinding: unknown action '{action}'"),
}
}
@@ -697,6 +711,52 @@ mod tests {
assert_eq!(key_tokens("shift-enter"), vec![SHIFT, ""]);
}
#[test]
fn shift_enter_fallback_retires_with_an_insert_newline_rebind() {
assert!(is_default_insert_newline_binding(
"InsertNewline",
"shift-enter"
));
assert!(!is_default_insert_newline_binding(
"InsertNewline",
"ctrl-o"
));
assert!(!is_default_insert_newline_binding("InsertNewline", ""));
}
#[test]
fn shift_enter_prefix_binding_still_waits_for_its_second_key() {
let mut effective = default_bindings()
.into_iter()
.map(|(action, key)| (action.to_string(), key.to_string()))
.collect::<Vec<_>>();
effective
.iter_mut()
.find(|(action, _)| action == "OpenSettings")
.unwrap()
.1 = "shift-enter x".to_string();
let mut keymap = gpui::Keymap::default();
keymap.add_bindings(action_bindings(&effective));
let input = [gpui::Keystroke::parse("shift-enter").unwrap()];
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
let (_, pending) = keymap.bindings_for_input(&input, &context);
assert!(pending, "the keymap must wait for the second key");
let input = [
gpui::Keystroke::parse("shift-enter").unwrap(),
gpui::Keystroke::parse("x").unwrap(),
];
let (matched, pending) = keymap.bindings_for_input(&input, &context);
assert!(!pending);
assert!(
matched
.first()
.is_some_and(|binding| binding.action().partial_eq(&OpenSettings))
);
}
#[test]
fn paste_ships_both_terminal_chords_off_macos_and_retires_together() {
let effective: Vec<(String, String)> = default_bindings()
+1 -1
View File
@@ -118,7 +118,7 @@ fn connect_blocking() -> std::io::Result<Arc<ControlClient>> {
use tty7_core::daemon::control::ControlHello;
crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?;
let hello = ControlHello::host_rpc(uuid::Uuid::new_v4().to_string(), "this computer");
let hello = ControlHello::gui(uuid::Uuid::new_v4().to_string(), "this computer");
let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink);
#[cfg(unix)]
let client = ControlClient::over_unix(
+3
View File
@@ -1027,6 +1027,9 @@ pub(crate) fn drain_events(cx: &mut gpui::App) {
crate::ui::tree_sync::resync_window_from_tree(cx, workspace);
}
}
ControlEvent::GuiOpen { path } if host.is_local() => {
crate::ui::windows::open_from_cli(cx, path.map(std::path::PathBuf::from));
}
other => log::debug!("unhandled control event from {host:?}: {other:?}"),
}
}
+24 -2
View File
@@ -36,6 +36,10 @@ use crate::ui::presets;
use crate::ui::rounding;
use crate::ui::rounding::RoundedCorners as _;
fn settings_row_id(label: &str, _desc: &str) -> SharedString {
SharedString::from(format!("settings-row-{label}"))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettingsSection {
Appearance,
@@ -958,10 +962,15 @@ impl Tty7App {
desc: impl Into<String>,
control: AnyElement,
cx: &Context<Self>,
) -> Div {
) -> Stateful<Div> {
let theme = cx.theme();
let label = label.into();
let desc = desc.into();
// Descriptions can contain live status (for example an agent hook target), so they
// must not participate in the identity that preserves GPUI's hover state.
let element_id = settings_row_id(&label, &desc);
h_flex()
.id(element_id)
.items_center()
.justify_between()
.gap_8()
@@ -970,6 +979,7 @@ impl Tty7App {
.mx_neg_2p5()
.rounded_lg()
.hover(|h| h.bg(gpui::rgb(cx.global::<presets::Surfaces>().window.hover)))
.on_hover(cx.listener(|_this, _hovered, _window, cx| cx.notify()))
.child(
v_flex()
.gap_0p5()
@@ -979,7 +989,7 @@ impl Tty7App {
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(theme.foreground)
.child(label.into()),
.child(label),
)
.when(!desc.is_empty(), |col| {
col.child(
@@ -4725,6 +4735,18 @@ impl Tty7App {
mod tests {
use super::*;
#[test]
fn settings_row_identity_depends_only_on_its_stable_label() {
assert_eq!(
settings_row_id("Claude Code", "Installing…"),
settings_row_id("Claude Code", "Installed in C:\\tools")
);
assert_ne!(
settings_row_id("Claude Code", "Installed"),
settings_row_id("Codex", "Installed")
);
}
#[test]
fn every_section_has_search_entries() {
for section in SettingsSection::ALL {
+122 -2
View File
@@ -63,6 +63,29 @@ impl WindowRegistry {
.or_else(|| registry.windows.first().map(|w| w.workspace))
}
pub fn most_recent_local(cx: &mut App) -> Option<WorkspaceId> {
Self::sweep(cx);
let views = WorkspaceStore::all(cx);
let registry = cx.global::<Self>();
let is_open_local = |id: WorkspaceId| {
registry.windows.iter().any(|window| window.workspace == id)
&& views.get(id).is_some_and(|view| !view.is_remote())
};
views.active.filter(|id| is_open_local(*id)).or_else(|| {
registry
.windows
.iter()
.filter(|window| is_open_local(window.workspace))
.max_by_key(|window| {
views
.get(window.workspace)
.map(|view| view.last_active)
.unwrap_or_default()
})
.map(|window| window.workspace)
})
}
pub fn app_in(cx: &mut App, window: &Window) -> Option<gpui::Entity<Tty7App>> {
Self::sweep(cx);
let handle = window.window_handle();
@@ -130,6 +153,14 @@ impl WindowRegistry {
}
pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) {
open_at(cx, workspace, None);
}
pub fn open_at(
cx: &mut App,
workspace: Option<WorkspaceId>,
initial_cwd: Option<std::path::PathBuf>,
) {
if let Some(id) = workspace
&& let Some(handle) = WindowRegistry::window_for(cx, id)
{
@@ -140,7 +171,10 @@ pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) {
let options = window_options(cx, workspace);
let mut created: Option<gpui::Entity<Tty7App>> = None;
let opened = cx.open_window(options, |window, cx| {
let app = cx.new(|cx| Tty7App::for_workspace(workspace, window, cx));
let app = cx.new(|cx| match initial_cwd.clone() {
Some(cwd) => Tty7App::for_workspace_at(workspace, Some(cwd), window, cx),
None => Tty7App::for_workspace(workspace, window, cx),
});
created = Some(app.clone());
cx.new(|cx| Root::new(app, window, cx).bg(gpui::transparent_black()))
});
@@ -162,6 +196,52 @@ pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) {
refresh_menu(cx);
}
pub fn open_from_cli(cx: &mut App, path: Option<std::path::PathBuf>) {
// Only the GUI process knows which of its windows was focused most recently.
// The daemon deliberately routes to a process, then leaves window selection
// to this registry.
let workspace = if path.is_some() {
WindowRegistry::most_recent_local(cx)
} else {
WindowRegistry::most_recent(cx)
};
let Some(workspace) = workspace else {
open_missing_cli_window_with(cx, path, open_at);
return;
};
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
return;
};
cx.activate(true);
let _ = handle.update(cx, move |_, window, cx| {
if let Some(path) = path {
app.update(cx, |app, cx| app.new_tab_at(path, window, cx));
}
window.activate_window();
});
}
/// Opens a window after CLI routing reaches a GUI process with no live windows.
///
/// A pathless request follows the same restoration policy as normal startup.
/// An explicit path always starts a fresh local workspace so the requested tab
/// cannot accidentally be attached to a detached remote workspace.
fn open_missing_cli_window_with(
cx: &mut App,
path: Option<std::path::PathBuf>,
open: impl FnOnce(&mut App, Option<WorkspaceId>, Option<std::path::PathBuf>),
) {
let restore = path
.is_none()
.then(|| WorkspaceStore::restore_one(cx))
.flatten();
open(cx, restore, path);
}
pub fn refresh_menu(cx: &mut App) {
crate::ui::theme::set_menus(cx);
}
@@ -456,6 +536,7 @@ fn cascade(bounds: Bounds<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::{WindowView, WindowViews};
fn bounds_at(x: f32, y: f32) -> Bounds<gpui::Pixels> {
Bounds {
@@ -491,6 +572,46 @@ mod tests {
assert_eq!(cascade(b, 6).origin, cascade(b, 1).origin);
}
#[gpui::test]
fn a_pathless_cli_request_restores_a_workspace_when_no_window_is_open(
cx: &mut gpui::TestAppContext,
) {
let view = WindowView::default();
let restored = view.id;
let mut opened = None;
cx.update(|cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![view],
active: Some(restored),
},
);
open_missing_cli_window_with(cx, None, |_, workspace, path| {
opened = Some((workspace, path));
});
});
assert_eq!(opened, Some((Some(restored), None)));
}
#[gpui::test]
fn a_pathless_cli_request_creates_a_default_window_without_history(
cx: &mut gpui::TestAppContext,
) {
let mut opened = None;
cx.update(|cx| {
WorkspaceStore::install_for_test(cx, WindowViews::default());
open_missing_cli_window_with(cx, None, |_, workspace, path| {
opened = Some((workspace, path));
});
});
assert_eq!(opened, Some((None, None)));
}
#[test]
fn the_confirmation_says_which_of_the_three_answers_it_got() {
assert_eq!(
@@ -535,7 +656,6 @@ mod tests {
fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror(
cx: &mut gpui::TestAppContext,
) {
use crate::core::session::{WindowView, WindowViews};
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace};
cx.update(|cx| {