mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge branch 'main' into feat/macos-in-app-updater
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod address;
|
||||
mod backend;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod gui;
|
||||
mod output;
|
||||
mod resolve;
|
||||
mod screen;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user