mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
522 lines
20 KiB
Rust
522 lines
20 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use crate::api::schema::{EventData, EventEnvelope, EventKind};
|
|
#[cfg(test)]
|
|
use tracing::error;
|
|
|
|
use super::{
|
|
api_helpers::{pane_agent_status, tab_attention_priority},
|
|
App, Mode,
|
|
};
|
|
use crate::{config::NewTerminalCwdConfig, workspace::Workspace};
|
|
|
|
pub(crate) fn resolve_new_terminal_cwd(
|
|
policy: &NewTerminalCwdConfig,
|
|
follow_cwd: Option<PathBuf>,
|
|
) -> PathBuf {
|
|
match policy {
|
|
NewTerminalCwdConfig::Follow => follow_cwd
|
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
|
|
.or_else(|| std::env::current_dir().ok())
|
|
.unwrap_or_else(|| PathBuf::from("/")),
|
|
NewTerminalCwdConfig::Home => std::env::var_os("HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::current_dir().ok())
|
|
.unwrap_or_else(|| PathBuf::from("/")),
|
|
NewTerminalCwdConfig::Current => {
|
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))
|
|
}
|
|
NewTerminalCwdConfig::Path(path) => crate::worktree::expand_tilde_path(path),
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<PathBuf> {
|
|
self.state
|
|
.workspaces
|
|
.get(ws_idx)?
|
|
.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
|
|
}
|
|
|
|
pub(super) fn follow_cwd_for_pane_in_workspace(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: crate::layout::PaneId,
|
|
) -> Option<PathBuf> {
|
|
let ws = self.state.workspaces.get(ws_idx)?;
|
|
let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
|
|
ws.tabs.get(tab_idx)?.follow_cwd_for_pane(
|
|
pane_id,
|
|
&self.state.terminals,
|
|
&self.terminal_runtimes,
|
|
)
|
|
}
|
|
|
|
pub(super) fn focused_pane_cwd_in_workspace(&self, ws_idx: usize) -> Option<PathBuf> {
|
|
let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
|
|
self.follow_cwd_for_pane_in_workspace(ws_idx, pane_id)
|
|
}
|
|
|
|
pub(super) fn resolve_new_terminal_cwd(&self, follow_cwd: Option<PathBuf>) -> PathBuf {
|
|
resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd)
|
|
}
|
|
|
|
pub(super) fn workspace_creation_source(&self) -> Option<usize> {
|
|
if self.state.mode == Mode::Navigate
|
|
&& self.state.workspaces.get(self.state.selected).is_some()
|
|
{
|
|
return Some(self.state.selected);
|
|
}
|
|
|
|
self.state.active.or_else(|| {
|
|
self.state
|
|
.workspaces
|
|
.get(self.state.selected)
|
|
.map(|_| self.state.selected)
|
|
})
|
|
}
|
|
|
|
pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) {
|
|
if self.state.prompt_new_workspace_name {
|
|
let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| {
|
|
self.focused_pane_cwd_in_workspace(ws_idx)
|
|
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
|
});
|
|
let cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
|
super::input::open_new_workspace_dialog(&mut self.state, cwd);
|
|
return;
|
|
}
|
|
|
|
self.runtime_workspace_create(
|
|
request_id,
|
|
crate::api::schema::WorkspaceCreateParams {
|
|
cwd: None,
|
|
focus: true,
|
|
label: None,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
self.state.mode = if self.state.active.is_some() {
|
|
Mode::Terminal
|
|
} else {
|
|
Mode::Navigate
|
|
};
|
|
}
|
|
|
|
/// Create a workspace with a real PTY (needs event_tx).
|
|
#[cfg(test)]
|
|
pub(crate) fn create_workspace(&mut self) {
|
|
let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| {
|
|
self.focused_pane_cwd_in_workspace(ws_idx)
|
|
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
|
});
|
|
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
|
if let Err(e) = self.create_workspace_with_events(initial_cwd, true) {
|
|
error!(err = %e, "failed to create workspace");
|
|
self.state.mode = Mode::Navigate;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn create_tab(&mut self) {
|
|
let custom_name = self.state.requested_new_tab_name.take();
|
|
let active_before = self.state.active;
|
|
let follow_cwd = self.state.active.and_then(|ws_idx| {
|
|
self.focused_pane_cwd_in_workspace(ws_idx)
|
|
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
|
});
|
|
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
|
match self.create_tab_with_options(initial_cwd, true) {
|
|
Ok(created_idx) => {
|
|
let created_workspace = active_before.is_none();
|
|
let ws_idx = if created_workspace {
|
|
Some(created_idx)
|
|
} else {
|
|
self.state.active
|
|
};
|
|
let tab_idx = if created_workspace { 0 } else { created_idx };
|
|
if let Some(name) = custom_name {
|
|
if let Some(ws) =
|
|
ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx))
|
|
{
|
|
if let Some(tab) = ws.tabs.get_mut(tab_idx) {
|
|
tab.set_custom_name(name);
|
|
}
|
|
self.schedule_session_save();
|
|
}
|
|
}
|
|
if let Some(ws_idx) = ws_idx {
|
|
if created_workspace {
|
|
self.emit_workspace_open_events(ws_idx);
|
|
} else {
|
|
self.emit_tab_created_events(ws_idx, tab_idx);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!(err = %e, "failed to create tab");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn create_tab_with_options(
|
|
&mut self,
|
|
initial_cwd: PathBuf,
|
|
focus: bool,
|
|
) -> std::io::Result<usize> {
|
|
let Some(ws_idx) = self.state.active else {
|
|
return self.create_workspace_with_options(initial_cwd, focus);
|
|
};
|
|
let (rows, cols) = self.state.estimate_pane_size();
|
|
let ws = &mut self.state.workspaces[ws_idx];
|
|
let (idx, terminal, runtime) = ws.create_tab(
|
|
rows,
|
|
cols,
|
|
initial_cwd,
|
|
self.state.pane_scrollback_limit_bytes,
|
|
self.state.host_terminal_theme,
|
|
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
|
Vec::new(),
|
|
)?;
|
|
let root_pane = ws.tabs[idx].root_pane;
|
|
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
|
|
self.state.terminals.insert(terminal.id.clone(), terminal);
|
|
self.state.remove_alias_shadowed_by_new_pane(root_pane);
|
|
if focus {
|
|
self.state.switch_workspace_tab(ws_idx, idx);
|
|
self.state.mode = Mode::Terminal;
|
|
}
|
|
let workspace_id = self.state.workspaces[ws_idx].id.clone();
|
|
let tab_id = self
|
|
.public_tab_id(ws_idx, idx)
|
|
.unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1));
|
|
let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw();
|
|
crate::logging::tab_created(&workspace_id, &tab_id, root_pane);
|
|
self.schedule_session_save();
|
|
Ok(idx)
|
|
}
|
|
|
|
pub(crate) fn create_workspace_with_options(
|
|
&mut self,
|
|
initial_cwd: PathBuf,
|
|
focus: bool,
|
|
) -> std::io::Result<usize> {
|
|
self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn create_workspace_with_events(
|
|
&mut self,
|
|
initial_cwd: PathBuf,
|
|
focus: bool,
|
|
) -> std::io::Result<()> {
|
|
let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?;
|
|
self.emit_workspace_open_events(ws_idx);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn create_workspace_with_launch_env(
|
|
&mut self,
|
|
initial_cwd: PathBuf,
|
|
focus: bool,
|
|
extra_env: Vec<(String, String)>,
|
|
) -> std::io::Result<usize> {
|
|
let (rows, cols) = self.state.estimate_pane_size();
|
|
let (ws, terminal, runtime) = Workspace::new_with_extra_env(
|
|
initial_cwd,
|
|
rows,
|
|
cols,
|
|
self.state.pane_scrollback_limit_bytes,
|
|
self.state.host_terminal_theme,
|
|
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
|
self.event_tx.clone(),
|
|
self.render_notify.clone(),
|
|
self.render_dirty.clone(),
|
|
extra_env,
|
|
)?;
|
|
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
|
|
self.state.terminals.insert(terminal.id.clone(), terminal);
|
|
self.state.workspaces.push(ws);
|
|
let idx = self.state.workspaces.len() - 1;
|
|
self.state
|
|
.remove_alias_shadowed_by_new_pane(self.state.workspaces[idx].tabs[0].root_pane);
|
|
let workspace_id = self.state.workspaces[idx].id.clone();
|
|
let root_pane = self.state.workspaces[idx].tabs[0].root_pane.raw();
|
|
crate::logging::workspace_created(&workspace_id, root_pane);
|
|
if focus || self.state.active.is_none() {
|
|
self.state.switch_workspace(idx);
|
|
self.state.mode = Mode::Terminal;
|
|
}
|
|
self.schedule_session_save();
|
|
Ok(idx)
|
|
}
|
|
|
|
pub(super) fn collect_panes_for_workspace(
|
|
&self,
|
|
workspace_id: Option<&str>,
|
|
) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> {
|
|
if let Some(workspace_id) = workspace_id {
|
|
let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
|
|
return Err((
|
|
"workspace_not_found".into(),
|
|
format!("workspace {workspace_id} not found"),
|
|
));
|
|
};
|
|
let Some(ws) = self.state.workspaces.get(ws_idx) else {
|
|
return Err((
|
|
"workspace_not_found".into(),
|
|
format!("workspace {workspace_id} not found"),
|
|
));
|
|
};
|
|
Ok(ws
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|tab| tab.layout.pane_ids().into_iter())
|
|
.filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
|
|
.collect())
|
|
} else {
|
|
Ok(self
|
|
.state
|
|
.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(ws_idx, ws)| {
|
|
ws.tabs
|
|
.iter()
|
|
.flat_map(|tab| tab.layout.pane_ids().into_iter())
|
|
.filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
|
|
})
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
pub(super) fn tab_info(
|
|
&self,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
) -> Option<crate::api::schema::TabInfo> {
|
|
let ws = self.state.workspaces.get(ws_idx)?;
|
|
let tab = ws.tabs.get(tab_idx)?;
|
|
let (agg_state, seen) = tab
|
|
.panes
|
|
.values()
|
|
.filter_map(|pane| {
|
|
self.state
|
|
.terminals
|
|
.get(&pane.attached_terminal_id)
|
|
.map(|terminal| (terminal.state, pane.seen))
|
|
})
|
|
.max_by_key(|(state, seen)| tab_attention_priority(*state, *seen))
|
|
.unwrap_or((crate::detect::AgentState::Unknown, true));
|
|
Some(crate::api::schema::TabInfo {
|
|
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
|
|
workspace_id: self.public_workspace_id(ws_idx),
|
|
number: tab.number,
|
|
label: ws.tab_display_name(tab_idx)?,
|
|
focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx,
|
|
pane_count: tab.panes.len(),
|
|
agent_status: pane_agent_status(agg_state, seen),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn emit_workspace_open_events(&mut self, ws_idx: usize) {
|
|
let workspace_info = self.workspace_info(ws_idx);
|
|
let Some(tab) = self.tab_info(ws_idx, 0) else {
|
|
return;
|
|
};
|
|
let Some(root_pane) = self.root_pane_info(ws_idx, 0) else {
|
|
return;
|
|
};
|
|
self.emit_event(EventEnvelope {
|
|
event: EventKind::WorkspaceCreated,
|
|
data: EventData::WorkspaceCreated {
|
|
workspace: workspace_info,
|
|
},
|
|
});
|
|
self.emit_tab_and_pane_created_events(tab, root_pane);
|
|
self.emit_layout_updated_event(ws_idx, 0);
|
|
}
|
|
|
|
pub(crate) fn emit_tab_created_events(&mut self, ws_idx: usize, tab_idx: usize) {
|
|
let Some(tab) = self.tab_info(ws_idx, tab_idx) else {
|
|
return;
|
|
};
|
|
let Some(root_pane) = self.root_pane_info(ws_idx, tab_idx) else {
|
|
return;
|
|
};
|
|
self.emit_tab_and_pane_created_events(tab, root_pane);
|
|
self.emit_layout_updated_event(ws_idx, tab_idx);
|
|
}
|
|
|
|
fn emit_tab_and_pane_created_events(
|
|
&mut self,
|
|
tab: crate::api::schema::TabInfo,
|
|
root_pane: crate::api::schema::PaneInfo,
|
|
) {
|
|
self.emit_event(EventEnvelope {
|
|
event: EventKind::TabCreated,
|
|
data: EventData::TabCreated { tab },
|
|
});
|
|
self.emit_event(EventEnvelope {
|
|
event: EventKind::PaneCreated,
|
|
data: EventData::PaneCreated { pane: root_pane },
|
|
});
|
|
}
|
|
|
|
pub(super) fn workspace_created_result(
|
|
&self,
|
|
ws_idx: usize,
|
|
) -> Option<crate::api::schema::ResponseResult> {
|
|
Some(crate::api::schema::ResponseResult::WorkspaceCreated {
|
|
workspace: self.workspace_info(ws_idx),
|
|
tab: self.tab_info(ws_idx, 0)?,
|
|
root_pane: self.root_pane_info(ws_idx, 0)?,
|
|
})
|
|
}
|
|
|
|
pub(super) fn tab_created_result(
|
|
&self,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
) -> Option<crate::api::schema::ResponseResult> {
|
|
Some(crate::api::schema::ResponseResult::TabCreated {
|
|
tab: self.tab_info(ws_idx, tab_idx)?,
|
|
root_pane: self.root_pane_info(ws_idx, tab_idx)?,
|
|
})
|
|
}
|
|
|
|
pub(super) fn root_pane_info(
|
|
&self,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
) -> Option<crate::api::schema::PaneInfo> {
|
|
let ws = self.state.workspaces.get(ws_idx)?;
|
|
let tab = ws.tabs.get(tab_idx)?;
|
|
self.pane_info(ws_idx, tab.root_pane)
|
|
}
|
|
|
|
pub(super) fn pane_info(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: crate::layout::PaneId,
|
|
) -> Option<crate::api::schema::PaneInfo> {
|
|
let ws = self.state.workspaces.get(ws_idx)?;
|
|
let pane = ws.pane_state(pane_id)?;
|
|
let terminal = self.state.terminals.get(&pane.attached_terminal_id)?;
|
|
let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
|
|
let scroll = self
|
|
.state
|
|
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
|
.and_then(|runtime| runtime.scroll_metrics())
|
|
.map(|metrics| crate::api::schema::PaneScrollInfo {
|
|
offset_from_bottom: metrics.offset_from_bottom as u64,
|
|
max_offset_from_bottom: metrics.max_offset_from_bottom as u64,
|
|
viewport_rows: metrics.viewport_rows as u64,
|
|
});
|
|
let focused = self.state.active == Some(ws_idx)
|
|
&& ws.active_tab == tab_idx
|
|
&& ws
|
|
.focused_pane_id()
|
|
.is_some_and(|focused| focused == pane_id);
|
|
let presentation = terminal.effective_presentation();
|
|
Some(crate::api::schema::PaneInfo {
|
|
pane_id: self.public_pane_id(ws_idx, pane_id)?,
|
|
terminal_id: terminal.id.to_string(),
|
|
workspace_id: self.public_workspace_id(ws_idx),
|
|
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
|
|
focused,
|
|
cwd: ws.tabs[tab_idx]
|
|
.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
|
|
.map(|cwd| cwd.display().to_string()),
|
|
foreground_cwd: ws.tabs[tab_idx]
|
|
.foreground_cwd_for_pane(pane_id, &self.terminal_runtimes)
|
|
.map(|cwd| cwd.display().to_string()),
|
|
label: terminal.manual_label.clone(),
|
|
agent: terminal.effective_agent_label().map(str::to_string),
|
|
title: presentation.title,
|
|
terminal_title: terminal.terminal_title.clone(),
|
|
terminal_title_stripped: terminal.terminal_title_stripped(),
|
|
display_agent: presentation.display_agent,
|
|
agent_status: pane_agent_status(terminal.state, pane.seen),
|
|
state_labels: presentation.state_labels,
|
|
tokens: terminal.metadata_tokens.values(),
|
|
agent_session: terminal_agent_session_info(terminal),
|
|
scroll,
|
|
revision: terminal.revision,
|
|
})
|
|
}
|
|
|
|
pub(super) fn lookup_runtime(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: crate::layout::PaneId,
|
|
) -> Option<(&crate::terminal::TerminalRuntime, String)> {
|
|
let runtime =
|
|
self.state
|
|
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
|
|
Some((runtime, self.public_workspace_id(ws_idx)))
|
|
}
|
|
|
|
pub(super) fn lookup_runtime_sender(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: crate::layout::PaneId,
|
|
) -> Option<&crate::terminal::TerminalRuntime> {
|
|
self.state
|
|
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
|
}
|
|
|
|
pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
|
|
let ws = &self.state.workspaces[index];
|
|
let (agg_state, seen) = ws.aggregate_state(&self.state.terminals);
|
|
crate::api::schema::WorkspaceInfo {
|
|
workspace_id: self.public_workspace_id(index),
|
|
number: index + 1,
|
|
label: ws.display_name_from(&self.state.terminals, &self.terminal_runtimes),
|
|
focused: self.state.active == Some(index),
|
|
pane_count: ws.public_pane_numbers.len(),
|
|
tab_count: ws.tabs.len(),
|
|
active_tab_id: self.public_tab_id(index, ws.active_tab).unwrap_or_else(|| {
|
|
crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1)
|
|
}),
|
|
agent_status: pane_agent_status(agg_state, seen),
|
|
tokens: ws.metadata_tokens.values(),
|
|
worktree: ws
|
|
.worktree_space()
|
|
.map(|space| crate::api::schema::WorkspaceWorktreeInfo {
|
|
repo_key: space.key.clone(),
|
|
repo_name: space.label.clone(),
|
|
repo_root: space.repo_root.display().to_string(),
|
|
checkout_path: space.checkout_path.display().to_string(),
|
|
is_linked_worktree: space.is_linked_worktree,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn terminal_agent_session_info(
|
|
terminal: &crate::terminal::TerminalState,
|
|
) -> Option<crate::api::schema::AgentSessionInfo> {
|
|
if let Some(authority) = terminal.hook_authority.as_ref() {
|
|
if let Some(session_ref) = authority.session_ref.as_ref() {
|
|
return Some(crate::api::schema::AgentSessionInfo {
|
|
source: authority.source.clone(),
|
|
agent: authority.agent_label.clone(),
|
|
kind: session_ref.kind,
|
|
value: session_ref.value.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
terminal
|
|
.persisted_agent_session
|
|
.as_ref()
|
|
.map(|session| crate::api::schema::AgentSessionInfo {
|
|
source: session.source.clone(),
|
|
agent: session.agent.clone(),
|
|
kind: session.session_ref.kind,
|
|
value: session.session_ref.value.clone(),
|
|
})
|
|
}
|