feat: add direct terminal attach stream
This commit is contained in:
parent
a36da363f1
commit
3ecacfddae
|
|
@ -108,6 +108,12 @@ Host workbox
|
|||
|
||||
same session, same agents, same state.
|
||||
|
||||
### direct agent attach
|
||||
|
||||
`herdr` and `herdr --remote` attach to the full Herdr session UI. `herdr agent attach <target>` attaches your current terminal directly to one server-owned terminal, like a single-pane terminal attach. `herdr terminal attach <terminal_id>` does the same by terminal id.
|
||||
|
||||
Direct attach streams the current rendered terminal state first, then live ANSI frames. Your input goes straight to that terminal. Detach with `ctrl+b q`; send a literal `ctrl+b` with `ctrl+b ctrl+b`. One writable client owns input and resize for a terminal. A second attach fails unless you pass `--takeover`.
|
||||
|
||||
## agent awareness
|
||||
|
||||
the sidebar shows which agents are blocked, working, or done. workspaces roll up to their most urgent state so you can scan the full list at a glance.
|
||||
|
|
|
|||
|
|
@ -1143,10 +1143,10 @@ impl AppState {
|
|||
}
|
||||
|
||||
fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
|
||||
let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
|
||||
let Some(ws_idx) = self.active else {
|
||||
return false;
|
||||
};
|
||||
let Some(rt) = ws.runtimes.get(&info.id) else {
|
||||
let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
|
||||
return false;
|
||||
};
|
||||
if !rt
|
||||
|
|
|
|||
|
|
@ -261,8 +261,9 @@ impl App {
|
|||
let pane_id = ws
|
||||
.focused_pane_id()
|
||||
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
|
||||
let scrollback = ws
|
||||
.focused_runtime()
|
||||
let scrollback = self
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(ws_idx, pane_id)
|
||||
.ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))?
|
||||
.recent_text(usize::MAX);
|
||||
|
||||
|
|
|
|||
|
|
@ -508,16 +508,18 @@ mod tests {
|
|||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
let start_metrics = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let start_metrics = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("initial scroll metrics");
|
||||
assert_eq!(start_metrics.offset_from_bottom, 0);
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
||||
|
||||
let end_metrics = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let end_metrics = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageUp");
|
||||
assert_eq!(
|
||||
|
|
@ -550,8 +552,9 @@ mod tests {
|
|||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
||||
let after_up = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let after_up = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageUp");
|
||||
assert!(after_up.offset_from_bottom > 0);
|
||||
|
|
@ -560,8 +563,9 @@ mod tests {
|
|||
KeyCode::PageDown,
|
||||
KeyModifiers::empty(),
|
||||
));
|
||||
let after_down = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let after_down = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageDown");
|
||||
assert_eq!(after_down.offset_from_bottom, 0);
|
||||
|
|
@ -591,8 +595,9 @@ mod tests {
|
|||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
||||
let after_press = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let after_press = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageUp press");
|
||||
assert_eq!(
|
||||
|
|
@ -605,8 +610,9 @@ mod tests {
|
|||
.with_kind(KeyEventKind::Release),
|
||||
);
|
||||
|
||||
let after_release = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let after_release = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageUp release");
|
||||
assert_eq!(
|
||||
|
|
@ -640,8 +646,9 @@ mod tests {
|
|||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));
|
||||
|
||||
let metrics = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let metrics = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after modified PageUp");
|
||||
assert_eq!(metrics.offset_from_bottom, 0);
|
||||
|
|
@ -672,16 +679,18 @@ mod tests {
|
|||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
let start_metrics = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let start_metrics = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("initial scroll metrics");
|
||||
assert_eq!(start_metrics.offset_from_bottom, 0);
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
|
||||
|
||||
let end_metrics = app.state.workspaces[0]
|
||||
.runtime(pane_id)
|
||||
let end_metrics = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(0, pane_id)
|
||||
.and_then(crate::pane::PaneRuntime::scroll_metrics)
|
||||
.expect("scroll metrics after PageUp");
|
||||
// Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom.
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ impl App {
|
|||
let mut state = AppState {
|
||||
terminals: std::collections::HashMap::new(),
|
||||
terminal_runtimes: std::collections::HashMap::new(),
|
||||
direct_attach_resize_locks: std::collections::HashSet::new(),
|
||||
workspaces,
|
||||
active,
|
||||
selected,
|
||||
|
|
|
|||
|
|
@ -822,6 +822,8 @@ pub struct AppState {
|
|||
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>,
|
||||
pub terminal_runtimes:
|
||||
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>,
|
||||
/// Terminal ids whose size is currently owned by a direct attach client.
|
||||
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub active: Option<usize>,
|
||||
pub selected: usize,
|
||||
|
|
@ -933,8 +935,7 @@ impl AppState {
|
|||
self.mode == Mode::Terminal
|
||||
&& self
|
||||
.active
|
||||
.and_then(|idx| self.workspaces.get(idx))
|
||||
.and_then(crate::workspace::Workspace::focused_runtime)
|
||||
.and_then(|idx| self.focused_runtime_in_workspace(idx))
|
||||
.and_then(crate::pane::PaneRuntime::input_state)
|
||||
.is_some_and(crate::pane::InputState::mouse_reporting_enabled)
|
||||
}
|
||||
|
|
@ -1060,6 +1061,7 @@ impl AppState {
|
|||
Self {
|
||||
terminals: std::collections::HashMap::new(),
|
||||
terminal_runtimes: std::collections::HashMap::new(),
|
||||
direct_attach_resize_locks: std::collections::HashSet::new(),
|
||||
workspaces: Vec::new(),
|
||||
active: None,
|
||||
selected: 0,
|
||||
|
|
|
|||
87
src/cli.rs
87
src/cli.rs
|
|
@ -36,6 +36,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
|
|||
"workspace" => run_workspace_command(&args[2..])?,
|
||||
"tab" => run_tab_command(&args[2..])?,
|
||||
"agent" => run_agent_command(&args[2..])?,
|
||||
"terminal" => run_terminal_command(&args[2..])?,
|
||||
"pane" => run_pane_command(&args[2..])?,
|
||||
"wait" => run_wait_command(&args[2..])?,
|
||||
"integration" => run_integration_command(&args[2..])?,
|
||||
|
|
@ -280,6 +281,7 @@ fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
|
|||
"send" => agent_send(&args[1..]),
|
||||
"rename" => agent_rename(&args[1..]),
|
||||
"focus" => agent_focus(&args[1..]),
|
||||
"attach" => agent_attach(&args[1..]),
|
||||
"start" => agent_start(&args[1..]),
|
||||
"help" | "--help" | "-h" => {
|
||||
print_agent_help();
|
||||
|
|
@ -292,6 +294,25 @@ fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_terminal_command(args: &[String]) -> std::io::Result<i32> {
|
||||
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
|
||||
print_terminal_help();
|
||||
return Ok(2);
|
||||
};
|
||||
|
||||
match subcommand {
|
||||
"attach" => terminal_attach(&args[1..]),
|
||||
"help" | "--help" | "-h" => {
|
||||
print_terminal_help();
|
||||
Ok(0)
|
||||
}
|
||||
_ => {
|
||||
print_terminal_help();
|
||||
Ok(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
|
||||
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
|
||||
print_pane_help();
|
||||
|
|
@ -906,6 +927,65 @@ fn agent_focus(args: &[String]) -> std::io::Result<i32> {
|
|||
})?)
|
||||
}
|
||||
|
||||
fn agent_attach(args: &[String]) -> std::io::Result<i32> {
|
||||
let (target, takeover) =
|
||||
match parse_attach_target(args, "usage: herdr agent attach <target> [--takeover]") {
|
||||
Ok(parsed) => parsed,
|
||||
Err(code) => return Ok(code),
|
||||
};
|
||||
|
||||
let response = send_request(&Request {
|
||||
id: "cli:agent:attach:resolve".into(),
|
||||
method: Method::AgentGet(AgentTarget {
|
||||
target: target.clone(),
|
||||
}),
|
||||
})?;
|
||||
if let Some(error) = response.get("error") {
|
||||
eprintln!("{}", serde_json::to_string(error).unwrap());
|
||||
return Ok(1);
|
||||
}
|
||||
let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
|
||||
eprintln!("agent attach failed: response did not include terminal_id");
|
||||
return Ok(1);
|
||||
};
|
||||
crate::client::run_terminal_attach(terminal_id.to_owned(), takeover)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn terminal_attach(args: &[String]) -> std::io::Result<i32> {
|
||||
let (terminal_id, takeover) = match parse_attach_target(
|
||||
args,
|
||||
"usage: herdr terminal attach <terminal_id> [--takeover]",
|
||||
) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(code) => return Ok(code),
|
||||
};
|
||||
crate::client::run_terminal_attach(terminal_id, takeover)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> {
|
||||
let Some(target) = args.first() else {
|
||||
eprintln!("{usage}");
|
||||
return Err(2);
|
||||
};
|
||||
let mut takeover = false;
|
||||
for arg in &args[1..] {
|
||||
match arg.as_str() {
|
||||
"--takeover" => takeover = true,
|
||||
"help" | "--help" | "-h" => {
|
||||
eprintln!("{usage}");
|
||||
return Err(0);
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option: {other}");
|
||||
return Err(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((target.clone(), takeover))
|
||||
}
|
||||
|
||||
fn agent_rename(args: &[String]) -> std::io::Result<i32> {
|
||||
let Some(target) = args.first() else {
|
||||
eprintln!("usage: herdr agent rename <target> <name>|--clear");
|
||||
|
|
@ -1898,10 +1978,17 @@ fn print_agent_help() {
|
|||
eprintln!(" herdr agent send <target> <text>");
|
||||
eprintln!(" herdr agent rename <target> <name>|--clear");
|
||||
eprintln!(" herdr agent focus <target>");
|
||||
eprintln!(" herdr agent attach <target> [--takeover]");
|
||||
eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
|
||||
eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids");
|
||||
}
|
||||
|
||||
fn print_terminal_help() {
|
||||
eprintln!("herdr terminal commands:");
|
||||
eprintln!(" herdr terminal attach <terminal_id> [--takeover]");
|
||||
eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b");
|
||||
}
|
||||
|
||||
fn print_pane_help() {
|
||||
eprintln!("herdr pane commands:");
|
||||
eprintln!(" herdr pane list [--workspace <workspace_id>]");
|
||||
|
|
|
|||
|
|
@ -54,6 +54,54 @@ struct ClientState {
|
|||
sound_config: crate::config::SoundConfig,
|
||||
/// Whether this client may write Kitty graphics bytes to its host terminal.
|
||||
kitty_graphics_enabled: bool,
|
||||
/// Direct attach prefix escape state. None for full-app clients.
|
||||
attach_escape: Option<AttachEscapeState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AttachEscapeState {
|
||||
pending_prefix: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AttachInputAction {
|
||||
Forward(Vec<u8>),
|
||||
Detach,
|
||||
None,
|
||||
}
|
||||
|
||||
impl AttachEscapeState {
|
||||
fn filter_input(&mut self, data: Vec<u8>) -> AttachInputAction {
|
||||
const PREFIX: u8 = 0x02; // Ctrl+B
|
||||
|
||||
let mut output = Vec::with_capacity(data.len());
|
||||
for byte in data {
|
||||
if self.pending_prefix {
|
||||
self.pending_prefix = false;
|
||||
match byte {
|
||||
b'q' => return AttachInputAction::Detach,
|
||||
PREFIX => output.push(PREFIX),
|
||||
other => {
|
||||
output.push(PREFIX);
|
||||
output.push(other);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte == PREFIX {
|
||||
self.pending_prefix = true;
|
||||
} else {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
AttachInputAction::None
|
||||
} else {
|
||||
AttachInputAction::Forward(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
|
|
@ -153,22 +201,42 @@ impl From<protocol::FramingError> for ClientError {
|
|||
///
|
||||
/// Returns a guard that restores the terminal when dropped.
|
||||
fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
|
||||
setup_terminal_with_capabilities(true, mouse_capture)
|
||||
}
|
||||
|
||||
/// Sets up a direct attach terminal.
|
||||
///
|
||||
/// Direct attach forwards stdin to the attached PTY, so it must not enable
|
||||
/// outer-terminal protocols that generate local responses or mouse reports.
|
||||
fn setup_direct_attach_terminal() -> io::Result<TerminalGuard> {
|
||||
setup_terminal_with_capabilities(false, false)
|
||||
}
|
||||
|
||||
fn setup_terminal_with_capabilities(
|
||||
enable_client_protocols: bool,
|
||||
mouse_capture: bool,
|
||||
) -> io::Result<TerminalGuard> {
|
||||
ratatui::init();
|
||||
if mouse_capture {
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
|
||||
if enable_client_protocols {
|
||||
if mouse_capture {
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
}
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)?;
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
}
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)?;
|
||||
|
||||
// tmux doesn't understand kitty keyboard protocol push.
|
||||
// Enable modifyOtherKeys mode 2 for tmux.
|
||||
let in_tmux = std::env::var("TMUX").is_ok();
|
||||
// Enable modifyOtherKeys mode 2 for tmux only for the full app client.
|
||||
let in_tmux = enable_client_protocols && std::env::var("TMUX").is_ok();
|
||||
if in_tmux {
|
||||
io::stdout().write_all(b"\x1b[>4;2m")?;
|
||||
io::stdout().flush()?;
|
||||
|
|
@ -311,15 +379,41 @@ enum ClientLoopEvent {
|
|||
///
|
||||
/// This is the entry point called from `main.rs` when running in client mode.
|
||||
pub fn run_client() -> io::Result<()> {
|
||||
run_client_with_mode(
|
||||
requested_render_encoding(),
|
||||
None,
|
||||
None,
|
||||
"connecting to server",
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs a direct terminal attach client.
|
||||
pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
|
||||
run_client_with_mode(
|
||||
RenderEncoding::TerminalAnsi,
|
||||
Some((terminal_id, takeover)),
|
||||
Some(AttachEscapeState::default()),
|
||||
"attaching to terminal",
|
||||
)
|
||||
}
|
||||
|
||||
fn run_client_with_mode(
|
||||
requested_encoding: RenderEncoding,
|
||||
attach_request: Option<(String, bool)>,
|
||||
attach_escape: Option<AttachEscapeState>,
|
||||
log_message: &'static str,
|
||||
) -> io::Result<()> {
|
||||
init_logging();
|
||||
|
||||
let loaded_config = crate::config::Config::load();
|
||||
let sound_config = loaded_config.config.ui.sound;
|
||||
let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics;
|
||||
let direct_attach_requested = attach_request.is_some();
|
||||
let kitty_graphics_enabled =
|
||||
loaded_config.config.experimental.kitty_graphics && !direct_attach_requested;
|
||||
|
||||
let socket_path = client_socket_path();
|
||||
crate::logging::startup("client");
|
||||
info!(path = %socket_path.display(), "connecting to server");
|
||||
info!(path = %socket_path.display(), "{log_message}");
|
||||
|
||||
// Try to connect to the server.
|
||||
let mut stream = match UnixStream::connect(&socket_path) {
|
||||
|
|
@ -336,8 +430,6 @@ pub fn run_client() -> io::Result<()> {
|
|||
let (cols, rows, cell_width_px, cell_height_px) =
|
||||
current_terminal_geometry(kitty_graphics_enabled);
|
||||
|
||||
let requested_encoding = requested_render_encoding();
|
||||
|
||||
// Perform handshake while the stream is still in blocking mode.
|
||||
let negotiated_encoding = match do_handshake(
|
||||
&mut stream,
|
||||
|
|
@ -354,10 +446,26 @@ pub fn run_client() -> io::Result<()> {
|
|||
}
|
||||
};
|
||||
|
||||
// Now set up the terminal (raw mode, mouse, keyboard enhancements).
|
||||
// This must happen AFTER the handshake succeeds, so we don't leave
|
||||
// the terminal in raw mode if the server rejects us.
|
||||
let _guard = setup_terminal(false).map_err(|err| {
|
||||
if let Some((terminal_id, takeover)) = attach_request {
|
||||
let attach = ClientMessage::AttachTerminal {
|
||||
terminal_id,
|
||||
takeover,
|
||||
};
|
||||
if let Err(err) = write_to_server(&mut stream, &attach) {
|
||||
eprintln!("herdr: failed to request terminal attach: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Now set up the terminal. This must happen AFTER the handshake succeeds,
|
||||
// so we don't leave the terminal in raw mode if the server rejects us.
|
||||
let direct_attach = attach_escape.is_some();
|
||||
let _guard = if direct_attach {
|
||||
setup_direct_attach_terminal()
|
||||
} else {
|
||||
setup_terminal(false)
|
||||
}
|
||||
.map_err(|err| {
|
||||
eprintln!("herdr: failed to set up terminal: {err}");
|
||||
err
|
||||
})?;
|
||||
|
|
@ -394,6 +502,7 @@ pub fn run_client() -> io::Result<()> {
|
|||
kitty_graphics_enabled,
|
||||
false,
|
||||
negotiated_encoding,
|
||||
attach_escape,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
|
@ -439,6 +548,7 @@ async fn run_client_loop(
|
|||
kitty_graphics_enabled: bool,
|
||||
mouse_capture_active: bool,
|
||||
negotiated_encoding: RenderEncoding,
|
||||
attach_escape: Option<AttachEscapeState>,
|
||||
) -> Result<(), ClientError> {
|
||||
let mut state = ClientState {
|
||||
blit_encoder: blit::BlitEncoder::new(),
|
||||
|
|
@ -446,6 +556,7 @@ async fn run_client_loop(
|
|||
reported_size: (cols, rows),
|
||||
sound_config,
|
||||
kitty_graphics_enabled,
|
||||
attach_escape,
|
||||
};
|
||||
debug!(?negotiated_encoding, "client render encoding active");
|
||||
|
||||
|
|
@ -459,7 +570,9 @@ async fn run_client_loop(
|
|||
input::stdin_reader_loop(stdin_tx, &stdin_quit);
|
||||
});
|
||||
|
||||
query_host_terminal_theme();
|
||||
if state.attach_escape.is_none() {
|
||||
query_host_terminal_theme();
|
||||
}
|
||||
|
||||
// Spawn the resize poller thread.
|
||||
let resize_quit = should_quit.clone();
|
||||
|
|
@ -503,10 +616,22 @@ async fn run_client_loop(
|
|||
|
||||
match event {
|
||||
ClientLoopEvent::StdinInput(data) => {
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
|
||||
if crate::raw_input::events_require_host_surface_redraw(&events) {
|
||||
state.request_full_redraw();
|
||||
}
|
||||
let data = if let Some(attach_escape) = &mut state.attach_escape {
|
||||
match attach_escape.filter_input(data) {
|
||||
AttachInputAction::Forward(data) => data,
|
||||
AttachInputAction::Detach => {
|
||||
let _ = write_to_server(&mut write_stream, &ClientMessage::Detach);
|
||||
return Ok(());
|
||||
}
|
||||
AttachInputAction::None => continue,
|
||||
}
|
||||
} else {
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
|
||||
if crate::raw_input::events_require_host_surface_redraw(&events) {
|
||||
state.request_full_redraw();
|
||||
}
|
||||
data
|
||||
};
|
||||
let msg = ClientMessage::Input { data };
|
||||
if let Err(e) = write_to_server(&mut write_stream, &msg) {
|
||||
return Err(ClientError::ConnectionLost(e));
|
||||
|
|
@ -1000,6 +1125,45 @@ mod tests {
|
|||
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_escape_detaches_on_prefix_q() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02]),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![b'q']),
|
||||
AttachInputAction::Detach
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_escape_sends_literal_prefix_on_double_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02]),
|
||||
AttachInputAction::None
|
||||
));
|
||||
match escape.filter_input(vec![0x02]) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]),
|
||||
other => panic!("expected forwarded prefix, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_escape_forwards_prefix_before_non_escape_key() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![b'a', 0x02]),
|
||||
AttachInputAction::Forward(bytes) if bytes == b"a"
|
||||
));
|
||||
match escape.filter_input(vec![b'x']) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']),
|
||||
other => panic!("expected forwarded bytes, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_connection_failed() {
|
||||
let err = ClientError::ConnectionFailed(io::Error::new(
|
||||
|
|
|
|||
|
|
@ -362,27 +362,25 @@ fn collect_visible_placements(
|
|||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let tab = match app
|
||||
if app
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.and_then(crate::workspace::Workspace::active_tab)
|
||||
.is_none()
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::debug!(ws_idx, "collect_visible_placements: no active tab");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
tracing::debug!(ws_idx, "collect_visible_placements: no active tab");
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
ws_idx,
|
||||
tab_runtimes_len = tab.runtimes.len(),
|
||||
terminal_runtimes_len = app.terminal_runtimes.len(),
|
||||
pane_infos_len = app.view.pane_infos.len(),
|
||||
"collect_visible_placements: starting iteration"
|
||||
);
|
||||
let mut placements = Vec::new();
|
||||
for info in &app.view.pane_infos {
|
||||
let runtime = match tab.runtimes.get(&info.id) {
|
||||
let runtime = match app.runtime_for_pane_in_workspace(ws_idx, info.id) {
|
||||
Some(rt) => rt,
|
||||
None => {
|
||||
tracing::debug!(pane_id = ?info.id, "collect_visible_placements: runtime not found");
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ pub(crate) struct GhosttyPaneTerminal {
|
|||
pub(crate) struct GhosttyPaneCore {
|
||||
pub terminal: crate::ghostty::Terminal,
|
||||
pub render_state: crate::ghostty::RenderState,
|
||||
pub initial_default_foreground: Option<crate::ghostty::RgbColor>,
|
||||
pub initial_default_background: Option<crate::ghostty::RgbColor>,
|
||||
pub host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
pub transient_default_color_owner_pgid: Option<u32>,
|
||||
|
|
@ -263,11 +264,12 @@ impl GhosttyPaneTerminal {
|
|||
|
||||
let mut render_state =
|
||||
crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let initial_default_background = render_state
|
||||
let initial_colors = render_state
|
||||
.update(&terminal)
|
||||
.ok()
|
||||
.and_then(|_| render_state.colors().ok())
|
||||
.map(|colors| colors.background);
|
||||
.and_then(|_| render_state.colors().ok());
|
||||
let initial_default_foreground = initial_colors.map(|colors| colors.foreground);
|
||||
let initial_default_background = initial_colors.map(|colors| colors.background);
|
||||
let mut key_encoder =
|
||||
crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
key_encoder.set_from_terminal(&terminal);
|
||||
|
|
@ -275,6 +277,7 @@ impl GhosttyPaneTerminal {
|
|||
core: Mutex::new(GhosttyPaneCore {
|
||||
terminal,
|
||||
render_state,
|
||||
initial_default_foreground,
|
||||
initial_default_background,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
|
||||
transient_default_color_owner_pgid: None,
|
||||
|
|
@ -705,6 +708,7 @@ impl GhosttyPaneTerminal {
|
|||
return;
|
||||
};
|
||||
let host_theme = core.host_terminal_theme;
|
||||
let initial_default_foreground = core.initial_default_foreground;
|
||||
let initial_default_background = core.initial_default_background;
|
||||
let GhosttyPaneCore {
|
||||
terminal,
|
||||
|
|
@ -717,7 +721,9 @@ impl GhosttyPaneTerminal {
|
|||
let colors = render_state.colors().ok();
|
||||
let default_bg = colors
|
||||
.and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background));
|
||||
let default_fg = colors.map(|c| ghostty_color(c.foreground));
|
||||
let default_fg = colors
|
||||
.and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
|
||||
let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
|
||||
let resolved_bg = colors.map(|c| ghostty_color(c.background));
|
||||
|
||||
let mut row_iterator = match crate::ghostty::RowIterator::new() {
|
||||
|
|
@ -745,7 +751,13 @@ impl GhosttyPaneTerminal {
|
|||
let mut x = 0u16;
|
||||
while x < area.width && cells.next() {
|
||||
let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
|
||||
let style = ghostty_cell_style(&cells, default_fg, default_bg, resolved_bg);
|
||||
let style = ghostty_cell_style(
|
||||
&cells,
|
||||
default_fg,
|
||||
default_bg,
|
||||
resolved_fg,
|
||||
resolved_bg,
|
||||
);
|
||||
let symbol = match ghostty_buffer_symbol_into(
|
||||
&cells,
|
||||
wide,
|
||||
|
|
@ -1063,6 +1075,7 @@ fn ghostty_cell_style(
|
|||
cells: &crate::ghostty::RowCellIter<'_>,
|
||||
default_fg: Option<Color>,
|
||||
default_bg: Option<Color>,
|
||||
resolved_fg: Option<Color>,
|
||||
resolved_bg: Option<Color>,
|
||||
) -> Style {
|
||||
let style_data = cells.style().unwrap_or_default();
|
||||
|
|
@ -1092,7 +1105,7 @@ fn ghostty_cell_style(
|
|||
bg = resolved_bg;
|
||||
}
|
||||
if fg.is_none() {
|
||||
fg = default_fg;
|
||||
fg = resolved_fg;
|
||||
}
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
|
|
@ -1127,6 +1140,24 @@ fn ghostty_cell_style(
|
|||
style.add_modifier(modifiers)
|
||||
}
|
||||
|
||||
fn ghostty_default_fg(
|
||||
color: crate::ghostty::RgbColor,
|
||||
host_theme: crate::terminal_theme::TerminalTheme,
|
||||
initial_default_foreground: Option<crate::ghostty::RgbColor>,
|
||||
) -> Option<Color> {
|
||||
if let Some(host_foreground) = host_theme.foreground {
|
||||
if host_foreground == terminal_theme_color(color) {
|
||||
None
|
||||
} else {
|
||||
Some(ghostty_color(color))
|
||||
}
|
||||
} else if initial_default_foreground.is_some_and(|initial| initial != color) {
|
||||
Some(ghostty_color(color))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ghostty_default_bg(
|
||||
color: crate::ghostty::RgbColor,
|
||||
host_theme: crate::terminal_theme::TerminalTheme,
|
||||
|
|
@ -1737,11 +1768,37 @@ mod tests {
|
|||
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "h");
|
||||
assert_eq!(buffer[(0, 0)].style().fg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(2, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_keeps_explicit_cell_foreground_when_host_is_unknown() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
|
||||
{
|
||||
let mut core = pane.core.lock().unwrap();
|
||||
core.terminal.write(b"\x1b[38;2;68;85;102mhi\x1b[0m");
|
||||
}
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(20, 5);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
|
||||
.unwrap();
|
||||
|
||||
let buffer = terminal.backend().buffer();
|
||||
let expected_fg = Some(Color::Rgb(0x44, 0x55, 0x66));
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "h");
|
||||
assert_eq!(buffer[(0, 0)].style().fg, expected_fg);
|
||||
assert_eq!(buffer[(2, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_keeps_explicit_cell_background_when_host_is_unknown() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
|
@ -1872,11 +1929,50 @@ mod tests {
|
|||
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "h");
|
||||
assert_eq!(buffer[(0, 0)].style().fg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(2, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset));
|
||||
assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_keeps_explicit_default_foreground_when_it_differs_from_host() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
|
||||
let host_theme = crate::terminal_theme::TerminalTheme {
|
||||
foreground: Some(crate::terminal_theme::RgbColor {
|
||||
r: 0xaa,
|
||||
g: 0xbb,
|
||||
b: 0xcc,
|
||||
}),
|
||||
background: Some(crate::terminal_theme::RgbColor {
|
||||
r: 0x11,
|
||||
g: 0x22,
|
||||
b: 0x33,
|
||||
}),
|
||||
};
|
||||
pane.apply_host_terminal_theme(host_theme);
|
||||
{
|
||||
let mut core = pane.core.lock().unwrap();
|
||||
core.terminal.write(b"\x1b]10;rgb:44/55/66\x1b\\hi");
|
||||
}
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(20, 5);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
|
||||
.unwrap();
|
||||
|
||||
let buffer = terminal.backend().buffer();
|
||||
let expected_fg = Some(Color::Rgb(0x44, 0x55, 0x66));
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "h");
|
||||
assert_eq!(buffer[(0, 0)].style().fg, expected_fg);
|
||||
assert_eq!(buffer[(2, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(2, 0)].style().fg, expected_fg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_keeps_explicit_default_background_when_it_differs_from_host() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ pub(crate) enum ServerEvent {
|
|||
},
|
||||
/// A client sent an input message.
|
||||
ClientInput { client_id: u64, data: Vec<u8> },
|
||||
/// A client requested direct attach to one terminal.
|
||||
ClientAttachTerminal {
|
||||
client_id: u64,
|
||||
terminal_id: String,
|
||||
takeover: bool,
|
||||
},
|
||||
/// A client sent a resize message.
|
||||
ClientResize {
|
||||
client_id: u64,
|
||||
|
|
@ -354,6 +360,14 @@ fn client_read_loop(
|
|||
}
|
||||
}
|
||||
ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
|
||||
ClientMessage::AttachTerminal {
|
||||
terminal_id,
|
||||
takeover,
|
||||
} => ServerEvent::ClientAttachTerminal {
|
||||
client_id,
|
||||
terminal_id,
|
||||
takeover,
|
||||
},
|
||||
ClientMessage::Hello { .. } => {
|
||||
// Duplicate Hello — ignore.
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use tokio::sync::mpsc;
|
|||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::api;
|
||||
use crate::app;
|
||||
|
|
@ -200,8 +201,24 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
|
|||
// Connected client state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ClientConnectionMode {
|
||||
App,
|
||||
TerminalAttach { terminal_id: String },
|
||||
}
|
||||
|
||||
type RenderTarget = (
|
||||
u64,
|
||||
(u16, u16),
|
||||
crate::kitty_graphics::HostCellSize,
|
||||
bool,
|
||||
ClientConnectionMode,
|
||||
);
|
||||
|
||||
/// A connected client tracked by the server.
|
||||
struct ClientConnection {
|
||||
/// Whether this connection is the full app client or a direct terminal attach.
|
||||
mode: ClientConnectionMode,
|
||||
/// The client's terminal size (after clamping).
|
||||
terminal_size: (u16, u16),
|
||||
/// Pixel size of one client terminal cell.
|
||||
|
|
@ -225,6 +242,7 @@ struct ClientConnection {
|
|||
}
|
||||
|
||||
impl ClientConnection {
|
||||
#[cfg(test)]
|
||||
fn new(
|
||||
terminal_size: (u16, u16),
|
||||
cell_size: crate::kitty_graphics::HostCellSize,
|
||||
|
|
@ -233,8 +251,31 @@ impl ClientConnection {
|
|||
last_activity: u64,
|
||||
render_encoding: RenderEncoding,
|
||||
writer: Option<ClientWriter>,
|
||||
) -> Self {
|
||||
Self::new_with_mode(
|
||||
ClientConnectionMode::App,
|
||||
terminal_size,
|
||||
cell_size,
|
||||
host_terminal_theme,
|
||||
outer_terminal_focus,
|
||||
last_activity,
|
||||
render_encoding,
|
||||
writer,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_mode(
|
||||
mode: ClientConnectionMode,
|
||||
terminal_size: (u16, u16),
|
||||
cell_size: crate::kitty_graphics::HostCellSize,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
outer_terminal_focus: Option<bool>,
|
||||
last_activity: u64,
|
||||
render_encoding: RenderEncoding,
|
||||
writer: Option<ClientWriter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
terminal_size,
|
||||
cell_size,
|
||||
host_terminal_theme,
|
||||
|
|
@ -291,6 +332,8 @@ pub struct HeadlessServer {
|
|||
next_client_id: u64,
|
||||
/// The client currently driving the shared pane runtime size and theme.
|
||||
foreground_client_id: Option<u64>,
|
||||
/// Writable direct attach owner per terminal id string.
|
||||
terminal_attach_owners: HashMap<String, u64>,
|
||||
/// Monotonic activity counter used to pick the most recently active client.
|
||||
next_activity_stamp: u64,
|
||||
/// Shared pane runtime size derived from the foreground client,
|
||||
|
|
@ -336,6 +379,7 @@ impl HeadlessServer {
|
|||
clients: HashMap::new(),
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
terminal_attach_owners: HashMap::new(),
|
||||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
|
|
@ -596,6 +640,7 @@ impl HeadlessServer {
|
|||
let next_foreground = self
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App))
|
||||
.max_by_key(|(_, client)| client.last_activity)
|
||||
.map(|(&client_id, _)| client_id);
|
||||
let changed = next_foreground != self.foreground_client_id;
|
||||
|
|
@ -607,7 +652,20 @@ impl HeadlessServer {
|
|||
fn remove_client(&mut self, client_id: u64) -> bool {
|
||||
let was_foreground = self.foreground_client_id == Some(client_id);
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
self.clients.remove(&client_id);
|
||||
let removed = self.clients.remove(&client_id);
|
||||
if let Some(ClientConnection {
|
||||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||||
..
|
||||
}) = removed
|
||||
{
|
||||
self.terminal_attach_owners.remove(&terminal_id);
|
||||
if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) {
|
||||
self.app
|
||||
.state
|
||||
.direct_attach_resize_locks
|
||||
.remove(&terminal_id);
|
||||
}
|
||||
}
|
||||
if was_foreground {
|
||||
self.promote_latest_remaining_client()
|
||||
} else {
|
||||
|
|
@ -759,6 +817,23 @@ impl HeadlessServer {
|
|||
changed
|
||||
}
|
||||
|
||||
fn terminal_id_by_string(&self, terminal_id: &str) -> Option<crate::terminal::TerminalId> {
|
||||
self.app
|
||||
.state
|
||||
.terminals
|
||||
.keys()
|
||||
.find(|id| id.to_string() == terminal_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn runtime_for_terminal_id_string(
|
||||
&self,
|
||||
terminal_id: &str,
|
||||
) -> Option<&crate::terminal::TerminalRuntime> {
|
||||
let terminal_id = self.terminal_id_by_string(terminal_id)?;
|
||||
self.app.state.terminal_runtimes.get(&terminal_id)
|
||||
}
|
||||
|
||||
fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
|
||||
self.app
|
||||
.state
|
||||
|
|
@ -1000,6 +1075,26 @@ impl HeadlessServer {
|
|||
|
||||
true
|
||||
}
|
||||
AppEvent::PaneDied { pane_id } => {
|
||||
let terminal_id = self.app.state.workspaces.iter().find_map(|ws| {
|
||||
ws.tabs.iter().find_map(|tab| {
|
||||
tab.panes
|
||||
.get(pane_id)
|
||||
.map(|pane| pane.attached_terminal_id.to_string())
|
||||
})
|
||||
});
|
||||
|
||||
self.app.handle_internal_event(ev);
|
||||
|
||||
if let Some(terminal_id) = terminal_id {
|
||||
self.shutdown_terminal_attach_clients(
|
||||
&terminal_id,
|
||||
format!("terminal {terminal_id} exited"),
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.app.handle_internal_event(ev);
|
||||
true
|
||||
|
|
@ -1130,6 +1225,104 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) {
|
||||
let client_ids: Vec<u64> = self
|
||||
.clients
|
||||
.iter()
|
||||
.filter_map(|(&client_id, client)| match &client.mode {
|
||||
ClientConnectionMode::TerminalAttach {
|
||||
terminal_id: attached,
|
||||
} if attached == terminal_id => Some(client_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for client_id in client_ids {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(reason.clone()),
|
||||
},
|
||||
);
|
||||
let foreground_changed = self.remove_client(client_id);
|
||||
if foreground_changed {
|
||||
self.resize_shared_runtime_to_effective_size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_terminal_client(
|
||||
&mut self,
|
||||
client_id: u64,
|
||||
terminal_id: String,
|
||||
takeover: bool,
|
||||
) -> bool {
|
||||
let Some(real_terminal_id) = self.terminal_id_by_string(&terminal_id) else {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(format!(
|
||||
"terminal attach failed: terminal {terminal_id} not found"
|
||||
)),
|
||||
},
|
||||
);
|
||||
self.remove_client(client_id);
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() {
|
||||
if existing_owner != client_id && !takeover {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(format!(
|
||||
"terminal attach failed: terminal {terminal_id} already has an attached client; retry with --takeover"
|
||||
)),
|
||||
},
|
||||
);
|
||||
self.remove_client(client_id);
|
||||
return false;
|
||||
}
|
||||
if existing_owner != client_id {
|
||||
self.send_to_client(
|
||||
existing_owner,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some("terminal attach taken over".to_owned()),
|
||||
},
|
||||
);
|
||||
self.remove_client(existing_owner);
|
||||
}
|
||||
}
|
||||
|
||||
let stamp = self.allocate_activity_stamp();
|
||||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||||
return false;
|
||||
};
|
||||
let (cols, rows) = client.terminal_size;
|
||||
let cell_size = client.cell_size;
|
||||
client.mode = ClientConnectionMode::TerminalAttach {
|
||||
terminal_id: terminal_id.clone(),
|
||||
};
|
||||
client.render_state.reset_baseline();
|
||||
client.last_activity = stamp;
|
||||
let was_foreground = self.foreground_client_id == Some(client_id);
|
||||
if was_foreground {
|
||||
self.promote_latest_remaining_client();
|
||||
}
|
||||
|
||||
info!(client_id, cols, rows, terminal_id = %terminal_id, "terminal attach client connected");
|
||||
self.terminal_attach_owners
|
||||
.insert(terminal_id.clone(), client_id);
|
||||
self.app
|
||||
.state
|
||||
.direct_attach_resize_locks
|
||||
.insert(real_terminal_id.clone());
|
||||
if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) {
|
||||
runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Handles a server event. Returns true if the event requires a re-render.
|
||||
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
|
||||
match ev {
|
||||
|
|
@ -1154,7 +1347,8 @@ impl HeadlessServer {
|
|||
let last_activity = self.allocate_activity_stamp();
|
||||
self.clients.insert(
|
||||
client_id,
|
||||
ClientConnection::new(
|
||||
ClientConnection::new_with_mode(
|
||||
ClientConnectionMode::App,
|
||||
(cols, rows),
|
||||
crate::kitty_graphics::HostCellSize {
|
||||
width_px: cell_width_px,
|
||||
|
|
@ -1172,8 +1366,25 @@ impl HeadlessServer {
|
|||
self.resize_shared_runtime_to_effective_size();
|
||||
true
|
||||
}
|
||||
ServerEvent::ClientAttachTerminal {
|
||||
client_id,
|
||||
terminal_id,
|
||||
takeover,
|
||||
} => self.attach_terminal_client(client_id, terminal_id, takeover),
|
||||
ServerEvent::ClientInput { client_id, data } => {
|
||||
debug!(client_id, len = data.len(), "client input received");
|
||||
if let Some(ClientConnection {
|
||||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||||
..
|
||||
}) = self.clients.get(&client_id)
|
||||
{
|
||||
if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) {
|
||||
if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) {
|
||||
warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
|
||||
let host_surface_redraw =
|
||||
crate::raw_input::events_require_host_surface_redraw(&events);
|
||||
|
|
@ -1251,6 +1462,30 @@ impl HeadlessServer {
|
|||
client_id,
|
||||
cols, rows, cell_width_px, cell_height_px, "client resize"
|
||||
);
|
||||
let direct_terminal_id = if let Some(ClientConnection {
|
||||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||||
terminal_size,
|
||||
cell_size,
|
||||
render_state,
|
||||
..
|
||||
}) = self.clients.get_mut(&client_id)
|
||||
{
|
||||
*terminal_size = (cols, rows);
|
||||
*cell_size = crate::kitty_graphics::HostCellSize {
|
||||
width_px: cell_width_px,
|
||||
height_px: cell_height_px,
|
||||
};
|
||||
render_state.reset_baseline();
|
||||
Some(terminal_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(terminal_id) = direct_terminal_id {
|
||||
if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) {
|
||||
runtime.resize(rows, cols, cell_width_px, cell_height_px);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
client.terminal_size = (cols, rows);
|
||||
client.cell_size = crate::kitty_graphics::HostCellSize {
|
||||
|
|
@ -1508,6 +1743,9 @@ impl HeadlessServer {
|
|||
|
||||
let mut broken_clients: Vec<u64> = Vec::new();
|
||||
for (&client_id, client) in &mut self.clients {
|
||||
if !matches!(client.mode, ClientConnectionMode::App) {
|
||||
continue;
|
||||
}
|
||||
if client.host_mouse_capture_active == Some(enabled) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1537,21 +1775,23 @@ impl HeadlessServer {
|
|||
/// frames to all connected clients.
|
||||
fn render_and_stream(&mut self) {
|
||||
let foreground_client_id = self.foreground_client_id;
|
||||
let mut render_targets: Vec<(u64, (u16, u16), crate::kitty_graphics::HostCellSize, bool)> =
|
||||
self.clients
|
||||
.iter()
|
||||
.filter(|(_, client)| client.writer.is_some())
|
||||
.map(|(&client_id, client)| {
|
||||
(
|
||||
client_id,
|
||||
client.terminal_size,
|
||||
client.cell_size,
|
||||
foreground_client_id == Some(client_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut render_targets: Vec<RenderTarget> = self
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|(_, client)| client.writer.is_some())
|
||||
.map(|(&client_id, client)| {
|
||||
(
|
||||
client_id,
|
||||
client.terminal_size,
|
||||
client.cell_size,
|
||||
foreground_client_id == Some(client_id),
|
||||
client.mode.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
|
||||
render_targets
|
||||
.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id));
|
||||
|
||||
if render_targets.is_empty() {
|
||||
let (cols, rows) = self.effective_size;
|
||||
|
|
@ -1570,32 +1810,55 @@ impl HeadlessServer {
|
|||
}
|
||||
|
||||
let mut broken_clients: Vec<u64> = Vec::new();
|
||||
for (client_id, (cols, rows), cell_size, is_foreground) in render_targets {
|
||||
for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets {
|
||||
let area = Rect::new(0, 0, cols, rows);
|
||||
let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known()
|
||||
{
|
||||
crate::server::render_stream::render_virtual_with_cell_size(
|
||||
&mut self.app.state,
|
||||
area,
|
||||
is_foreground,
|
||||
cell_size,
|
||||
)
|
||||
} else {
|
||||
crate::server::render_stream::render_virtual(
|
||||
&mut self.app.state,
|
||||
area,
|
||||
is_foreground,
|
||||
)
|
||||
let is_app_client = matches!(mode, ClientConnectionMode::App);
|
||||
let mut frame = match mode {
|
||||
ClientConnectionMode::App => {
|
||||
let (buffer, cursor) =
|
||||
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||||
crate::server::render_stream::render_virtual_with_cell_size(
|
||||
&mut self.app.state,
|
||||
area,
|
||||
is_foreground,
|
||||
cell_size,
|
||||
)
|
||||
} else {
|
||||
crate::server::render_stream::render_virtual(
|
||||
&mut self.app.state,
|
||||
area,
|
||||
is_foreground,
|
||||
)
|
||||
};
|
||||
let hyperlinks =
|
||||
crate::server::render_stream::visible_hyperlinks(&self.app.state);
|
||||
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
|
||||
}
|
||||
ClientConnectionMode::TerminalAttach { terminal_id } => {
|
||||
let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(format!(
|
||||
"terminal attach ended: terminal {terminal_id} not found"
|
||||
)),
|
||||
},
|
||||
);
|
||||
broken_clients.push(client_id);
|
||||
continue;
|
||||
};
|
||||
let (buffer, cursor) =
|
||||
crate::server::render_stream::render_terminal_virtual(runtime, area);
|
||||
let hyperlinks = runtime.visible_hyperlinks(area);
|
||||
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks)
|
||||
}
|
||||
};
|
||||
let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state);
|
||||
let mut frame =
|
||||
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks);
|
||||
|
||||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||||
continue;
|
||||
};
|
||||
let mut next_graphics_cache = client.graphics_cache.clone();
|
||||
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||||
if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||||
frame.graphics = crate::kitty_graphics::encode_local_pane_graphics(
|
||||
&self.app.state,
|
||||
cell_size,
|
||||
|
|
@ -1999,6 +2262,7 @@ mod tests {
|
|||
clients: HashMap::new(),
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
terminal_attach_owners: HashMap::new(),
|
||||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
|
|
@ -2020,6 +2284,13 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> {
|
||||
match read_server_message(bytes) {
|
||||
ServerMessage::ServerShutdown { reason } => reason,
|
||||
other => panic!("expected shutdown, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_client_writer() -> (
|
||||
ClientWriter,
|
||||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
|
|
@ -2037,6 +2308,77 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_attach_rejects_missing_terminal_and_removes_client() {
|
||||
let mut server = test_headless_server();
|
||||
let (writer, control_rx, _render_rx) = test_client_writer();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
writer,
|
||||
}));
|
||||
assert!(server.clients.contains_key(&7));
|
||||
|
||||
assert!(
|
||||
!server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||||
client_id: 7,
|
||||
terminal_id: "term_missing".to_owned(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
assert!(!server.clients.contains_key(&7));
|
||||
let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
|
||||
assert_eq!(
|
||||
reason,
|
||||
Some("terminal attach failed: terminal term_missing not found".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_attach_client_exits_when_attached_pane_dies() {
|
||||
let mut server = test_headless_server();
|
||||
let workspace = crate::workspace::Workspace::test_new("attached");
|
||||
let pane_id = workspace.tabs[0].root_pane;
|
||||
server.app.state.workspaces = vec![workspace];
|
||||
server.app.state.ensure_test_terminals();
|
||||
let terminal_id = server.app.state.workspaces[0]
|
||||
.pane_state(pane_id)
|
||||
.expect("pane")
|
||||
.attached_terminal_id
|
||||
.to_string();
|
||||
let (writer, control_rx, _render_rx) = test_client_writer();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
render_encoding: RenderEncoding::TerminalAnsi,
|
||||
writer,
|
||||
}));
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||||
client_id: 7,
|
||||
terminal_id: terminal_id.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7));
|
||||
|
||||
assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id }));
|
||||
|
||||
assert!(!server.clients.contains_key(&7));
|
||||
assert!(!server.terminal_attach_owners.contains_key(&terminal_id));
|
||||
let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
|
||||
assert_eq!(reason, Some(format!("terminal {terminal_id} exited")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_socket_path_derived_from_api_socket_override() {
|
||||
let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ pub enum ClientMessage {
|
|||
|
||||
/// Graceful disconnect request.
|
||||
Detach,
|
||||
|
||||
/// Switch this connection into direct terminal attach mode.
|
||||
AttachTerminal {
|
||||
/// Terminal id to attach to.
|
||||
terminal_id: String,
|
||||
/// Replace an existing writable attach owner for this terminal.
|
||||
takeover: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -647,6 +655,18 @@ mod tests {
|
|||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_attach_terminal_roundtrip() {
|
||||
let msg = ClientMessage::AttachTerminal {
|
||||
terminal_id: "term_123".to_owned(),
|
||||
takeover: true,
|
||||
};
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ClientMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
// ---- Round-trip: ServerMessage ----
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -258,6 +258,34 @@ pub(crate) fn render_virtual_with_cell_size(
|
|||
(buffer, cursor)
|
||||
}
|
||||
|
||||
/// Renders one server-owned terminal directly for `terminal attach` clients.
|
||||
pub(crate) fn render_terminal_virtual(
|
||||
runtime: &crate::terminal::TerminalRuntime,
|
||||
area: Rect,
|
||||
) -> (ratatui::buffer::Buffer, Option<CursorState>) {
|
||||
let backend = CursorTrackingBackend::new(area.width, area.height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail");
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
runtime.render(frame, area, true);
|
||||
})
|
||||
.expect("render to TestBackend should never fail");
|
||||
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let cursor = runtime
|
||||
.cursor_state(area, true)
|
||||
.map(|cursor| CursorState {
|
||||
x: cursor.x,
|
||||
y: cursor.y,
|
||||
visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime),
|
||||
shape: cursor.shape,
|
||||
})
|
||||
.or_else(|| terminal.backend().rendered_cursor());
|
||||
|
||||
(buffer, cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> {
|
||||
let Some(ws_idx) = app_state.active else {
|
||||
return Vec::new();
|
||||
|
|
|
|||
|
|
@ -59,13 +59,15 @@ fn runtime_for_tab_pane<'a>(
|
|||
app: &'a AppState,
|
||||
tab: &'a crate::workspace::Tab,
|
||||
pane_id: crate::layout::PaneId,
|
||||
) -> Option<&'a TerminalRuntime> {
|
||||
) -> Option<(&'a crate::terminal::TerminalId, &'a TerminalRuntime)> {
|
||||
let terminal_id = tab.terminal_id(pane_id)?;
|
||||
#[cfg(test)]
|
||||
if let Some(runtime) = tab.runtimes.get(&pane_id) {
|
||||
return Some(runtime);
|
||||
return Some((terminal_id, runtime));
|
||||
}
|
||||
let terminal_id = tab.terminal_id(pane_id)?;
|
||||
app.terminal_runtimes.get(terminal_id)
|
||||
app.terminal_runtimes
|
||||
.get(terminal_id)
|
||||
.map(|runtime| (terminal_id, runtime))
|
||||
}
|
||||
|
||||
fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) {
|
||||
|
|
@ -98,14 +100,16 @@ pub(super) fn resize_tab_panes(
|
|||
|
||||
if tab.zoomed {
|
||||
let focused_id = tab.layout.focused();
|
||||
if let Some(rt) = runtime_for_tab_pane(app, tab, focused_id) {
|
||||
if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, focused_id) {
|
||||
let inner_rect = stable_terminal_inner_rect(area);
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
cell_size.width_px,
|
||||
cell_size.height_px,
|
||||
);
|
||||
if !app.direct_attach_resize_locks.contains(terminal_id) {
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
cell_size.width_px,
|
||||
cell_size.height_px,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -117,14 +121,16 @@ pub(super) fn resize_tab_panes(
|
|||
area
|
||||
};
|
||||
|
||||
if let Some(rt) = runtime_for_tab_pane(app, tab, info.id) {
|
||||
if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, info.id) {
|
||||
let inner_rect = stable_terminal_inner_rect(pane_inner);
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
cell_size.width_px,
|
||||
cell_size.height_px,
|
||||
);
|
||||
if !app.direct_attach_resize_locks.contains(terminal_id) {
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
cell_size.width_px,
|
||||
cell_size.height_px,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +158,11 @@ pub(super) fn compute_pane_infos(
|
|||
let mut scrollbar_rect = None;
|
||||
if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) {
|
||||
(inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area);
|
||||
if resize_panes {
|
||||
if resize_panes
|
||||
&& ws.terminal_id(focused_id).is_some_and(|terminal_id| {
|
||||
!app.direct_attach_resize_locks.contains(terminal_id)
|
||||
})
|
||||
{
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
|
|
@ -191,7 +201,11 @@ pub(super) fn compute_pane_infos(
|
|||
let mut scrollbar_rect = None;
|
||||
if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) {
|
||||
(inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner);
|
||||
if resize_panes {
|
||||
if resize_panes
|
||||
&& ws.terminal_id(info.id).is_some_and(|terminal_id| {
|
||||
!app.direct_attach_resize_locks.contains(terminal_id)
|
||||
})
|
||||
{
|
||||
rt.resize(
|
||||
inner_rect.height,
|
||||
inner_rect.width,
|
||||
|
|
|
|||
|
|
@ -720,15 +720,25 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn workspace_identity_uses_identity_cwd() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"herdr-workspace-identity-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let identity_cwd = root.join("pion");
|
||||
std::fs::create_dir_all(identity_cwd.join(".git")).unwrap();
|
||||
|
||||
let mut ws = Workspace::test_new("ignored");
|
||||
ws.custom_name = None;
|
||||
ws.identity_cwd = PathBuf::from("/herdr-test/pion");
|
||||
ws.identity_cwd = identity_cwd.clone();
|
||||
|
||||
assert_eq!(ws.display_name(), "pion");
|
||||
assert_eq!(
|
||||
ws.resolved_identity_cwd(),
|
||||
Some(PathBuf::from("/herdr-test/pion"))
|
||||
);
|
||||
assert_eq!(ws.resolved_identity_cwd(), Some(identity_cwd));
|
||||
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue