5956 lines
208 KiB
Rust
5956 lines
208 KiB
Rust
//! Pure state mutations on AppState.
|
|
//! These don't need channels, async, or PTY runtime.
|
|
|
|
use std::time::Instant;
|
|
|
|
use tracing::{info, warn};
|
|
|
|
use crate::detect::{Agent, AgentState};
|
|
use crate::events::AppEvent;
|
|
use crate::layout::PaneId;
|
|
#[cfg(test)]
|
|
use crate::layout::{find_in_direction, NavDirection};
|
|
use crate::selection::Selection;
|
|
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
|
|
use crate::workspace::WorkspaceGitStatus;
|
|
|
|
use super::api_helpers::pane_agent_status;
|
|
use super::state::{
|
|
navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after,
|
|
text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow,
|
|
NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind,
|
|
ToastNotification, ToastTarget, ViewLayout,
|
|
};
|
|
|
|
fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool {
|
|
matches!(new_state, AgentState::Idle)
|
|
&& matches!(prev_state, AgentState::Working | AgentState::Blocked)
|
|
}
|
|
|
|
fn is_completion_transition(change: &EffectiveStateChange) -> bool {
|
|
is_completion_transition_parts(
|
|
change.previous_state,
|
|
change.state,
|
|
change.previous_agent_label.as_deref(),
|
|
change.agent_label.as_deref(),
|
|
)
|
|
}
|
|
|
|
fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> Option<String> {
|
|
let tab_number = ws.public_tab_number(tab_idx)?;
|
|
Some(crate::workspace::public_tab_id_for_number(
|
|
&ws.id, tab_number,
|
|
))
|
|
}
|
|
|
|
pub fn is_completion_transition_parts(
|
|
previous_state: AgentState,
|
|
state: AgentState,
|
|
previous_agent_label: Option<&str>,
|
|
agent_label: Option<&str>,
|
|
) -> bool {
|
|
is_background_completion_transition(previous_state, state)
|
|
|| (previous_state == AgentState::Unknown
|
|
&& state == AgentState::Idle
|
|
&& previous_agent_label.is_some()
|
|
&& previous_agent_label == agent_label)
|
|
}
|
|
|
|
pub fn active_tab_suppresses_notifications(
|
|
is_active_tab: bool,
|
|
outer_terminal_focus: Option<bool>,
|
|
) -> bool {
|
|
is_active_tab && outer_terminal_focus != Some(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn notification_sound_for_state_change(
|
|
suppress_active_tab_notifications: bool,
|
|
prev_state: AgentState,
|
|
new_state: AgentState,
|
|
) -> Option<crate::sound::Sound> {
|
|
if new_state == prev_state {
|
|
return None;
|
|
}
|
|
|
|
match new_state {
|
|
AgentState::Blocked => Some(crate::sound::Sound::Request),
|
|
AgentState::Idle
|
|
if is_background_completion_transition(prev_state, new_state)
|
|
&& !suppress_active_tab_notifications =>
|
|
{
|
|
Some(crate::sound::Sound::Done)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn notification_sound_for_state_change_with_agent_labels(
|
|
suppress_active_tab_notifications: bool,
|
|
prev_state: AgentState,
|
|
new_state: AgentState,
|
|
previous_agent_label: Option<&str>,
|
|
agent_label: Option<&str>,
|
|
) -> Option<crate::sound::Sound> {
|
|
if new_state == prev_state {
|
|
return None;
|
|
}
|
|
|
|
match new_state {
|
|
AgentState::Blocked => Some(crate::sound::Sound::Request),
|
|
AgentState::Idle
|
|
if is_completion_transition_parts(
|
|
prev_state,
|
|
new_state,
|
|
previous_agent_label,
|
|
agent_label,
|
|
) && !suppress_active_tab_notifications =>
|
|
{
|
|
Some(crate::sound::Sound::Done)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn notification_sound_for_effective_state_change(
|
|
suppress_active_tab_notifications: bool,
|
|
change: &EffectiveStateChange,
|
|
) -> Option<crate::sound::Sound> {
|
|
if change.state == change.previous_state {
|
|
return None;
|
|
}
|
|
|
|
match change.state {
|
|
AgentState::Blocked => Some(crate::sound::Sound::Request),
|
|
AgentState::Idle
|
|
if is_completion_transition(change) && !suppress_active_tab_notifications =>
|
|
{
|
|
Some(crate::sound::Sound::Done)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn notification_toast_for_state_change_with_agent_labels(
|
|
suppress_active_tab_notifications: bool,
|
|
prev_state: AgentState,
|
|
new_state: AgentState,
|
|
previous_agent_label: Option<&str>,
|
|
agent_label: Option<&str>,
|
|
) -> Option<ToastKind> {
|
|
if suppress_active_tab_notifications || new_state == prev_state {
|
|
return None;
|
|
}
|
|
|
|
match new_state {
|
|
AgentState::Blocked => Some(ToastKind::NeedsAttention),
|
|
AgentState::Idle
|
|
if is_completion_transition_parts(
|
|
prev_state,
|
|
new_state,
|
|
previous_agent_label,
|
|
agent_label,
|
|
) =>
|
|
{
|
|
Some(ToastKind::Finished)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn notification_toast_for_effective_state_change(
|
|
suppress_active_tab_notifications: bool,
|
|
change: &EffectiveStateChange,
|
|
) -> Option<ToastKind> {
|
|
if suppress_active_tab_notifications || change.state == change.previous_state {
|
|
return None;
|
|
}
|
|
|
|
match change.state {
|
|
AgentState::Blocked => Some(ToastKind::NeedsAttention),
|
|
AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn notification_toast_for_pane_state_update(
|
|
suppress_active_tab_notifications: bool,
|
|
update: &PaneStateUpdate,
|
|
) -> Option<ToastKind> {
|
|
if suppress_active_tab_notifications || update.state == update.previous_state {
|
|
return None;
|
|
}
|
|
|
|
notification_toast_for_state_change_with_agent_labels(
|
|
suppress_active_tab_notifications,
|
|
update.previous_state,
|
|
update.state,
|
|
update.previous_agent_label.as_deref(),
|
|
update.agent_label.as_deref(),
|
|
)
|
|
}
|
|
|
|
fn toast_agent_label(agent_label: &str) -> &str {
|
|
agent_label
|
|
}
|
|
|
|
fn toast_event_text(kind: ToastKind) -> &'static str {
|
|
match kind {
|
|
ToastKind::NeedsAttention => "needs attention",
|
|
ToastKind::Finished => "finished",
|
|
ToastKind::UpdateInstalled => "updated",
|
|
}
|
|
}
|
|
|
|
fn sound_for_toast_kind(
|
|
kind: ToastKind,
|
|
suppress_active_tab_notifications: bool,
|
|
) -> Option<crate::sound::Sound> {
|
|
match kind {
|
|
ToastKind::NeedsAttention => Some(crate::sound::Sound::Request),
|
|
ToastKind::Finished if !suppress_active_tab_notifications => {
|
|
Some(crate::sound::Sound::Done)
|
|
}
|
|
ToastKind::Finished | ToastKind::UpdateInstalled => None,
|
|
}
|
|
}
|
|
|
|
pub fn notification_context(
|
|
ws: &crate::workspace::Workspace,
|
|
workspace_label: &str,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
) -> String {
|
|
let mut context = format!("{} · {}", workspace_label, ws_idx + 1);
|
|
if ws.tabs.len() > 1 {
|
|
if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) {
|
|
if let Some(label) = ws.tab_display_name(tab_idx) {
|
|
context.push_str(&format!(" · {label}"));
|
|
}
|
|
}
|
|
}
|
|
context
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PaneStateUpdate {
|
|
pub pane_id: PaneId,
|
|
pub ws_idx: usize,
|
|
pub previous_agent_label: Option<String>,
|
|
pub previous_known_agent: Option<Agent>,
|
|
pub previous_state: AgentState,
|
|
pub previous_seen: bool,
|
|
pub previous_presentation: crate::terminal::EffectivePresentation,
|
|
pub agent_label: Option<String>,
|
|
pub known_agent: Option<Agent>,
|
|
pub state: AgentState,
|
|
pub seen: bool,
|
|
pub presentation: crate::terminal::EffectivePresentation,
|
|
pub agent_name_changed: bool,
|
|
pub agent_released: bool,
|
|
pub agent_release_status: Option<crate::api::schema::AgentStatus>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Navigator operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl AppState {
|
|
pub(crate) fn current_pane_focus_target(&self) -> Option<PaneFocusTarget> {
|
|
let ws_idx = self.active?;
|
|
let ws = self.workspaces.get(ws_idx)?;
|
|
let pane_id = ws.focused_pane_id()?;
|
|
Some(PaneFocusTarget {
|
|
workspace_id: ws.id.clone(),
|
|
pane_id,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn pane_focus_target_indices(
|
|
&self,
|
|
target: &PaneFocusTarget,
|
|
) -> Option<(usize, usize)> {
|
|
let ws_idx = self
|
|
.workspaces
|
|
.iter()
|
|
.position(|ws| ws.id == target.workspace_id)?;
|
|
let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?;
|
|
Some((ws_idx, tab_idx))
|
|
}
|
|
|
|
pub(crate) fn record_pane_focus_change(
|
|
&mut self,
|
|
previous: Option<PaneFocusTarget>,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
) {
|
|
let Some(ws) = self.workspaces.get(ws_idx) else {
|
|
return;
|
|
};
|
|
let target = PaneFocusTarget {
|
|
workspace_id: ws.id.clone(),
|
|
pane_id,
|
|
};
|
|
if previous.as_ref() != Some(&target) {
|
|
self.previous_pane_focus = previous;
|
|
}
|
|
}
|
|
|
|
fn record_pane_focus_after_navigation(&mut self, previous: Option<PaneFocusTarget>) {
|
|
let current = self.current_pane_focus_target();
|
|
if previous != current {
|
|
self.previous_pane_focus = previous;
|
|
}
|
|
}
|
|
|
|
fn sync_selection_after_focus_navigation(&mut self) {
|
|
if self.copy_mode.is_some() {
|
|
self.sync_copy_mode_with_focus();
|
|
} else {
|
|
self.clear_selection();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool {
|
|
let Some(ws) = self.workspaces.get(ws_idx) else {
|
|
return false;
|
|
};
|
|
let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else {
|
|
return false;
|
|
};
|
|
let previous = self.current_pane_focus_target();
|
|
let target = PaneFocusTarget {
|
|
workspace_id: ws.id.clone(),
|
|
pane_id,
|
|
};
|
|
if previous.as_ref() == Some(&target) {
|
|
return false;
|
|
}
|
|
|
|
if self.copy_mode.is_some() {
|
|
self.clear_copy_mode_selection();
|
|
}
|
|
self.switch_workspace_tab(ws_idx, tab_idx);
|
|
if let Some(tab) = self
|
|
.workspaces
|
|
.get_mut(ws_idx)
|
|
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
|
{
|
|
tab.layout.focus_pane(pane_id);
|
|
self.previous_pane_focus = previous;
|
|
self.mark_session_dirty();
|
|
self.sync_copy_mode_with_focus();
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn open_navigator(&mut self) {
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
self.open_navigator_from(&terminal_runtimes);
|
|
}
|
|
|
|
pub(crate) fn open_navigator_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
self.navigator.query.clear();
|
|
self.navigator.search_focused = false;
|
|
self.navigator.state_filter = None;
|
|
self.navigator.scroll = 0;
|
|
self.navigator.expanded_workspaces.clear();
|
|
|
|
for ws in &self.workspaces {
|
|
self.navigator.expanded_workspaces.insert(ws.id.clone());
|
|
}
|
|
|
|
self.mode = Mode::Navigator;
|
|
self.navigator.selected = self
|
|
.current_navigator_row_index_from(terminal_runtimes)
|
|
.unwrap_or(0);
|
|
self.ensure_navigator_selection_visible_from(terminal_runtimes);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn navigator_rows(&self) -> Vec<NavigatorRow> {
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
self.navigator_rows_from(&terminal_runtimes)
|
|
}
|
|
|
|
pub(crate) fn navigator_rows_from(
|
|
&self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) -> Vec<NavigatorRow> {
|
|
let query = self.navigator.query.trim().to_lowercase();
|
|
let query_kind = navigator_query_kind(&query, self.navigator.state_filter);
|
|
let mut rows = Vec::new();
|
|
for (ws_idx, ws) in self.workspaces.iter().enumerate() {
|
|
let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes);
|
|
let activity = workspace_activity_summary(ws, &self.terminals);
|
|
let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase();
|
|
let workspace_matches = match query_kind {
|
|
NavigatorQueryKind::Empty => true,
|
|
NavigatorQueryKind::State(filter) => {
|
|
let (state, seen) = ws.aggregate_state(&self.terminals);
|
|
navigator_state_filter_matches(filter, state, seen)
|
|
}
|
|
NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text),
|
|
};
|
|
|
|
let child_rows =
|
|
self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches);
|
|
if !workspace_matches && child_rows.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let expanded = !matches!(query_kind, NavigatorQueryKind::Empty)
|
|
|| self.navigator.expanded_workspaces.contains(&ws.id);
|
|
let (state, seen) = ws.aggregate_state(&self.terminals);
|
|
let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>();
|
|
rows.push(NavigatorRow {
|
|
target: NavigatorTarget::Workspace { ws_idx },
|
|
depth: 0,
|
|
label: format!("{workspace_label} ({pane_count})"),
|
|
meta: activity,
|
|
status: state,
|
|
seen,
|
|
is_current: self.active == Some(ws_idx),
|
|
is_workspace: true,
|
|
is_tab: false,
|
|
expanded,
|
|
search_text: workspace_search_text,
|
|
matched: workspace_matches,
|
|
});
|
|
if expanded {
|
|
rows.extend(child_rows);
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
fn navigator_child_rows(
|
|
&self,
|
|
ws_idx: usize,
|
|
query_kind: NavigatorQueryKind,
|
|
query: &str,
|
|
workspace_matches: bool,
|
|
) -> Vec<NavigatorRow> {
|
|
let Some(ws) = self.workspaces.get(ws_idx) else {
|
|
return Vec::new();
|
|
};
|
|
let multi_tab = ws.tabs.len() > 1;
|
|
let mut rows = Vec::new();
|
|
for tab_idx in 0..ws.tabs.len() {
|
|
let mut tab_row = multi_tab.then(|| self.navigator_tab_row(ws_idx, tab_idx));
|
|
let tab_matches = tab_row.as_ref().is_some_and(|row| match query_kind {
|
|
NavigatorQueryKind::Empty => true,
|
|
NavigatorQueryKind::State(filter) => {
|
|
navigator_state_filter_matches(filter, row.status, row.seen)
|
|
}
|
|
NavigatorQueryKind::Text => navigator_matches(query, &row.search_text),
|
|
});
|
|
if let Some(tab_row) = tab_row.as_mut() {
|
|
tab_row.matched = tab_matches;
|
|
}
|
|
let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab);
|
|
let filtered_panes = match query_kind {
|
|
NavigatorQueryKind::Empty => pane_rows,
|
|
NavigatorQueryKind::State(filter) => pane_rows
|
|
.into_iter()
|
|
.filter(|row| navigator_state_filter_matches(filter, row.status, row.seen))
|
|
.collect::<Vec<_>>(),
|
|
// A matching workspace or tab shows its whole subtree; panes
|
|
// keep their own match flag so context rows can be dimmed.
|
|
NavigatorQueryKind::Text if workspace_matches || tab_matches => {
|
|
for row in pane_rows.iter_mut() {
|
|
row.matched = navigator_matches(query, &row.search_text);
|
|
}
|
|
pane_rows
|
|
}
|
|
NavigatorQueryKind::Text => pane_rows
|
|
.into_iter()
|
|
.filter(|row| navigator_matches(query, &row.search_text))
|
|
.collect::<Vec<_>>(),
|
|
};
|
|
|
|
if let Some(tab_row) = tab_row {
|
|
if tab_matches || !filtered_panes.is_empty() {
|
|
rows.push(tab_row);
|
|
}
|
|
}
|
|
rows.extend(filtered_panes);
|
|
}
|
|
rows
|
|
}
|
|
|
|
fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow {
|
|
let ws = &self.workspaces[ws_idx];
|
|
let tab = &ws.tabs[tab_idx];
|
|
let label = ws
|
|
.tab_display_name(tab_idx)
|
|
.unwrap_or_else(|| (tab_idx + 1).to_string());
|
|
let (status, seen) = tab_aggregate_state(tab, &self.terminals);
|
|
let activity = tab_activity_summary(tab, &self.terminals);
|
|
let pane_count = tab.panes.len();
|
|
let meta = if activity.is_empty() {
|
|
format!("{pane_count} panes")
|
|
} else {
|
|
format!("{pane_count} panes · {activity}")
|
|
};
|
|
let search_text = format!("{label} {meta}").to_lowercase();
|
|
NavigatorRow {
|
|
target: NavigatorTarget::Tab { ws_idx, tab_idx },
|
|
depth: 1,
|
|
label,
|
|
meta,
|
|
status,
|
|
seen,
|
|
is_current: false,
|
|
is_workspace: false,
|
|
is_tab: true,
|
|
expanded: true,
|
|
search_text,
|
|
matched: true,
|
|
}
|
|
}
|
|
|
|
fn navigator_pane_rows_for_tab(
|
|
&self,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
multi_tab: bool,
|
|
) -> Vec<NavigatorRow> {
|
|
let Some(ws) = self.workspaces.get(ws_idx) else {
|
|
return Vec::new();
|
|
};
|
|
let Some(tab) = ws.tabs.get(tab_idx) else {
|
|
return Vec::new();
|
|
};
|
|
let mut rows = Vec::new();
|
|
for pane_id in tab.layout.pane_ids() {
|
|
let Some(pane) = tab.panes.get(&pane_id) else {
|
|
continue;
|
|
};
|
|
let terminal = self.terminals.get(&pane.attached_terminal_id);
|
|
let pane_number = ws.public_pane_number(pane_id).unwrap_or(0);
|
|
let label = terminal
|
|
.and_then(|terminal| terminal.effective_title())
|
|
.or_else(|| {
|
|
terminal
|
|
.and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string))
|
|
})
|
|
.or_else(|| {
|
|
terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string))
|
|
})
|
|
.or_else(|| {
|
|
terminal
|
|
.and_then(|terminal| terminal.effective_agent_label().map(str::to_string))
|
|
})
|
|
.or_else(|| {
|
|
launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref()))
|
|
})
|
|
.unwrap_or_else(|| format!("pane {pane_number}"));
|
|
let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent());
|
|
let agent_label = display_agent.as_deref().or_else(|| {
|
|
terminal
|
|
.and_then(|terminal| terminal.agent_name.as_deref())
|
|
.or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()))
|
|
});
|
|
let state = terminal
|
|
.map(|terminal| terminal.state)
|
|
.unwrap_or(AgentState::Unknown);
|
|
let status_label = terminal
|
|
.map(|terminal| terminal.effective_presentation().state_labels)
|
|
.and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned());
|
|
let status = status_label
|
|
.or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string()));
|
|
let meta = match (agent_label, status.as_deref()) {
|
|
(Some(agent_label), Some(status)) => format!("{agent_label} · {status}"),
|
|
(Some(agent_label), None) => agent_label.to_string(),
|
|
(None, _) => "shell".to_string(),
|
|
};
|
|
let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id);
|
|
let search_text = format!("{label} {meta}").to_lowercase();
|
|
rows.push(NavigatorRow {
|
|
target: NavigatorTarget::Pane {
|
|
ws_idx,
|
|
tab_idx,
|
|
pane_id,
|
|
},
|
|
depth: if multi_tab { 2 } else { 1 },
|
|
label,
|
|
meta,
|
|
status: state,
|
|
seen: pane.seen,
|
|
is_current,
|
|
is_workspace: false,
|
|
is_tab: false,
|
|
expanded: false,
|
|
search_text,
|
|
matched: true,
|
|
});
|
|
}
|
|
rows
|
|
}
|
|
|
|
fn current_navigator_row_index_from(
|
|
&self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) -> Option<usize> {
|
|
let rows = self.navigator_rows_from(terminal_runtimes);
|
|
rows.iter()
|
|
.position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current)
|
|
.or_else(|| rows.iter().position(|row| row.is_current))
|
|
}
|
|
|
|
pub(crate) fn ensure_navigator_selection_visible_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
let body = self.navigator_body_rect();
|
|
let viewport = body.height as usize;
|
|
if viewport == 0 {
|
|
self.navigator.scroll = 0;
|
|
return;
|
|
}
|
|
let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes));
|
|
let max_scroll = lines.len().saturating_sub(viewport);
|
|
let selected_line =
|
|
navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0);
|
|
if selected_line < self.navigator.scroll {
|
|
self.navigator.scroll = selected_line;
|
|
} else if selected_line >= self.navigator.scroll.saturating_add(viewport) {
|
|
self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport);
|
|
}
|
|
self.navigator.scroll = self.navigator.scroll.min(max_scroll);
|
|
}
|
|
|
|
pub(crate) fn navigator_max_scroll_from(
|
|
&self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
viewport: usize,
|
|
) -> usize {
|
|
if viewport == 0 {
|
|
return 0;
|
|
}
|
|
navigator_display_lines(&self.navigator_rows_from(terminal_runtimes))
|
|
.len()
|
|
.saturating_sub(viewport)
|
|
}
|
|
|
|
/// After a mouse-wheel scroll, snap the selection to the first selectable
|
|
/// row at or below the top of the viewport.
|
|
pub(crate) fn align_navigator_selection_to_scroll_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes));
|
|
if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) {
|
|
self.navigator.selected = row_idx;
|
|
}
|
|
self.clamp_navigator_selection_from(terminal_runtimes);
|
|
}
|
|
|
|
pub(crate) fn move_navigator_selection_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
delta: isize,
|
|
) {
|
|
let count = self.navigator_rows_from(terminal_runtimes).len();
|
|
if count == 0 {
|
|
self.navigator.selected = 0;
|
|
self.navigator.scroll = 0;
|
|
return;
|
|
}
|
|
let current = self.navigator.selected.min(count - 1) as isize;
|
|
self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize;
|
|
self.ensure_navigator_selection_visible_from(terminal_runtimes);
|
|
}
|
|
|
|
/// Move the selection by a distance measured in display lines (used for
|
|
/// half-page jumps), landing on the nearest selectable row in the move
|
|
/// direction.
|
|
pub(crate) fn move_navigator_selection_by_lines_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
delta_lines: isize,
|
|
) {
|
|
let rows = self.navigator_rows_from(terminal_runtimes);
|
|
if rows.is_empty() {
|
|
self.navigator.selected = 0;
|
|
self.navigator.scroll = 0;
|
|
return;
|
|
}
|
|
let lines = navigator_display_lines(&rows);
|
|
let current_line =
|
|
navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1))
|
|
.unwrap_or(0);
|
|
let target_line =
|
|
(current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize;
|
|
let row_idx = if delta_lines >= 0 {
|
|
navigator_first_row_at_or_after(&lines, target_line)
|
|
} else {
|
|
lines[..=target_line]
|
|
.iter()
|
|
.rev()
|
|
.find_map(|line| match line {
|
|
super::state::NavigatorDisplayLine::Row(idx) => Some(*idx),
|
|
super::state::NavigatorDisplayLine::Spacer => None,
|
|
})
|
|
};
|
|
if let Some(row_idx) = row_idx {
|
|
self.navigator.selected = row_idx;
|
|
}
|
|
self.ensure_navigator_selection_visible_from(terminal_runtimes);
|
|
}
|
|
|
|
/// After the query or state filter changes, select the first row that
|
|
/// itself matched the filter, so enter immediately accepts the best match.
|
|
/// State filters prefer pane matches over aggregate workspace/tab matches.
|
|
pub(crate) fn select_first_navigator_match_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
let query = self.navigator.query.trim().to_lowercase();
|
|
let query_kind = navigator_query_kind(&query, self.navigator.state_filter);
|
|
if !matches!(query_kind, NavigatorQueryKind::Empty) {
|
|
let rows = self.navigator_rows_from(terminal_runtimes);
|
|
let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) {
|
|
rows.iter()
|
|
.position(|row| {
|
|
row.matched && matches!(row.target, NavigatorTarget::Pane { .. })
|
|
})
|
|
.or_else(|| rows.iter().position(|row| row.matched))
|
|
} else {
|
|
rows.iter().position(|row| row.matched)
|
|
};
|
|
if let Some(idx) = idx {
|
|
self.navigator.selected = idx;
|
|
}
|
|
}
|
|
self.clamp_navigator_selection_from(terminal_runtimes);
|
|
}
|
|
|
|
pub(crate) fn clamp_navigator_selection_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
let count = self.navigator_rows_from(terminal_runtimes).len();
|
|
self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1));
|
|
self.ensure_navigator_selection_visible_from(terminal_runtimes);
|
|
}
|
|
|
|
pub(crate) fn toggle_selected_navigator_workspace_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) {
|
|
let Some(row) = self
|
|
.navigator_rows_from(terminal_runtimes)
|
|
.get(self.navigator.selected)
|
|
.cloned()
|
|
else {
|
|
return;
|
|
};
|
|
let NavigatorTarget::Workspace { ws_idx } = row.target else {
|
|
return;
|
|
};
|
|
let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else {
|
|
return;
|
|
};
|
|
if self.navigator.expanded_workspaces.contains(&workspace_id) {
|
|
self.navigator.expanded_workspaces.remove(&workspace_id);
|
|
} else {
|
|
self.navigator.expanded_workspaces.insert(workspace_id);
|
|
}
|
|
self.clamp_navigator_selection_from(terminal_runtimes);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn accept_navigator_selection(&mut self) -> bool {
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
self.accept_navigator_selection_from(&terminal_runtimes)
|
|
}
|
|
|
|
pub(crate) fn accept_navigator_selection_from(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
) -> bool {
|
|
let Some(row) = self
|
|
.navigator_rows_from(terminal_runtimes)
|
|
.get(self.navigator.selected)
|
|
.cloned()
|
|
else {
|
|
return false;
|
|
};
|
|
self.focus_navigator_target(row.target)
|
|
}
|
|
|
|
pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool {
|
|
match target {
|
|
NavigatorTarget::Workspace { ws_idx } => {
|
|
if ws_idx >= self.workspaces.len() {
|
|
return false;
|
|
}
|
|
self.switch_workspace(ws_idx);
|
|
self.mode = Mode::Terminal;
|
|
true
|
|
}
|
|
NavigatorTarget::Tab { ws_idx, tab_idx } => {
|
|
if ws_idx >= self.workspaces.len() {
|
|
return false;
|
|
}
|
|
let tab_exists = self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.is_some_and(|ws| tab_idx < ws.tabs.len());
|
|
if !tab_exists {
|
|
return false;
|
|
}
|
|
self.switch_workspace_tab(ws_idx, tab_idx);
|
|
self.mode = Mode::Terminal;
|
|
true
|
|
}
|
|
NavigatorTarget::Pane {
|
|
ws_idx,
|
|
tab_idx,
|
|
pane_id,
|
|
} => {
|
|
if ws_idx >= self.workspaces.len() {
|
|
return false;
|
|
}
|
|
if self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.and_then(|ws| ws.tabs.get(tab_idx))
|
|
.is_some_and(|tab| tab.panes.contains_key(&pane_id))
|
|
{
|
|
self.focus_pane_in_workspace(ws_idx, pane_id);
|
|
self.mode = Mode::Terminal;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum NavigatorQueryKind {
|
|
Empty,
|
|
Text,
|
|
State(NavigatorStateFilter),
|
|
}
|
|
|
|
fn navigator_query_kind(
|
|
query: &str,
|
|
state_filter: Option<NavigatorStateFilter>,
|
|
) -> NavigatorQueryKind {
|
|
if let Some(filter) = state_filter {
|
|
return NavigatorQueryKind::State(filter);
|
|
}
|
|
if query.is_empty() {
|
|
NavigatorQueryKind::Empty
|
|
} else {
|
|
NavigatorQueryKind::Text
|
|
}
|
|
}
|
|
|
|
fn navigator_state_filter_matches(
|
|
filter: NavigatorStateFilter,
|
|
state: AgentState,
|
|
seen: bool,
|
|
) -> bool {
|
|
match filter {
|
|
NavigatorStateFilter::Blocked => state == AgentState::Blocked,
|
|
NavigatorStateFilter::Working => state == AgentState::Working,
|
|
NavigatorStateFilter::Idle => state == AgentState::Idle && seen,
|
|
NavigatorStateFilter::Done => state == AgentState::Idle && !seen,
|
|
}
|
|
}
|
|
|
|
fn navigator_matches(query: &str, text: &str) -> bool {
|
|
text_matches_query(query, text)
|
|
}
|
|
|
|
fn launch_label(argv: Option<&Vec<String>>) -> Option<String> {
|
|
let argv = argv?;
|
|
let command = argv.first()?;
|
|
std::path::Path::new(command)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(str::to_string)
|
|
.or_else(|| Some(command.clone()))
|
|
}
|
|
|
|
fn state_label_text(state: AgentState, seen: bool) -> &'static str {
|
|
match (state, seen) {
|
|
(AgentState::Blocked, _) => "blocked",
|
|
(AgentState::Working, _) => "working",
|
|
(AgentState::Idle, false) => "done",
|
|
(AgentState::Idle, true) => "idle",
|
|
(AgentState::Unknown, _) => "unknown",
|
|
}
|
|
}
|
|
|
|
fn tab_aggregate_state(
|
|
tab: &crate::workspace::Tab,
|
|
terminals: &std::collections::HashMap<
|
|
crate::terminal::TerminalId,
|
|
crate::terminal::TerminalState,
|
|
>,
|
|
) -> (AgentState, bool) {
|
|
let mut aggregate = AgentState::Unknown;
|
|
let mut seen = true;
|
|
for pane in tab.panes.values() {
|
|
let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
|
|
continue;
|
|
};
|
|
if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) {
|
|
aggregate = terminal.state;
|
|
seen = pane.seen;
|
|
}
|
|
}
|
|
(aggregate, seen)
|
|
}
|
|
|
|
fn state_priority(state: AgentState, seen: bool) -> u8 {
|
|
match (state, seen) {
|
|
(AgentState::Blocked, _) => 5,
|
|
(AgentState::Working, _) => 4,
|
|
(AgentState::Idle, false) => 3,
|
|
(AgentState::Idle, true) => 2,
|
|
(AgentState::Unknown, _) => 1,
|
|
}
|
|
}
|
|
|
|
fn tab_activity_summary(
|
|
tab: &crate::workspace::Tab,
|
|
terminals: &std::collections::HashMap<
|
|
crate::terminal::TerminalId,
|
|
crate::terminal::TerminalState,
|
|
>,
|
|
) -> String {
|
|
activity_summary_for_panes(tab.panes.values(), terminals)
|
|
}
|
|
|
|
fn workspace_activity_summary(
|
|
ws: &crate::workspace::Workspace,
|
|
terminals: &std::collections::HashMap<
|
|
crate::terminal::TerminalId,
|
|
crate::terminal::TerminalState,
|
|
>,
|
|
) -> String {
|
|
activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals)
|
|
}
|
|
|
|
fn activity_summary_for_panes<'a>(
|
|
panes: impl Iterator<Item = &'a crate::pane::PaneState>,
|
|
terminals: &std::collections::HashMap<
|
|
crate::terminal::TerminalId,
|
|
crate::terminal::TerminalState,
|
|
>,
|
|
) -> String {
|
|
let mut blocked = 0usize;
|
|
let mut working = 0usize;
|
|
let mut done = 0usize;
|
|
for pane in panes {
|
|
let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
|
|
continue;
|
|
};
|
|
match (terminal.state, pane.seen) {
|
|
(AgentState::Blocked, _) => blocked += 1,
|
|
(AgentState::Working, _) => working += 1,
|
|
(AgentState::Idle, false) => done += 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
if blocked > 0 {
|
|
parts.push(format!("{blocked} blocked"));
|
|
}
|
|
if working > 0 {
|
|
parts.push(format!("{working} working"));
|
|
}
|
|
if done > 0 {
|
|
parts.push(format!("{done} done"));
|
|
}
|
|
parts.join(" · ")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl AppState {
|
|
pub(crate) fn next_agent_metadata_expiry(&self) -> Option<std::time::Instant> {
|
|
self.terminals
|
|
.values()
|
|
.filter_map(|terminal| terminal.next_agent_metadata_expiry())
|
|
.chain(
|
|
self.terminals
|
|
.values()
|
|
.filter_map(|terminal| terminal.metadata_tokens.next_expiry()),
|
|
)
|
|
.chain(
|
|
self.workspaces
|
|
.iter()
|
|
.filter_map(|workspace| workspace.metadata_tokens.next_expiry()),
|
|
)
|
|
.min()
|
|
}
|
|
|
|
pub(crate) fn expire_agent_metadata_at(
|
|
&mut self,
|
|
scheduled_deadline: std::time::Instant,
|
|
now: std::time::Instant,
|
|
) -> Vec<PaneStateUpdate> {
|
|
let pane_terminals: Vec<_> = self
|
|
.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(ws_idx, ws)| {
|
|
ws.tabs.iter().flat_map(move |tab| {
|
|
tab.layout
|
|
.pane_ids()
|
|
.into_iter()
|
|
.filter_map(move |pane_id| {
|
|
ws.pane_state(pane_id)
|
|
.map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone()))
|
|
})
|
|
})
|
|
})
|
|
.collect();
|
|
pane_terminals
|
|
.into_iter()
|
|
.filter_map(|(ws_idx, pane_id, terminal_id)| {
|
|
let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen;
|
|
let mutation = self
|
|
.terminals
|
|
.get_mut(&terminal_id)?
|
|
.expire_agent_metadata_at(scheduled_deadline, now)?;
|
|
let change = mutation.effective_state_change?;
|
|
let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?;
|
|
let update = PaneStateUpdate {
|
|
pane_id,
|
|
ws_idx,
|
|
previous_agent_label: change.previous_agent_label.clone(),
|
|
previous_known_agent: change.previous_known_agent,
|
|
previous_state: change.previous_state,
|
|
previous_seen,
|
|
previous_presentation: change.previous_presentation.clone(),
|
|
agent_label: change.agent_label.clone(),
|
|
known_agent: change.known_agent,
|
|
state: change.state,
|
|
seen,
|
|
presentation: change.presentation.clone(),
|
|
agent_name_changed: false,
|
|
agent_released: false,
|
|
agent_release_status: None,
|
|
};
|
|
Some(update)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn expire_metadata_tokens(
|
|
&mut self,
|
|
now: std::time::Instant,
|
|
) -> (Vec<(usize, PaneId)>, Vec<usize>) {
|
|
let pane_terminals = self
|
|
.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(ws_idx, workspace)| {
|
|
workspace.tabs.iter().flat_map(move |tab| {
|
|
tab.layout
|
|
.pane_ids()
|
|
.into_iter()
|
|
.filter_map(move |pane_id| {
|
|
workspace
|
|
.pane_state(pane_id)
|
|
.map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone()))
|
|
})
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let changed_panes = pane_terminals
|
|
.into_iter()
|
|
.filter_map(|(ws_idx, pane_id, terminal_id)| {
|
|
let terminal = self.terminals.get_mut(&terminal_id)?;
|
|
terminal.metadata_tokens.expire_at(now).then(|| {
|
|
terminal.revision = terminal.revision.saturating_add(1);
|
|
(ws_idx, pane_id)
|
|
})
|
|
})
|
|
.collect();
|
|
let changed_workspaces = self
|
|
.workspaces
|
|
.iter_mut()
|
|
.enumerate()
|
|
.filter_map(|(ws_idx, workspace)| {
|
|
workspace.metadata_tokens.expire_at(now).then_some(ws_idx)
|
|
})
|
|
.collect();
|
|
(changed_panes, changed_workspaces)
|
|
}
|
|
|
|
pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool {
|
|
let Some(active_ws_idx) = self.active else {
|
|
return false;
|
|
};
|
|
if active_ws_idx != ws_idx {
|
|
return false;
|
|
}
|
|
self.workspaces[ws_idx]
|
|
.find_tab_index_for_pane(pane_id)
|
|
.is_some_and(|tab_idx| tab_idx == self.workspaces[ws_idx].active_tab)
|
|
}
|
|
|
|
pub fn switch_workspace(&mut self, idx: usize) {
|
|
if idx < self.workspaces.len() {
|
|
let previous_focus = self.current_pane_focus_target();
|
|
self.active = Some(idx);
|
|
self.selected = idx;
|
|
let workspace_id = self.workspaces[idx].id.clone();
|
|
crate::logging::workspace_focused(&workspace_id);
|
|
self.mark_session_dirty();
|
|
self.ensure_workspace_visible(idx);
|
|
if let Some(ws) = self.workspaces.get_mut(idx) {
|
|
let active_tab = ws.active_tab;
|
|
ws.switch_tab(active_tab);
|
|
let tab_id =
|
|
public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone());
|
|
crate::logging::tab_focused(&workspace_id, &tab_id);
|
|
}
|
|
self.tab_scroll_follow_active = true;
|
|
self.refresh_tab_bar_view();
|
|
self.record_pane_focus_after_navigation(previous_focus);
|
|
self.sync_selection_after_focus_navigation();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn switch_workspace_tab(&mut self, ws_idx: usize, tab_idx: usize) -> bool {
|
|
if ws_idx >= self.workspaces.len() {
|
|
return false;
|
|
}
|
|
if self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.is_none_or(|ws| tab_idx >= ws.tabs.len())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let previous_focus = self.current_pane_focus_target();
|
|
let workspace_changed = self.active != Some(ws_idx);
|
|
self.active = Some(ws_idx);
|
|
self.selected = ws_idx;
|
|
let workspace_id = self.workspaces[ws_idx].id.clone();
|
|
if workspace_changed {
|
|
crate::logging::workspace_focused(&workspace_id);
|
|
}
|
|
self.mark_session_dirty();
|
|
self.ensure_workspace_visible(ws_idx);
|
|
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
|
|
ws.switch_tab(tab_idx);
|
|
let tab_id =
|
|
public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone());
|
|
crate::logging::tab_focused(&workspace_id, &tab_id);
|
|
}
|
|
self.tab_scroll_follow_active = true;
|
|
self.refresh_tab_bar_view();
|
|
self.record_pane_focus_after_navigation(previous_focus);
|
|
self.sync_selection_after_focus_navigation();
|
|
true
|
|
}
|
|
|
|
pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) {
|
|
if idx >= self.workspaces.len() {
|
|
return;
|
|
}
|
|
|
|
if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate {
|
|
self.ensure_mobile_workspace_visible(idx);
|
|
return;
|
|
}
|
|
|
|
if self.sidebar_collapsed {
|
|
return;
|
|
}
|
|
|
|
let entries = crate::ui::workspace_list_entries(self);
|
|
let Some(target_entry_idx) = entries.iter().position(|entry| {
|
|
matches!(
|
|
entry,
|
|
crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx
|
|
)
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
self.workspace_scroll = crate::ui::normalized_workspace_scroll(
|
|
self,
|
|
self.view.sidebar_rect,
|
|
self.workspace_scroll,
|
|
);
|
|
let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
|
|
if cards.iter().any(|card| card.ws_idx == idx) {
|
|
return;
|
|
}
|
|
|
|
if target_entry_idx < self.workspace_scroll {
|
|
self.workspace_scroll = target_entry_idx;
|
|
return;
|
|
}
|
|
|
|
while !cards.iter().any(|card| card.ws_idx == idx) {
|
|
let previous_scroll = self.workspace_scroll;
|
|
self.workspace_scroll = self.workspace_scroll.saturating_add(1);
|
|
if self.workspace_scroll == previous_scroll {
|
|
break;
|
|
}
|
|
self.workspace_scroll = crate::ui::normalized_workspace_scroll(
|
|
self,
|
|
self.view.sidebar_rect,
|
|
self.workspace_scroll,
|
|
);
|
|
if self.workspace_scroll == previous_scroll {
|
|
break;
|
|
}
|
|
cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
|
|
if cards.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure_mobile_workspace_visible(&mut self, idx: usize) {
|
|
let viewport = crate::ui::mobile_switcher_areas(self).viewport;
|
|
if viewport.height == 0 {
|
|
return;
|
|
}
|
|
|
|
let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx);
|
|
let visible_start = self.mobile_switcher_scroll;
|
|
let visible_end = visible_start.saturating_add(viewport.height as usize);
|
|
if row_range.start < visible_start {
|
|
self.mobile_switcher_scroll = row_range.start;
|
|
} else if row_range.end > visible_end {
|
|
self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize);
|
|
}
|
|
self.mobile_switcher_scroll = self
|
|
.mobile_switcher_scroll
|
|
.min(crate::ui::mobile_switcher_max_scroll(self));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn switch_tab(&mut self, idx: usize) {
|
|
if let Some(ws_idx) = self.active {
|
|
let previous_focus = self.current_pane_focus_target();
|
|
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
|
return;
|
|
};
|
|
ws.switch_tab(idx);
|
|
let workspace_id = ws.id.clone();
|
|
let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone());
|
|
crate::logging::tab_focused(&workspace_id, &tab_id);
|
|
self.mark_session_dirty();
|
|
self.tab_scroll_follow_active = true;
|
|
self.refresh_tab_bar_view();
|
|
self.record_pane_focus_after_navigation(previous_focus);
|
|
self.sync_selection_after_focus_navigation();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mark_active_tab_seen(&mut self) -> bool {
|
|
let Some(ws_idx) = self.active else {
|
|
return false;
|
|
};
|
|
let Some(tab) = self
|
|
.workspaces
|
|
.get_mut(ws_idx)
|
|
.and_then(crate::workspace::Workspace::active_tab_mut)
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
let mut changed = false;
|
|
for pane in tab.panes.values_mut() {
|
|
if !pane.seen {
|
|
pane.seen = true;
|
|
changed = true;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
pub(crate) fn visible_workspace_order(&self) -> Vec<usize> {
|
|
// Mobile always shows the worktree tree expanded, so its visible order
|
|
// must ignore collapse state to match what the switcher renders.
|
|
let entries = if self.view.layout == ViewLayout::Mobile {
|
|
crate::ui::workspace_list_entries_expanded(self)
|
|
} else {
|
|
crate::ui::workspace_list_entries(self)
|
|
};
|
|
let order = entries
|
|
.into_iter()
|
|
.map(|entry| match entry {
|
|
crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if order.is_empty() {
|
|
(0..self.workspaces.len()).collect()
|
|
} else {
|
|
order
|
|
}
|
|
}
|
|
|
|
pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option<usize> {
|
|
self.visible_workspace_order().get(position).copied()
|
|
}
|
|
|
|
pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) {
|
|
if self.workspaces.is_empty() {
|
|
return;
|
|
}
|
|
let order = self.visible_workspace_order();
|
|
let current_pos = order
|
|
.iter()
|
|
.position(|idx| *idx == self.selected)
|
|
.unwrap_or(0);
|
|
let target_pos = current_pos
|
|
.saturating_add_signed(delta)
|
|
.min(order.len().saturating_sub(1));
|
|
if let Some(ws_idx) = order.get(target_pos).copied() {
|
|
self.selected = ws_idx;
|
|
self.ensure_workspace_visible(ws_idx);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn next_workspace(&mut self) {
|
|
if self.workspaces.is_empty() {
|
|
return;
|
|
}
|
|
let current = self.active.unwrap_or(self.selected);
|
|
let order = self.visible_workspace_order();
|
|
let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
|
|
let next = order[(current_pos + 1) % order.len()];
|
|
self.switch_workspace(next);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn previous_workspace(&mut self) {
|
|
if self.workspaces.is_empty() {
|
|
return;
|
|
}
|
|
let current = self.active.unwrap_or(self.selected);
|
|
let order = self.visible_workspace_order();
|
|
let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
|
|
let prev = if current_pos == 0 {
|
|
order[order.len() - 1]
|
|
} else {
|
|
order[current_pos - 1]
|
|
};
|
|
self.switch_workspace(prev);
|
|
}
|
|
|
|
pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool {
|
|
if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() {
|
|
return false;
|
|
}
|
|
|
|
let target_idx = if source_idx < insert_idx {
|
|
insert_idx - 1
|
|
} else {
|
|
insert_idx
|
|
};
|
|
if source_idx == target_idx {
|
|
return false;
|
|
}
|
|
|
|
self.mark_session_dirty();
|
|
|
|
let active_id = self.active.map(|idx| self.workspaces[idx].id.clone());
|
|
let selected_id = self
|
|
.workspaces
|
|
.get(self.selected)
|
|
.map(|workspace| workspace.id.clone());
|
|
|
|
let workspace = self.workspaces.remove(source_idx);
|
|
self.workspaces.insert(target_idx, workspace);
|
|
|
|
self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id));
|
|
self.selected = selected_id
|
|
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
|
|
.unwrap_or(0);
|
|
self.ensure_workspace_visible(self.selected);
|
|
true
|
|
}
|
|
|
|
pub fn move_workspace_block(
|
|
&mut self,
|
|
workspace_ids: &[String],
|
|
before_workspace_id: Option<&str>,
|
|
) -> bool {
|
|
let moved_ids = workspace_ids
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<std::collections::HashSet<_>>();
|
|
if moved_ids.is_empty()
|
|
|| moved_ids.len() != workspace_ids.len()
|
|
|| !workspace_ids
|
|
.iter()
|
|
.all(|id| self.workspaces.iter().any(|workspace| workspace.id == *id))
|
|
|| before_workspace_id.is_some_and(|id| {
|
|
moved_ids.contains(id)
|
|
|| !self.workspaces.iter().any(|workspace| workspace.id == id)
|
|
})
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let mut desired_ids = self
|
|
.workspaces
|
|
.iter()
|
|
.filter(|workspace| !moved_ids.contains(workspace.id.as_str()))
|
|
.map(|workspace| workspace.id.clone())
|
|
.collect::<Vec<_>>();
|
|
let insert_idx = before_workspace_id
|
|
.and_then(|id| desired_ids.iter().position(|candidate| candidate == id))
|
|
.unwrap_or(desired_ids.len());
|
|
desired_ids.splice(insert_idx..insert_idx, workspace_ids.iter().cloned());
|
|
if self
|
|
.workspaces
|
|
.iter()
|
|
.map(|workspace| workspace.id.as_str())
|
|
.eq(desired_ids.iter().map(String::as_str))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let active_id = self.active.map(|idx| self.workspaces[idx].id.clone());
|
|
let selected_id = self
|
|
.workspaces
|
|
.get(self.selected)
|
|
.map(|workspace| workspace.id.clone());
|
|
let desired_positions = desired_ids
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, id)| (id.clone(), index))
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
|
|
self.mark_session_dirty();
|
|
self.workspaces.sort_by_key(|workspace| {
|
|
desired_positions
|
|
.get(&workspace.id)
|
|
.copied()
|
|
.unwrap_or(usize::MAX)
|
|
});
|
|
self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id));
|
|
self.selected = selected_id
|
|
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
|
|
.unwrap_or(0);
|
|
self.ensure_workspace_visible(self.selected);
|
|
true
|
|
}
|
|
|
|
pub fn scroll_tabs_left(&mut self) {
|
|
self.tab_scroll_follow_active = false;
|
|
self.tab_scroll = self.tab_scroll.saturating_sub(1);
|
|
self.refresh_tab_bar_view();
|
|
}
|
|
|
|
pub fn scroll_tabs_right(&mut self) {
|
|
self.tab_scroll_follow_active = false;
|
|
self.tab_scroll = self.tab_scroll.saturating_add(1);
|
|
self.refresh_tab_bar_view();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn next_tab(&mut self) {
|
|
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
|
|
if !ws.tabs.is_empty() {
|
|
let next = (ws.active_tab + 1) % ws.tabs.len();
|
|
self.switch_tab(next);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn previous_tab(&mut self) {
|
|
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
|
|
if !ws.tabs.is_empty() {
|
|
let prev = if ws.active_tab == 0 {
|
|
ws.tabs.len() - 1
|
|
} else {
|
|
ws.active_tab - 1
|
|
};
|
|
self.switch_tab(prev);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn next_agent(&mut self) {
|
|
self.cycle_agent_entry(true);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn previous_agent(&mut self) {
|
|
self.cycle_agent_entry(false);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn focus_agent_entry(&mut self, idx: usize) -> bool {
|
|
let entries = crate::ui::agent_panel_entries(self);
|
|
let Some(target) = entries.get(idx) else {
|
|
return false;
|
|
};
|
|
let ws_idx = target.ws_idx;
|
|
let pane_id = target.pane_id;
|
|
|
|
if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id)
|
|
{
|
|
self.ensure_agent_panel_entry_visible(idx);
|
|
return true;
|
|
}
|
|
|
|
if self.focus_pane_in_workspace(ws_idx, pane_id) {
|
|
self.ensure_agent_panel_entry_visible(idx);
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn cycle_agent_entry(&mut self, forward: bool) {
|
|
let entries = crate::ui::agent_panel_entries(self);
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let focused = self
|
|
.active
|
|
.and_then(|idx| self.workspaces.get(idx))
|
|
.and_then(crate::workspace::Workspace::focused_pane_id);
|
|
let current_idx =
|
|
focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id));
|
|
let target_idx = match (current_idx, forward) {
|
|
(Some(idx), true) => (idx + 1) % entries.len(),
|
|
(Some(0), false) => entries.len() - 1,
|
|
(Some(idx), false) => idx - 1,
|
|
(None, true) => 0,
|
|
(None, false) => entries.len() - 1,
|
|
};
|
|
|
|
self.focus_agent_entry(target_idx);
|
|
}
|
|
|
|
pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) {
|
|
if self.sidebar_collapsed {
|
|
return;
|
|
}
|
|
|
|
let (_, detail_area) = crate::ui::expanded_sidebar_sections(
|
|
self.view.sidebar_rect,
|
|
self.sidebar_section_split,
|
|
);
|
|
self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target(
|
|
self,
|
|
detail_area,
|
|
self.agent_panel_scroll,
|
|
idx,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn terminal_ids_for_workspace(
|
|
&self,
|
|
ws_idx: usize,
|
|
) -> Vec<crate::terminal::TerminalId> {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.into_iter()
|
|
.flat_map(|ws| &ws.tabs)
|
|
.flat_map(|tab| tab.panes.values())
|
|
.map(|pane| pane.attached_terminal_id.clone())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn pane_ids_for_workspace(&self, ws_idx: usize) -> Vec<PaneId> {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.into_iter()
|
|
.flat_map(|ws| &ws.tabs)
|
|
.flat_map(|tab| tab.layout.pane_ids())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn terminal_ids_for_tab(
|
|
&self,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
) -> Vec<crate::terminal::TerminalId> {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.and_then(|ws| ws.tabs.get(tab_idx))
|
|
.into_iter()
|
|
.flat_map(|tab| tab.panes.values())
|
|
.map(|pane| pane.attached_terminal_id.clone())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn pane_ids_for_tab(&self, ws_idx: usize, tab_idx: usize) -> Vec<PaneId> {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.and_then(|ws| ws.tabs.get(tab_idx))
|
|
.map(|tab| tab.layout.pane_ids())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn terminal_id_for_pane(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
) -> Option<crate::terminal::TerminalId> {
|
|
self.workspaces
|
|
.get(ws_idx)?
|
|
.pane_state(pane_id)
|
|
.map(|pane| pane.attached_terminal_id.clone())
|
|
}
|
|
|
|
pub(crate) fn remove_unattached_terminal_ids(
|
|
&mut self,
|
|
terminal_ids: impl IntoIterator<Item = crate::terminal::TerminalId>,
|
|
) {
|
|
for terminal_id in terminal_ids {
|
|
let still_attached = self.workspaces.iter().any(|ws| {
|
|
ws.tabs.iter().any(|tab| {
|
|
tab.panes
|
|
.values()
|
|
.any(|pane| pane.attached_terminal_id == terminal_id)
|
|
})
|
|
});
|
|
if !still_attached
|
|
&& self.terminals.remove(&terminal_id).is_some()
|
|
&& !self.terminal_runtime_shutdowns.contains(&terminal_id)
|
|
{
|
|
self.terminal_runtime_shutdowns.push(terminal_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn remove_plugin_pane_records(
|
|
&mut self,
|
|
pane_ids: impl IntoIterator<Item = PaneId>,
|
|
) {
|
|
let pane_ids = pane_ids.into_iter().collect::<Vec<_>>();
|
|
self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied());
|
|
if self
|
|
.previous_pane_focus
|
|
.as_ref()
|
|
.is_some_and(|focus| pane_ids.contains(&focus.pane_id))
|
|
{
|
|
self.previous_pane_focus = None;
|
|
}
|
|
for pane_id in pane_ids {
|
|
self.plugin_panes.remove(&pane_id);
|
|
self.pane_graphics_layers.remove(&pane_id);
|
|
self.pane_graphics_streams.remove(&pane_id);
|
|
}
|
|
}
|
|
|
|
pub fn close_selected_workspace(&mut self) {
|
|
if self.workspaces.is_empty() {
|
|
return;
|
|
}
|
|
self.selection = None;
|
|
self.selection_autoscroll = None;
|
|
self.mark_session_dirty();
|
|
let close_indices = self
|
|
.workspaces
|
|
.get(self.selected)
|
|
.and_then(|ws| ws.worktree_space())
|
|
.filter(|space| !space.is_linked_worktree)
|
|
.map(|space| {
|
|
self.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, ws)| {
|
|
ws.worktree_space()
|
|
.is_some_and(|member| member.key == space.key)
|
|
.then_some(idx)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.filter(|indices| indices.len() >= 2)
|
|
.unwrap_or_else(|| vec![self.selected]);
|
|
|
|
let mut terminal_ids = Vec::new();
|
|
let mut pane_ids = Vec::new();
|
|
for idx in &close_indices {
|
|
terminal_ids.extend(self.terminal_ids_for_workspace(*idx));
|
|
pane_ids.extend(self.pane_ids_for_workspace(*idx));
|
|
if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) {
|
|
crate::logging::workspace_closed(&workspace_id);
|
|
}
|
|
}
|
|
let active_workspace_id = self
|
|
.active
|
|
.and_then(|idx| self.workspaces.get(idx))
|
|
.map(|ws| ws.id.clone());
|
|
self.remove_plugin_pane_records(pane_ids);
|
|
for idx in close_indices.iter().rev() {
|
|
self.workspaces.remove(*idx);
|
|
}
|
|
self.remove_unattached_terminal_ids(terminal_ids);
|
|
if self.workspaces.is_empty() {
|
|
self.active = None;
|
|
self.selected = 0;
|
|
self.workspace_scroll = 0;
|
|
self.tab_scroll = 0;
|
|
self.tab_scroll_follow_active = true;
|
|
} else {
|
|
// Keep focus on the previously focused workspace
|
|
if let Some(id) = active_workspace_id {
|
|
if let Some(idx) = self.workspaces.iter().position(|ws| ws.id == id) {
|
|
self.selected = idx;
|
|
}
|
|
}
|
|
if self.selected >= self.workspaces.len() {
|
|
self.selected = self.workspaces.len() - 1;
|
|
}
|
|
self.active = Some(self.selected);
|
|
self.workspace_scroll = self
|
|
.workspace_scroll
|
|
.min(self.workspaces.len().saturating_sub(1));
|
|
self.ensure_workspace_visible(self.selected);
|
|
self.tab_scroll_follow_active = true;
|
|
self.refresh_tab_bar_view();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn refresh_tab_bar_view(&mut self) {
|
|
let area = self.view.tab_bar_rect;
|
|
let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else {
|
|
self.tab_scroll = 0;
|
|
self.view.tab_hit_areas.clear();
|
|
self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default();
|
|
self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default();
|
|
self.view.new_tab_hit_area = ratatui::layout::Rect::default();
|
|
return;
|
|
};
|
|
|
|
let layout = crate::ui::compute_tab_bar_view(
|
|
ws,
|
|
area,
|
|
self.tab_scroll,
|
|
self.tab_scroll_follow_active,
|
|
self.mouse_capture,
|
|
);
|
|
self.tab_scroll = layout.scroll;
|
|
self.view.tab_hit_areas = layout.tab_hit_areas;
|
|
self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area;
|
|
self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area;
|
|
self.view.new_tab_hit_area = layout.new_tab_hit_area;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pane operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum PaneZoomCommand {
|
|
Toggle,
|
|
On,
|
|
Off,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum PaneZoomNoopReason {
|
|
SinglePane,
|
|
AlreadyZoomed,
|
|
AlreadyUnzoomed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct PaneZoomOutcome {
|
|
pub changed: bool,
|
|
pub focus_changed: bool,
|
|
pub reason: Option<PaneZoomNoopReason>,
|
|
pub zoomed: bool,
|
|
}
|
|
|
|
impl AppState {
|
|
#[cfg(test)]
|
|
pub fn navigate_pane(&mut self, direction: NavDirection) {
|
|
let Some(ws_idx) = self.active else {
|
|
return;
|
|
};
|
|
let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
|
|
return;
|
|
};
|
|
let panes = if tab.zoomed {
|
|
tab.layout.panes(self.view.terminal_area)
|
|
} else {
|
|
self.view.pane_infos.clone()
|
|
};
|
|
|
|
if let Some(focused) = panes.iter().find(|p| p.is_focused) {
|
|
if let Some(target) = find_in_direction(focused, direction, &panes) {
|
|
self.focus_pane_in_workspace(ws_idx, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn swap_pane(&mut self, direction: NavDirection) -> bool {
|
|
let Some(ws_idx) = self.active else {
|
|
return false;
|
|
};
|
|
let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
|
|
return false;
|
|
};
|
|
let panes = if tab.zoomed {
|
|
tab.layout.panes(self.view.terminal_area)
|
|
} else {
|
|
self.view.pane_infos.clone()
|
|
};
|
|
|
|
let Some(focused) = panes.iter().find(|p| p.is_focused) else {
|
|
return false;
|
|
};
|
|
let Some(target) = find_in_direction(focused, direction, &panes) else {
|
|
return false;
|
|
};
|
|
let source = focused.id;
|
|
let Some(tab) = self
|
|
.workspaces
|
|
.get_mut(ws_idx)
|
|
.and_then(|ws| ws.active_tab_mut())
|
|
else {
|
|
return false;
|
|
};
|
|
if tab.layout.swap_panes(source, target) {
|
|
self.mark_session_dirty();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn resize_pane(&mut self, direction: NavDirection) {
|
|
if let Some(first) = self.view.pane_infos.first() {
|
|
let area = self
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.fold(first.rect, |acc, p| acc.union(p.rect));
|
|
if let Some(tab) = self
|
|
.active
|
|
.and_then(|i| self.workspaces.get_mut(i))
|
|
.and_then(|ws| ws.active_tab_mut())
|
|
{
|
|
tab.layout.resize_focused(direction, 0.05, area);
|
|
self.mark_session_dirty();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn cycle_pane(&mut self, reverse: bool) {
|
|
let Some(ws_idx) = self.active else {
|
|
return;
|
|
};
|
|
let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
|
|
return;
|
|
};
|
|
let ids = tab.layout.pane_ids();
|
|
if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) {
|
|
let target = if reverse {
|
|
ids[(pos + ids.len() - 1) % ids.len()]
|
|
} else {
|
|
ids[(pos + 1) % ids.len()]
|
|
};
|
|
self.focus_pane_in_workspace(ws_idx, target);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn last_pane(&mut self) {
|
|
let Some(target) = self.previous_pane_focus.clone() else {
|
|
return;
|
|
};
|
|
let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else {
|
|
self.previous_pane_focus = None;
|
|
return;
|
|
};
|
|
let current = self.current_pane_focus_target();
|
|
if current.as_ref() == Some(&target) {
|
|
self.previous_pane_focus = None;
|
|
return;
|
|
}
|
|
|
|
self.switch_workspace_tab(ws_idx, tab_idx);
|
|
if let Some(tab) = self
|
|
.workspaces
|
|
.get_mut(ws_idx)
|
|
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
|
{
|
|
tab.layout.focus_pane(target.pane_id);
|
|
self.previous_pane_focus = current;
|
|
self.mark_session_dirty();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn apply_pane_zoom(
|
|
&mut self,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
command: PaneZoomCommand,
|
|
) -> Option<PaneZoomOutcome> {
|
|
let tab_idx = self
|
|
.workspaces
|
|
.get(ws_idx)?
|
|
.find_tab_index_for_pane(pane_id)?;
|
|
let focus_changed = self.focus_pane_in_workspace(ws_idx, pane_id);
|
|
let tab = self
|
|
.workspaces
|
|
.get_mut(ws_idx)
|
|
.and_then(|ws| ws.tabs.get_mut(tab_idx))?;
|
|
if tab.layout.pane_count() <= 1 {
|
|
return Some(PaneZoomOutcome {
|
|
changed: false,
|
|
focus_changed,
|
|
reason: Some(PaneZoomNoopReason::SinglePane),
|
|
zoomed: tab.zoomed,
|
|
});
|
|
}
|
|
|
|
let desired = match command {
|
|
PaneZoomCommand::Toggle => !tab.zoomed,
|
|
PaneZoomCommand::On => true,
|
|
PaneZoomCommand::Off => false,
|
|
};
|
|
let reason = match (command, tab.zoomed) {
|
|
(PaneZoomCommand::On, true) => Some(PaneZoomNoopReason::AlreadyZoomed),
|
|
(PaneZoomCommand::Off, false) => Some(PaneZoomNoopReason::AlreadyUnzoomed),
|
|
_ => None,
|
|
};
|
|
if reason.is_some() {
|
|
return Some(PaneZoomOutcome {
|
|
changed: false,
|
|
focus_changed,
|
|
reason,
|
|
zoomed: tab.zoomed,
|
|
});
|
|
}
|
|
|
|
tab.zoomed = desired;
|
|
let zoomed = tab.zoomed;
|
|
self.mark_session_dirty();
|
|
Some(PaneZoomOutcome {
|
|
changed: true,
|
|
focus_changed,
|
|
reason: None,
|
|
zoomed,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn toggle_zoom(&mut self) {
|
|
let Some(ws_idx) = self.active else {
|
|
return;
|
|
};
|
|
let Some(pane_id) = self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.and_then(crate::workspace::Workspace::focused_pane_id)
|
|
else {
|
|
return;
|
|
};
|
|
self.apply_pane_zoom(ws_idx, pane_id, PaneZoomCommand::Toggle);
|
|
}
|
|
|
|
pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.and_then(|ws| ws.worktree_space())
|
|
.filter(|space| !space.is_linked_worktree)
|
|
.is_some_and(|space| {
|
|
self.workspaces
|
|
.iter()
|
|
.filter(|ws| {
|
|
ws.worktree_space()
|
|
.is_some_and(|member| member.key == space.key)
|
|
})
|
|
.count()
|
|
>= 2
|
|
})
|
|
}
|
|
|
|
pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool {
|
|
if self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) {
|
|
self.selected = ws_idx;
|
|
self.mode = Mode::ConfirmClose;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn close_focused_pane_would_close_workspace(&self, ws_idx: usize) -> bool {
|
|
self.workspaces.get(ws_idx).is_some_and(|ws| {
|
|
let pane_count = ws
|
|
.active_tab()
|
|
.map(|tab| tab.layout.pane_count())
|
|
.unwrap_or(0);
|
|
pane_count <= 1 && ws.tabs.len() <= 1
|
|
})
|
|
}
|
|
|
|
pub(crate) fn close_pane_would_close_workspace(&self, ws_idx: usize, pane_id: PaneId) -> bool {
|
|
self.workspaces.get(ws_idx).is_some_and(|ws| {
|
|
ws.find_tab_index_for_pane(pane_id).is_some_and(|tab_idx| {
|
|
ws.tabs[tab_idx].layout.pane_count() <= 1 && ws.tabs.len() <= 1
|
|
})
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
/// Close the focused pane. Returns true when the close was deferred to confirmation.
|
|
pub fn close_pane(&mut self) -> bool {
|
|
let active = self.active;
|
|
if active.is_some_and(|ws_idx| {
|
|
self.close_focused_pane_would_close_workspace(ws_idx)
|
|
&& self.workspace_close_would_close_worktree_group(ws_idx)
|
|
}) {
|
|
if let Some(ws_idx) = active {
|
|
if self.confirm_implicit_worktree_group_close(ws_idx) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selection = None;
|
|
self.selection_autoscroll = None;
|
|
self.mark_session_dirty();
|
|
let terminal_ids = active
|
|
.and_then(|i| {
|
|
self.workspaces
|
|
.get(i)
|
|
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (i, pane_id)))
|
|
})
|
|
.and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id))
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
let pane_ids = active
|
|
.and_then(|i| self.workspaces.get(i).and_then(|ws| ws.focused_pane_id()))
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
let should_close_workspace = active
|
|
.and_then(|i| self.workspaces.get_mut(i))
|
|
.is_some_and(|ws| ws.close_focused());
|
|
self.remove_plugin_pane_records(pane_ids);
|
|
if should_close_workspace {
|
|
if let Some(active) = active {
|
|
self.selected = active;
|
|
}
|
|
self.close_selected_workspace();
|
|
} else {
|
|
self.remove_unattached_terminal_ids(terminal_ids);
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
/// Close the active tab. Returns true when the close was deferred to confirmation.
|
|
pub fn close_tab(&mut self) -> bool {
|
|
if self.active.is_some_and(|ws_idx| {
|
|
self.workspaces
|
|
.get(ws_idx)
|
|
.is_some_and(|ws| ws.tabs.len() <= 1)
|
|
&& self.workspace_close_would_close_worktree_group(ws_idx)
|
|
}) {
|
|
if let Some(ws_idx) = self.active {
|
|
if self.confirm_implicit_worktree_group_close(ws_idx) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selection = None;
|
|
self.selection_autoscroll = None;
|
|
self.mark_session_dirty();
|
|
let should_close_workspace = self
|
|
.active
|
|
.and_then(|i| self.workspaces.get(i))
|
|
.is_some_and(|ws| ws.tabs.len() <= 1);
|
|
if should_close_workspace {
|
|
if let Some(active) = self.active {
|
|
self.selected = active;
|
|
}
|
|
self.close_selected_workspace();
|
|
return false;
|
|
}
|
|
if let Some(ws_idx) = self.active {
|
|
let terminal_ids = self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab))
|
|
.unwrap_or_default();
|
|
let pane_ids = self
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.map(|ws| self.pane_ids_for_tab(ws_idx, ws.active_tab))
|
|
.unwrap_or_default();
|
|
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
|
return false;
|
|
};
|
|
let workspace_id = ws.id.clone();
|
|
let closing_tab_id =
|
|
public_tab_id_for_index(ws, ws.active_tab).unwrap_or_else(|| workspace_id.clone());
|
|
ws.close_active_tab();
|
|
self.remove_plugin_pane_records(pane_ids);
|
|
self.remove_unattached_terminal_ids(terminal_ids);
|
|
crate::logging::tab_closed(&workspace_id, &closing_tab_id);
|
|
self.tab_scroll_follow_active = true;
|
|
self.refresh_tab_bar_view();
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Selection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl AppState {
|
|
pub fn clear_selection(&mut self) {
|
|
self.selection = None;
|
|
self.selection_autoscroll = None;
|
|
}
|
|
|
|
pub(crate) fn stop_selection_autoscroll_state(&mut self) {
|
|
self.selection_autoscroll = None;
|
|
}
|
|
|
|
pub(crate) fn select_word_at_pane_cell(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
pane_id: crate::layout::PaneId,
|
|
viewport_row: u16,
|
|
col: u16,
|
|
) -> bool {
|
|
// Resolve the active pane cell the double-click landed on.
|
|
let Some(ws_idx) = self
|
|
.active
|
|
.filter(|idx| self.workspaces.get(*idx).is_some())
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
let Some(info) = self.pane_info_by_id(pane_id) else {
|
|
return false;
|
|
};
|
|
if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
|
|
return false;
|
|
}
|
|
|
|
// Leave mouse input to terminal apps that requested it.
|
|
let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
|
|
else {
|
|
return false;
|
|
};
|
|
if rt
|
|
.input_state()
|
|
.is_some_and(crate::pane::InputState::mouse_reporting_enabled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Read the visible row and identify the clicked token bounds.
|
|
let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
|
|
let row_selection = Selection::range(
|
|
pane_id,
|
|
viewport_row,
|
|
0,
|
|
info.inner_rect.width.saturating_sub(1),
|
|
metrics,
|
|
);
|
|
let Some(row_text) = rt.extract_selection(&row_selection) else {
|
|
return false;
|
|
};
|
|
let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else {
|
|
return false;
|
|
};
|
|
|
|
let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics);
|
|
if !selection.finish() {
|
|
return false;
|
|
}
|
|
|
|
let text = if self.copy_on_select {
|
|
let Some(text) = rt
|
|
.extract_selection(&selection)
|
|
.filter(|text| !text.is_empty())
|
|
else {
|
|
self.clear_selection();
|
|
return false;
|
|
};
|
|
Some(text)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
self.selection = Some(selection);
|
|
self.selection_autoscroll = None;
|
|
if let Some(text) = text {
|
|
self.request_clipboard_write = Some(text.into_bytes());
|
|
info!("copied double-clicked token to clipboard");
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) fn url_at_pane_cell(
|
|
&self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
pane_id: crate::layout::PaneId,
|
|
viewport_row: u16,
|
|
col: u16,
|
|
) -> Option<String> {
|
|
let ws_idx = self
|
|
.active
|
|
.filter(|idx| self.workspaces.get(*idx).is_some())?;
|
|
let info = self.pane_info_by_id(pane_id)?;
|
|
if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
|
|
return None;
|
|
}
|
|
|
|
let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?;
|
|
let screen_col = info.inner_rect.x.saturating_add(col);
|
|
let screen_row = info.inner_rect.y.saturating_add(viewport_row);
|
|
if let Some((_, _, uri)) = rt
|
|
.visible_hyperlinks(info.inner_rect)
|
|
.into_iter()
|
|
.find(|((x, y), _, _)| *x == screen_col && *y == screen_row)
|
|
{
|
|
return safe_web_url(&uri).map(str::to_owned);
|
|
}
|
|
|
|
let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
|
|
let visible_selection = Selection::line_range(
|
|
pane_id,
|
|
Selection::absolute_row_for_viewport(0, metrics),
|
|
Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics),
|
|
info.inner_rect.width.saturating_sub(1),
|
|
);
|
|
let visible_text = rt.extract_selection(&visible_selection)?;
|
|
let logical_cell =
|
|
logical_cell_for_visible_cell(&visible_text, info.inner_rect.width, viewport_row, col)?;
|
|
let line_start = visible_text[..logical_cell.byte_index]
|
|
.rfind('\n')
|
|
.map_or(0, |idx| idx + 1);
|
|
let line_end = visible_text[logical_cell.byte_index..]
|
|
.find('\n')
|
|
.map_or(visible_text.len(), |idx| logical_cell.byte_index + idx);
|
|
let line = visible_text.get(line_start..line_end)?;
|
|
url_at_column(line, logical_cell.logical_col).map(str::to_owned)
|
|
}
|
|
|
|
pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
|
|
let mut sel = match self.selection.take() {
|
|
Some(sel) => sel,
|
|
None => return,
|
|
};
|
|
if !sel.is_finalized() && !sel.finish() {
|
|
return;
|
|
}
|
|
|
|
let ws_idx = match self.active {
|
|
Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx,
|
|
_ => return,
|
|
};
|
|
|
|
let text = self
|
|
.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
|
|
.and_then(|rt| rt.extract_selection(&sel));
|
|
if let Some(text) = text {
|
|
if !text.is_empty() {
|
|
self.request_clipboard_write = Some(text.into_bytes());
|
|
info!("copied selection to clipboard");
|
|
}
|
|
}
|
|
|
|
self.clear_selection();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn safe_web_url(url: &str) -> Option<&str> {
|
|
(url.starts_with("http://") || url.starts_with("https://")).then_some(url)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct TextCell {
|
|
ch: char,
|
|
start_col: u16,
|
|
end_col: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct CellSpan {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
impl CellSpan {
|
|
fn contains(self, idx: usize) -> bool {
|
|
idx >= self.start && idx <= self.end
|
|
}
|
|
|
|
fn columns(self, cells: &[TextCell]) -> (u16, u16) {
|
|
(cells[self.start].start_col, cells[self.end].end_col)
|
|
}
|
|
}
|
|
|
|
/// Finds the terminal display-column bounds for the token under a double-click.
|
|
///
|
|
/// The algorithm first maps text to terminal cells so wide characters and
|
|
/// zero-width marks use display columns, then prefers structured spans that
|
|
/// users expect to copy whole (URLs and quoted paths), and finally falls back
|
|
/// to a separator-delimited token.
|
|
fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
|
|
// Map the row into display cells before doing any word-boundary work.
|
|
let cells = text_cells(row);
|
|
let clicked_idx = cell_index_at_column(&cells, col)?;
|
|
|
|
// Prefer spans that can legally include punctuation or spaces.
|
|
let span = url_span_at_column(&cells, clicked_idx)
|
|
.or_else(|| quoted_path_span_at_column(&cells, clicked_idx))
|
|
.or_else(|| token_span_at_column(&cells, clicked_idx))?;
|
|
|
|
// Convert the internal cell span back to inclusive terminal columns.
|
|
Some(span.columns(&cells))
|
|
}
|
|
|
|
pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> {
|
|
let cells = text_cells(row);
|
|
let clicked_idx = cell_index_at_column(&cells, col)?;
|
|
let span = url_spans(&cells)
|
|
.into_iter()
|
|
.find(|span| span.contains(clicked_idx))?;
|
|
let start_byte = byte_index_for_cell(row, span.start);
|
|
let end_byte = byte_index_after_cell(row, span.end);
|
|
safe_web_url(row.get(start_byte..end_byte)?)
|
|
}
|
|
|
|
fn url_spans(cells: &[TextCell]) -> Vec<CellSpan> {
|
|
let mut spans = Vec::new();
|
|
let mut start = 0;
|
|
while start < cells.len() {
|
|
if starts_with_chars(&cells[start..], "http://")
|
|
|| starts_with_chars(&cells[start..], "https://")
|
|
{
|
|
let mut end = start;
|
|
while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
|
|
end += 1;
|
|
}
|
|
if let Some(span) = trim_url_edges(cells, CellSpan { start, end }) {
|
|
spans.push(span);
|
|
}
|
|
start = end + 1;
|
|
} else {
|
|
start += 1;
|
|
}
|
|
}
|
|
spans
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct VisibleTextCell {
|
|
pub(crate) byte_index: usize,
|
|
pub(crate) ch: char,
|
|
pub(crate) logical_col: u16,
|
|
pub(crate) screen_row: u16,
|
|
pub(crate) screen_col: u16,
|
|
}
|
|
|
|
pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec<VisibleTextCell> {
|
|
if pane_width == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut cells = Vec::new();
|
|
let mut screen_row = 0u16;
|
|
let mut screen_col = 0u16;
|
|
let mut logical_col = 0u16;
|
|
let mut pending_wrap = false;
|
|
for (byte_index, ch) in text.char_indices() {
|
|
if ch == '\n' {
|
|
screen_row = screen_row.saturating_add(1);
|
|
screen_col = 0;
|
|
logical_col = 0;
|
|
pending_wrap = false;
|
|
continue;
|
|
}
|
|
if pending_wrap {
|
|
screen_row = screen_row.saturating_add(1);
|
|
screen_col = 0;
|
|
pending_wrap = false;
|
|
}
|
|
|
|
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
|
cells.push(VisibleTextCell {
|
|
byte_index,
|
|
ch,
|
|
logical_col,
|
|
screen_row,
|
|
screen_col,
|
|
});
|
|
|
|
logical_col = logical_col.saturating_add(width);
|
|
screen_col = screen_col.saturating_add(width);
|
|
while screen_col > pane_width {
|
|
screen_col -= pane_width;
|
|
screen_row = screen_row.saturating_add(1);
|
|
}
|
|
if width > 0 && screen_col == pane_width {
|
|
pending_wrap = true;
|
|
screen_col = pane_width.saturating_sub(1);
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
pub(crate) fn logical_cell_for_visible_cell(
|
|
text: &str,
|
|
pane_width: u16,
|
|
target_row: u16,
|
|
target_col: u16,
|
|
) -> Option<VisibleTextCell> {
|
|
visible_text_cells(text, pane_width)
|
|
.into_iter()
|
|
.find(|cell| {
|
|
let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32));
|
|
cell.screen_row == target_row
|
|
&& if width == 0 {
|
|
target_col == cell.screen_col
|
|
} else {
|
|
target_col >= cell.screen_col
|
|
&& target_col < cell.screen_col.saturating_add(width)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
|
|
if is_word_separator(cells[clicked_idx].ch) {
|
|
return None;
|
|
}
|
|
|
|
let mut start = clicked_idx;
|
|
while start > 0 && !is_word_separator(cells[start - 1].ch) {
|
|
start -= 1;
|
|
}
|
|
|
|
let mut end = clicked_idx;
|
|
while end + 1 < cells.len() && !is_word_separator(cells[end + 1].ch) {
|
|
end += 1;
|
|
}
|
|
|
|
trim_token_edges(cells, CellSpan { start, end }).filter(|span| span.contains(clicked_idx))
|
|
}
|
|
|
|
fn text_cells(row: &str) -> Vec<TextCell> {
|
|
let mut next_col = 0u16;
|
|
row.chars()
|
|
.map(|ch| {
|
|
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
|
let start_col = if width == 0 {
|
|
next_col.saturating_sub(1)
|
|
} else {
|
|
next_col
|
|
};
|
|
if width > 0 {
|
|
next_col = next_col.saturating_add(width);
|
|
}
|
|
TextCell {
|
|
ch,
|
|
start_col,
|
|
end_col: next_col.saturating_sub(1),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option<usize> {
|
|
cells
|
|
.iter()
|
|
.position(|cell| cell.start_col <= col && col <= cell.end_col)
|
|
}
|
|
|
|
fn byte_index_for_cell(row: &str, cell_idx: usize) -> usize {
|
|
row.char_indices()
|
|
.nth(cell_idx)
|
|
.map(|(idx, _)| idx)
|
|
.unwrap_or(row.len())
|
|
}
|
|
|
|
fn byte_index_after_cell(row: &str, cell_idx: usize) -> usize {
|
|
row.char_indices()
|
|
.nth(cell_idx.saturating_add(1))
|
|
.map(|(idx, _)| idx)
|
|
.unwrap_or(row.len())
|
|
}
|
|
|
|
fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
|
|
let mut start = 0;
|
|
while start < cells.len() {
|
|
if starts_with_chars(&cells[start..], "http://")
|
|
|| starts_with_chars(&cells[start..], "https://")
|
|
{
|
|
let mut end = start;
|
|
while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
|
|
end += 1;
|
|
}
|
|
if clicked_idx >= start && clicked_idx <= end {
|
|
let span = trim_url_edges(cells, CellSpan { start, end })?;
|
|
return span.contains(clicked_idx).then_some(span);
|
|
}
|
|
start = end + 1;
|
|
} else {
|
|
start += 1;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn trim_url_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
|
|
let start = span.start;
|
|
let mut end = span.end;
|
|
while start <= end && should_trim_trailing_url_cell(cells, start, end) {
|
|
if end == 0 {
|
|
return None;
|
|
}
|
|
end -= 1;
|
|
}
|
|
(start <= end).then_some(CellSpan { start, end })
|
|
}
|
|
|
|
fn should_trim_trailing_url_cell(cells: &[TextCell], start: usize, end: usize) -> bool {
|
|
match cells[end].ch {
|
|
'"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' => true,
|
|
')' => !trailing_url_closer_is_balanced(cells, start, end, '(', ')'),
|
|
']' => !trailing_url_closer_is_balanced(cells, start, end, '[', ']'),
|
|
'}' => !trailing_url_closer_is_balanced(cells, start, end, '{', '}'),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn trailing_url_closer_is_balanced(
|
|
cells: &[TextCell],
|
|
start: usize,
|
|
end: usize,
|
|
open: char,
|
|
close: char,
|
|
) -> bool {
|
|
let mut balance = 0i32;
|
|
for cell in &cells[start..end] {
|
|
if cell.ch == open {
|
|
balance += 1;
|
|
} else if cell.ch == close {
|
|
balance -= 1;
|
|
}
|
|
}
|
|
balance > 0
|
|
}
|
|
|
|
fn quoted_path_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
|
|
let clicked = cells.get(clicked_idx)?.ch;
|
|
if clicked == '"' || clicked == '\'' || clicked == '`' {
|
|
return None;
|
|
}
|
|
|
|
for quote in ['"', '\'', '`'] {
|
|
let mut start = None;
|
|
for (idx, cell) in cells.iter().copied().enumerate() {
|
|
let ch = cell.ch;
|
|
if ch != quote || is_escaped(cells, idx) {
|
|
continue;
|
|
}
|
|
if let Some(open) = start {
|
|
if clicked_idx > open
|
|
&& clicked_idx < idx
|
|
&& cells[open + 1..idx].iter().any(|cell| cell.ch == '/')
|
|
{
|
|
return Some(CellSpan {
|
|
start: open + 1,
|
|
end: idx - 1,
|
|
});
|
|
}
|
|
start = None;
|
|
} else {
|
|
start = Some(idx);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_escaped(cells: &[TextCell], idx: usize) -> bool {
|
|
let mut slashes = 0;
|
|
let mut cursor = idx;
|
|
while cursor > 0 && cells[cursor - 1].ch == '\\' {
|
|
slashes += 1;
|
|
cursor -= 1;
|
|
}
|
|
slashes % 2 == 1
|
|
}
|
|
|
|
fn starts_with_chars(cells: &[TextCell], prefix: &str) -> bool {
|
|
prefix
|
|
.chars()
|
|
.enumerate()
|
|
.all(|(idx, expected)| cells.get(idx).is_some_and(|cell| cell.ch == expected))
|
|
}
|
|
|
|
fn is_word_separator(ch: char) -> bool {
|
|
ch.is_whitespace()
|
|
|| matches!(
|
|
ch,
|
|
'|' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!'
|
|
)
|
|
}
|
|
|
|
fn trim_token_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
|
|
let mut start = span.start;
|
|
let mut end = span.end;
|
|
while start <= end && is_leading_token_wrapper(cells[start].ch) {
|
|
start += 1;
|
|
}
|
|
if start < end && cells[end].ch == '$' && is_trailing_token_wrapper(cells[end - 1].ch) {
|
|
end -= 1;
|
|
}
|
|
while start <= end && is_trailing_token_wrapper(cells[end].ch) {
|
|
if end == 0 {
|
|
return None;
|
|
}
|
|
end -= 1;
|
|
}
|
|
(start <= end).then_some(CellSpan { start, end })
|
|
}
|
|
|
|
fn is_leading_token_wrapper(ch: char) -> bool {
|
|
matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
|
|
}
|
|
|
|
fn is_trailing_token_wrapper(ch: char) -> bool {
|
|
matches!(
|
|
ch,
|
|
')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?'
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl AppState {
|
|
pub fn apply_workspace_git_statuses(
|
|
&mut self,
|
|
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
|
results: Vec<WorkspaceGitStatus>,
|
|
) -> bool {
|
|
let mut changed = false;
|
|
for result in results {
|
|
let Some(ws_idx) = self
|
|
.workspaces
|
|
.iter()
|
|
.position(|ws| ws.id == result.workspace_id)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if self.workspaces[ws_idx]
|
|
.resolved_identity_cwd_from(&self.terminals, terminal_runtimes)
|
|
.as_ref()
|
|
!= Some(&result.resolved_identity_cwd)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let ws = &mut self.workspaces[ws_idx];
|
|
if ws.cached_identity_cwd != result.resolved_identity_cwd {
|
|
ws.cached_identity_cwd = result.resolved_identity_cwd;
|
|
}
|
|
if ws.cached_auto_label != result.auto_label {
|
|
ws.cached_auto_label = result.auto_label;
|
|
changed |= ws.custom_name.is_none();
|
|
}
|
|
if ws.cached_git_status_key != result.status_cache_key {
|
|
ws.cached_git_status_key = result.status_cache_key;
|
|
}
|
|
if result.demand.branch && ws.cached_git_branch != result.branch {
|
|
ws.cached_git_branch = result.branch;
|
|
changed = true;
|
|
}
|
|
if result.demand.ahead_behind && ws.cached_git_ahead_behind != result.ahead_behind {
|
|
ws.cached_git_ahead_behind = result.ahead_behind;
|
|
changed = true;
|
|
}
|
|
if ws.cached_git_space != result.space {
|
|
ws.cached_git_space = result.space;
|
|
changed = true;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> {
|
|
match event {
|
|
AppEvent::PaneDied { pane_id } => {
|
|
self.handle_pane_died(pane_id);
|
|
Vec::new()
|
|
}
|
|
AppEvent::UpdateReady {
|
|
version,
|
|
install_command,
|
|
} => {
|
|
self.update_available = Some(version.clone());
|
|
self.update_install_command = install_command.clone();
|
|
self.latest_release_notes_available = true;
|
|
self.update_dismissed = true;
|
|
if matches!(
|
|
self.toast_config.delivery,
|
|
crate::config::ToastDelivery::Herdr
|
|
) {
|
|
self.toast = Some(ToastNotification {
|
|
kind: ToastKind::UpdateInstalled,
|
|
title: format!("v{version} available"),
|
|
context: crate::update::update_install_instruction(&install_command),
|
|
position: None,
|
|
target: None,
|
|
});
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::AgentDetectionManifestsUpdated { updated, status } => {
|
|
self.agent_manifest_update_status = status;
|
|
self.refresh_agent_manifest_summaries();
|
|
if !updated.is_empty()
|
|
&& matches!(
|
|
self.toast_config.delivery,
|
|
crate::config::ToastDelivery::Herdr
|
|
)
|
|
{
|
|
let agent_list = updated
|
|
.iter()
|
|
.map(|item| {
|
|
format!(
|
|
"{} {}",
|
|
crate::detect::agent_label(item.agent),
|
|
item.version
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
self.toast = Some(ToastNotification {
|
|
kind: ToastKind::UpdateInstalled,
|
|
title: "Agent detection rules updated".to_string(),
|
|
context: agent_list,
|
|
position: None,
|
|
target: None,
|
|
});
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::StateChanged {
|
|
pane_id,
|
|
agent,
|
|
state,
|
|
visible_blocker,
|
|
visible_working,
|
|
process_exited,
|
|
observed_at,
|
|
} => self
|
|
.update_terminal_state(pane_id, |terminal| {
|
|
Some(terminal.set_detected_state_with_screen_signals_at(
|
|
agent,
|
|
state,
|
|
visible_blocker,
|
|
false,
|
|
visible_working,
|
|
process_exited,
|
|
observed_at,
|
|
))
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
AppEvent::HookStateReported {
|
|
pane_id,
|
|
source,
|
|
agent_label,
|
|
state,
|
|
message,
|
|
seq,
|
|
session_ref,
|
|
} => {
|
|
if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) {
|
|
self.update_terminal_state(pane_id, |terminal| {
|
|
terminal.set_agent_session_ref(source, agent_label, session_ref, seq)
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
} else {
|
|
self.update_terminal_state(pane_id, |terminal| {
|
|
terminal.set_hook_authority_with_session_ref(
|
|
source,
|
|
agent_label,
|
|
state,
|
|
message,
|
|
session_ref,
|
|
seq,
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
}
|
|
AppEvent::AgentSessionReported {
|
|
pane_id,
|
|
source,
|
|
agent_label,
|
|
seq,
|
|
session_ref,
|
|
session_start_source,
|
|
} => self
|
|
.update_terminal_state(pane_id, |terminal| {
|
|
terminal.set_agent_session_ref_for_session_start(
|
|
source,
|
|
agent_label,
|
|
session_ref,
|
|
seq,
|
|
session_start_source,
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
AppEvent::HookMetadataReported {
|
|
pane_id,
|
|
source,
|
|
agent_label,
|
|
applies_to_source,
|
|
title,
|
|
display_agent,
|
|
state_labels,
|
|
clear_title,
|
|
clear_display_agent,
|
|
clear_state_labels,
|
|
seq,
|
|
ttl,
|
|
} => self
|
|
.update_terminal_state(pane_id, |terminal| {
|
|
terminal.set_agent_metadata(crate::terminal::AgentMetadataReport {
|
|
source,
|
|
agent_label,
|
|
applies_to_source,
|
|
title,
|
|
display_agent,
|
|
state_labels,
|
|
clear_title,
|
|
clear_display_agent,
|
|
clear_state_labels,
|
|
ttl,
|
|
seq,
|
|
})
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
AppEvent::HookAuthorityCleared {
|
|
pane_id,
|
|
source,
|
|
seq,
|
|
} => self
|
|
.update_terminal_state(pane_id, |terminal| {
|
|
terminal.clear_hook_authority_with_mutation(source.as_deref(), seq)
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
AppEvent::HookAgentReleased {
|
|
pane_id,
|
|
source,
|
|
agent_label,
|
|
seq,
|
|
..
|
|
} => {
|
|
if crate::agent_resume::is_official_agent_source(&source, &agent_label) {
|
|
Vec::new()
|
|
} else {
|
|
self.update_terminal_state(pane_id, |terminal| {
|
|
terminal.release_agent_with_mutation(&source, &agent_label, seq)
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
}
|
|
// Both intercepted before this dispatch — in App::handle_internal_event (monolithic)
|
|
// or via HeadlessServer forwarding to the foreground client (server); never touch
|
|
// AppState. Kept for AppEvent exhaustiveness.
|
|
AppEvent::ClipboardWrite { .. } => Vec::new(),
|
|
AppEvent::PrefixInputSource { .. } => Vec::new(),
|
|
AppEvent::TerminalCwdReported { pane_id, cwd } => {
|
|
if !cwd.is_absolute() || !cwd.is_dir() {
|
|
return Vec::new();
|
|
}
|
|
let Some(terminal_id) = self.workspaces.iter().find_map(|ws| {
|
|
ws.pane_state(pane_id)
|
|
.map(|pane| pane.attached_terminal_id.clone())
|
|
}) else {
|
|
return Vec::new();
|
|
};
|
|
let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
|
|
return Vec::new();
|
|
};
|
|
if terminal.cwd != cwd {
|
|
terminal.cwd = cwd;
|
|
self.mark_session_dirty();
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::GitStatusRefreshed {
|
|
results,
|
|
cache_updates,
|
|
} => {
|
|
let _ = results;
|
|
let _ = cache_updates;
|
|
Vec::new()
|
|
}
|
|
AppEvent::WorktreeAddFinished(_) => Vec::new(),
|
|
AppEvent::WorktreeRemoveFinished(_) => Vec::new(),
|
|
AppEvent::PluginCommandFinished { .. } => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn update_terminal_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
|
|
where
|
|
F: FnOnce(&mut crate::terminal::TerminalState) -> Option<TerminalStateMutation>,
|
|
{
|
|
let ws_idx = self
|
|
.workspaces
|
|
.iter()
|
|
.position(|ws| ws.pane_state(pane_id).is_some())?;
|
|
let terminal_id = self.workspaces[ws_idx]
|
|
.pane_state(pane_id)?
|
|
.attached_terminal_id
|
|
.clone();
|
|
let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen;
|
|
let now = Instant::now();
|
|
let (mutation, managed_changed, agent_name_changed, unchanged_change) = {
|
|
let terminal = self.terminals.get_mut(&terminal_id)?;
|
|
let previous_agent_name = terminal.agent_name.clone();
|
|
let mutation = update(terminal)?;
|
|
let managed_changed = terminal.reconcile_managed_agent_at(now, false);
|
|
let agent_name_changed = terminal.agent_name != previous_agent_name;
|
|
let unchanged_change = (mutation.agent_released || agent_name_changed)
|
|
.then(|| terminal.unchanged_effective_state_change_at(now));
|
|
(
|
|
mutation,
|
|
managed_changed,
|
|
agent_name_changed,
|
|
unchanged_change,
|
|
)
|
|
};
|
|
if mutation.session_ref_changed || managed_changed || agent_name_changed {
|
|
self.mark_session_dirty();
|
|
}
|
|
let agent_released = mutation.agent_released;
|
|
let change = mutation.effective_state_change.or(unchanged_change)?;
|
|
if change.previous_state != change.state {
|
|
self.next_agent_state_change_seq += 1;
|
|
if let Some(terminal) = self.terminals.get_mut(&terminal_id) {
|
|
terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq);
|
|
}
|
|
}
|
|
let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?;
|
|
let update = PaneStateUpdate {
|
|
pane_id,
|
|
ws_idx,
|
|
previous_agent_label: change.previous_agent_label.clone(),
|
|
previous_known_agent: change.previous_known_agent,
|
|
previous_state: change.previous_state,
|
|
previous_seen,
|
|
previous_presentation: change.previous_presentation.clone(),
|
|
agent_label: if agent_released {
|
|
change.previous_agent_label.clone()
|
|
} else {
|
|
change.agent_label.clone()
|
|
},
|
|
known_agent: if agent_released {
|
|
change.previous_known_agent
|
|
} else {
|
|
change.known_agent
|
|
},
|
|
state: change.state,
|
|
seen,
|
|
presentation: change.presentation.clone(),
|
|
agent_name_changed,
|
|
agent_released,
|
|
agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)),
|
|
};
|
|
Some(update)
|
|
}
|
|
|
|
pub(crate) fn next_managed_agent_deadline(&self) -> Option<Instant> {
|
|
self.terminals
|
|
.values()
|
|
.filter_map(crate::terminal::TerminalState::next_managed_agent_deadline)
|
|
.min()
|
|
}
|
|
|
|
pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> {
|
|
let mut changed_terminals = std::collections::HashSet::new();
|
|
for (terminal_id, terminal) in &mut self.terminals {
|
|
if terminal.reconcile_managed_agent_at(now, false) {
|
|
changed_terminals.insert(terminal_id.clone());
|
|
}
|
|
}
|
|
if changed_terminals.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
self.mark_session_dirty();
|
|
self.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(ws_idx, workspace)| {
|
|
let changed_terminals = &changed_terminals;
|
|
workspace.tabs.iter().flat_map(move |tab| {
|
|
tab.panes.iter().filter_map(move |(&pane_id, pane)| {
|
|
changed_terminals
|
|
.contains(&pane.attached_terminal_id)
|
|
.then_some((ws_idx, pane_id))
|
|
})
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn publish_pane_process_exit_if_agent(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
) -> Option<PaneStateUpdate> {
|
|
let observed_at = std::time::Instant::now();
|
|
let update = self.update_terminal_state(pane_id, |terminal| {
|
|
let agent = terminal.effective_known_agent().or(terminal.detected_agent);
|
|
if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() {
|
|
return None;
|
|
}
|
|
Some(terminal.set_detected_state_with_screen_signals_at(
|
|
agent,
|
|
AgentState::Idle,
|
|
false,
|
|
true,
|
|
false,
|
|
true,
|
|
observed_at,
|
|
))
|
|
})?;
|
|
update.agent_released.then_some(update)
|
|
}
|
|
|
|
fn apply_pane_state_change(
|
|
&mut self,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
change: &EffectiveStateChange,
|
|
) -> Option<bool> {
|
|
let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id);
|
|
let suppress_active_tab_notifications =
|
|
active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus);
|
|
let pane = self.workspaces[ws_idx]
|
|
.tabs
|
|
.iter_mut()
|
|
.find_map(|tab| tab.panes.get_mut(&pane_id))?;
|
|
|
|
if change.state != AgentState::Idle {
|
|
pane.seen = true;
|
|
} else if is_completion_transition(change) {
|
|
pane.seen = suppress_active_tab_notifications;
|
|
}
|
|
let seen = pane.seen;
|
|
|
|
if let Some(delivery) = self.record_or_deliver_agent_notification(ws_idx, pane_id, change) {
|
|
self.apply_agent_notification_delivery(&delivery);
|
|
}
|
|
|
|
Some(seen)
|
|
}
|
|
|
|
fn record_or_deliver_agent_notification(
|
|
&mut self,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
change: &EffectiveStateChange,
|
|
) -> Option<AgentNotificationDelivery> {
|
|
self.pending_agent_notifications.remove(&pane_id);
|
|
|
|
let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id);
|
|
let suppress_active_tab_notifications =
|
|
active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus);
|
|
|
|
let client_notification_kind = notification_toast_for_effective_state_change(
|
|
suppress_active_tab_notifications,
|
|
change,
|
|
);
|
|
let sound = notification_sound_for_effective_state_change(
|
|
suppress_active_tab_notifications,
|
|
change,
|
|
);
|
|
if client_notification_kind.is_none() && sound.is_none() {
|
|
return None;
|
|
}
|
|
|
|
let agent_label = change
|
|
.agent_label
|
|
.clone()
|
|
.or_else(|| change.previous_agent_label.clone())?;
|
|
let known_agent = change.known_agent.or(change.previous_known_agent);
|
|
let kind = client_notification_kind.unwrap_or(match sound {
|
|
Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention,
|
|
Some(crate::sound::Sound::Done) | None => ToastKind::Finished,
|
|
});
|
|
let workspace_id = self.workspaces[ws_idx].id.clone();
|
|
|
|
if self.toast_config.delay_seconds == 0 {
|
|
return self.agent_notification_delivery(
|
|
ws_idx,
|
|
pane_id,
|
|
workspace_id,
|
|
agent_label,
|
|
known_agent,
|
|
kind,
|
|
change.state,
|
|
);
|
|
}
|
|
|
|
self.pending_agent_notifications.insert(
|
|
pane_id,
|
|
PendingAgentNotification {
|
|
pane_id,
|
|
workspace_id,
|
|
agent_label,
|
|
known_agent,
|
|
kind,
|
|
state: change.state,
|
|
deadline: {
|
|
let now = std::time::Instant::now();
|
|
let delay_seconds = self
|
|
.toast_config
|
|
.delay_seconds
|
|
.min(crate::config::MAX_TOAST_DELAY_SECONDS);
|
|
now.checked_add(std::time::Duration::from_secs(delay_seconds))
|
|
.unwrap_or(now)
|
|
},
|
|
},
|
|
);
|
|
None
|
|
}
|
|
|
|
fn agent_notification_delivery(
|
|
&self,
|
|
ws_idx: usize,
|
|
pane_id: PaneId,
|
|
workspace_id: String,
|
|
agent_label: String,
|
|
known_agent: Option<Agent>,
|
|
kind: ToastKind,
|
|
expected_state: AgentState,
|
|
) -> Option<AgentNotificationDelivery> {
|
|
let terminal_state = self
|
|
.workspaces
|
|
.get(ws_idx)?
|
|
.pane_state(pane_id)
|
|
.and_then(|pane| self.terminals.get(&pane.attached_terminal_id))?;
|
|
if terminal_state.state != expected_state {
|
|
return None;
|
|
}
|
|
if terminal_state
|
|
.effective_agent_label()
|
|
.is_some_and(|current| current != agent_label)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id);
|
|
let suppress_active_tab_notifications =
|
|
active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus);
|
|
let sound = sound_for_toast_kind(kind, suppress_active_tab_notifications)
|
|
.filter(|_| self.sound.allows(known_agent));
|
|
let build_toast = || {
|
|
let workspace_label =
|
|
self.workspaces[ws_idx].display_name_from_terminals(&self.terminals);
|
|
let context =
|
|
notification_context(&self.workspaces[ws_idx], &workspace_label, ws_idx, pane_id);
|
|
ToastNotification {
|
|
kind,
|
|
title: format!(
|
|
"{} {}",
|
|
toast_agent_label(&agent_label),
|
|
toast_event_text(kind)
|
|
),
|
|
context,
|
|
position: None,
|
|
target: Some(ToastTarget {
|
|
workspace_id: workspace_id.clone(),
|
|
pane_id,
|
|
}),
|
|
}
|
|
};
|
|
let toast = (!is_active_tab).then(build_toast);
|
|
let client_notification = (!suppress_active_tab_notifications).then(build_toast);
|
|
|
|
if toast.is_none() && client_notification.is_none() && sound.is_none() {
|
|
return None;
|
|
}
|
|
|
|
Some(AgentNotificationDelivery {
|
|
pane_id,
|
|
workspace_id,
|
|
agent_label,
|
|
known_agent,
|
|
kind,
|
|
toast,
|
|
client_notification,
|
|
sound,
|
|
})
|
|
}
|
|
|
|
fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) {
|
|
if self.local_sound_playback {
|
|
if let Some(sound) = delivery.sound {
|
|
crate::sound::play(sound, &self.sound);
|
|
}
|
|
}
|
|
|
|
if matches!(
|
|
self.toast_config.delivery,
|
|
crate::config::ToastDelivery::Herdr
|
|
) {
|
|
if let Some(toast) = delivery.toast.clone() {
|
|
self.toast = Some(toast);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn next_pending_agent_notification_deadline(&self) -> Option<std::time::Instant> {
|
|
self.pending_agent_notifications
|
|
.values()
|
|
.map(|pending| pending.deadline)
|
|
.min()
|
|
}
|
|
|
|
pub fn drain_due_agent_notifications(
|
|
&mut self,
|
|
now: std::time::Instant,
|
|
) -> Vec<AgentNotificationDelivery> {
|
|
let due_panes: Vec<PaneId> = self
|
|
.pending_agent_notifications
|
|
.iter()
|
|
.filter_map(|(&pane_id, pending)| (pending.deadline <= now).then_some(pane_id))
|
|
.collect();
|
|
let mut deliveries = Vec::new();
|
|
|
|
for pane_id in due_panes {
|
|
let Some(pending) = self.pending_agent_notifications.remove(&pane_id) else {
|
|
continue;
|
|
};
|
|
let Some(ws_idx) = self
|
|
.workspaces
|
|
.iter()
|
|
.position(|ws| ws.id == pending.workspace_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(delivery) = self.agent_notification_delivery(
|
|
ws_idx,
|
|
pending.pane_id,
|
|
pending.workspace_id,
|
|
pending.agent_label,
|
|
pending.known_agent,
|
|
pending.kind,
|
|
pending.state,
|
|
) else {
|
|
continue;
|
|
};
|
|
self.apply_agent_notification_delivery(&delivery);
|
|
deliveries.push(delivery);
|
|
}
|
|
|
|
deliveries
|
|
}
|
|
|
|
fn handle_pane_died(&mut self, pane_id: PaneId) {
|
|
self.pending_agent_notifications.remove(&pane_id);
|
|
self.remove_plugin_pane_records([pane_id]);
|
|
let ws_idx = self
|
|
.workspaces
|
|
.iter()
|
|
.position(|ws| ws.find_tab_index_for_pane(pane_id).is_some());
|
|
|
|
let Some(ws_idx) = ws_idx else {
|
|
warn!(pane = pane_id.raw(), "PaneDied for unknown pane");
|
|
return;
|
|
};
|
|
|
|
if self
|
|
.selection
|
|
.as_ref()
|
|
.is_some_and(|s| s.pane_id == pane_id)
|
|
{
|
|
self.selection = None;
|
|
self.selection_autoscroll = None;
|
|
}
|
|
|
|
let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
|
|
let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
|
|
self.pane_id_aliases.retain(|_, alias| *alias != pane_id);
|
|
self.public_pane_id_aliases
|
|
.retain(|_, alias| *alias != pane_id);
|
|
let should_close_workspace = {
|
|
let ws = &mut self.workspaces[ws_idx];
|
|
ws.remove_pane(pane_id)
|
|
};
|
|
self.mark_session_dirty();
|
|
|
|
if should_close_workspace {
|
|
self.workspaces.remove(ws_idx);
|
|
self.remove_unattached_terminal_ids(workspace_terminal_ids);
|
|
if self.workspaces.is_empty() {
|
|
self.active = None;
|
|
self.selected = 0;
|
|
if self.mode == Mode::Terminal {
|
|
self.mode = Mode::Navigate;
|
|
}
|
|
} else {
|
|
if let Some(active) = self.active {
|
|
if active >= self.workspaces.len() {
|
|
self.active = Some(self.workspaces.len() - 1);
|
|
}
|
|
}
|
|
if self.selected >= self.workspaces.len() {
|
|
self.selected = self.workspaces.len() - 1;
|
|
}
|
|
}
|
|
} else {
|
|
self.remove_unattached_terminal_ids(pane_terminal_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::detect::{Agent, AgentState};
|
|
use crate::workspace::Workspace;
|
|
use ratatui::layout::Direction;
|
|
|
|
fn app_with_workspaces(names: &[&str]) -> AppState {
|
|
let mut state = AppState::test_new();
|
|
state.toast_config.delay_seconds = 0;
|
|
for name in names {
|
|
let ws = Workspace::test_new(name);
|
|
state.workspaces.push(ws);
|
|
}
|
|
state.ensure_test_terminals();
|
|
if !state.workspaces.is_empty() {
|
|
state.active = Some(0);
|
|
state.mode = Mode::Terminal;
|
|
}
|
|
state
|
|
}
|
|
|
|
fn insert_test_pane_graphics_layer(state: &mut AppState, pane_id: PaneId) {
|
|
state.pane_graphics_layers.insert(
|
|
pane_id,
|
|
crate::app::state::PaneGraphicsLayer::new(
|
|
crate::api::schema::PaneGraphicsFormat::Rgba,
|
|
1,
|
|
1,
|
|
vec![1, 2, 3, 4],
|
|
crate::api::schema::PaneGraphicsPlacementParams::default(),
|
|
),
|
|
);
|
|
}
|
|
|
|
fn insert_test_pane_graphics_state(state: &mut AppState, pane_id: PaneId) {
|
|
insert_test_pane_graphics_layer(state, pane_id);
|
|
state
|
|
.pane_graphics_streams
|
|
.insert(pane_id, "test-stream".into());
|
|
}
|
|
|
|
fn mark_linked_worktree(state: &mut AppState, ws_idx: usize) {
|
|
state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
|
|
key: "repo-key".into(),
|
|
label: "herdr".into(),
|
|
repo_root: "/repo/herdr".into(),
|
|
checkout_path: format!("/repo/worktree-{ws_idx}").into(),
|
|
is_linked_worktree: true,
|
|
});
|
|
}
|
|
|
|
fn mark_parent_worktree(state: &mut AppState, ws_idx: usize) {
|
|
state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
|
|
key: "repo-key".into(),
|
|
label: "herdr".into(),
|
|
repo_root: "/repo/herdr".into(),
|
|
checkout_path: "/repo/herdr".into(),
|
|
is_linked_worktree: false,
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn notification_context_formats_resolved_workspace_label() {
|
|
let state = app_with_workspaces(&["stale"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
|
|
assert_eq!(
|
|
notification_context(&state.workspaces[0], "__herdr_projects__", 0, root),
|
|
"__herdr_projects__ · 1"
|
|
);
|
|
}
|
|
|
|
fn selected_word(row: &str, col: u16) -> Option<String> {
|
|
let (start, end) = word_bounds_at_column(row, col)?;
|
|
Some(text_in_cell_range(row, start, end))
|
|
}
|
|
|
|
fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> {
|
|
url_at_column(row, col_of(row, click))
|
|
}
|
|
|
|
fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String {
|
|
text_cells(row)
|
|
.into_iter()
|
|
.filter(|cell| cell.start_col >= start_col && cell.end_col <= end_col)
|
|
.map(|cell| cell.ch)
|
|
.collect()
|
|
}
|
|
|
|
fn col_of(row: &str, needle: &str) -> u16 {
|
|
let byte_idx = row
|
|
.find(needle)
|
|
.unwrap_or_else(|| panic!("{needle:?} not found in {row:?}"));
|
|
let prefix = &row[..byte_idx];
|
|
prefix
|
|
.chars()
|
|
.map(|ch| u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)))
|
|
.sum()
|
|
}
|
|
|
|
fn assert_selects(row: &str, click: &str, expected: &str) {
|
|
assert_eq!(
|
|
selected_word(row, col_of(row, click)).as_deref(),
|
|
Some(expected),
|
|
"row={row:?}, click={click:?}"
|
|
);
|
|
}
|
|
|
|
fn assert_selects_nothing(row: &str, click: &str) {
|
|
assert_eq!(
|
|
selected_word(row, col_of(row, click)),
|
|
None,
|
|
"row={row:?}, click={click:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn double_click_word_bounds_cover_terminal_text() {
|
|
let cases = [
|
|
(
|
|
"see https://example.com/a-b_c?q=x@y.",
|
|
"example.com",
|
|
"https://example.com/a-b_c?q=x@y",
|
|
),
|
|
(
|
|
"open \"https://example.com/a,b;c?q=x\";",
|
|
"example.com",
|
|
"https://example.com/a,b;c?q=x",
|
|
),
|
|
(
|
|
"see https://en.wikipedia.org/wiki/Foo_(bar_(baz)),",
|
|
"wikipedia",
|
|
"https://en.wikipedia.org/wiki/Foo_(bar_(baz))",
|
|
),
|
|
(
|
|
"see https://example.com/a(b[c{d}e]f),",
|
|
"example.com",
|
|
"https://example.com/a(b[c{d}e]f)",
|
|
),
|
|
(
|
|
"see (https://example.com/a(b(c)d)))",
|
|
"example.com",
|
|
"https://example.com/a(b(c)d)",
|
|
),
|
|
(
|
|
"open /tmp/foo-bar/baz_qux/",
|
|
"foo-bar",
|
|
"/tmp/foo-bar/baz_qux/",
|
|
),
|
|
(
|
|
"open ./src/app/actions.rs:795",
|
|
"actions",
|
|
"./src/app/actions.rs:795",
|
|
),
|
|
(
|
|
"open ../herdr-worktrees/issue-1",
|
|
"herdr",
|
|
"../herdr-worktrees/issue-1",
|
|
),
|
|
(
|
|
"edit src/app/actions.rs,then",
|
|
"actions",
|
|
"src/app/actions.rs",
|
|
),
|
|
(
|
|
"cat \"/tmp/build output/log.txt\"",
|
|
"output",
|
|
"/tmp/build output/log.txt",
|
|
),
|
|
(
|
|
"cat '/Users/me/Library/Application Support/app/config.json'",
|
|
"Support",
|
|
"/Users/me/Library/Application Support/app/config.json",
|
|
),
|
|
("echo 你好-world done", "好", "你好-world"),
|
|
("先跑 cargo test", "cargo", "cargo"),
|
|
(
|
|
"export PATH=$HOME/.cargo/bin:$PATH",
|
|
"$HOME",
|
|
"PATH=$HOME/.cargo/bin:$PATH",
|
|
),
|
|
(
|
|
"git checkout feature/foo-bar_baz",
|
|
"foo",
|
|
"feature/foo-bar_baz",
|
|
),
|
|
("refs #123 and @owner/name", "#123", "#123"),
|
|
("refs #123 and @owner/name", "owner", "@owner/name"),
|
|
("cargo test --package=herdr", "--package", "--package=herdr"),
|
|
(
|
|
"cargo test app::actions::tests",
|
|
"app::",
|
|
"app::actions::tests",
|
|
),
|
|
(
|
|
"image ghcr.io/org/app:latest",
|
|
"ghcr",
|
|
"ghcr.io/org/app:latest",
|
|
),
|
|
("ERROR [worker-1] request_id=abc-123", "worker", "worker-1"),
|
|
(
|
|
"tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
|
|
"newhoo",
|
|
"newhoo",
|
|
),
|
|
(
|
|
"render_status_line(app, area)",
|
|
"render",
|
|
"render_status_line",
|
|
),
|
|
("render_status_line(app, area)", "app", "app"),
|
|
("render_status_line(app, area)", "area", "area"),
|
|
("if !enabled {", "enabled", "enabled"),
|
|
("println!(\"hi\")", "println", "println"),
|
|
("( master)$", "master", "master"),
|
|
("regex foo$", "foo", "foo$"),
|
|
];
|
|
|
|
for (row, click, expected) in cases {
|
|
assert_selects(row, click, expected);
|
|
}
|
|
|
|
let row = "echo 你好-world done";
|
|
assert_eq!(
|
|
selected_word(row, col_of(row, "好") + 1).as_deref(),
|
|
Some("你好-world")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn double_click_word_bounds_ignore_delimiters() {
|
|
for (row, click) in [
|
|
(
|
|
"tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
|
|
"|",
|
|
),
|
|
("alpha,beta;gamma", ","),
|
|
("alpha,beta;gamma", ";"),
|
|
("render_status_line(app, area)", "("),
|
|
("render_status_line(app, area)", ")"),
|
|
("if !enabled {", "!"),
|
|
("if !enabled {", "{"),
|
|
("(done).", "("),
|
|
("(done).", "."),
|
|
] {
|
|
assert_selects_nothing(row, click);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn url_at_column_returns_safe_visible_url_only() {
|
|
assert_eq!(
|
|
selected_url("see https://example.com/a(b)c.", "example"),
|
|
Some("https://example.com/a(b)c")
|
|
);
|
|
assert_eq!(
|
|
selected_url("[docs](https://example.com/docs),", "example"),
|
|
Some("https://example.com/docs")
|
|
);
|
|
assert_eq!(
|
|
selected_url("[docs](https://example.com/docs)", "docs"),
|
|
None
|
|
);
|
|
assert_eq!(selected_url("open file:///tmp/report", "file"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
|
|
let mut state = app_with_workspaces(&["single", "multi"]);
|
|
state.workspaces[1].test_add_tab(Some("tests"));
|
|
state.ensure_test_terminals();
|
|
|
|
state.open_navigator();
|
|
let rows = state.navigator_rows();
|
|
|
|
assert!(!rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. }
|
|
)));
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Tab {
|
|
ws_idx: 1,
|
|
tab_idx: 0
|
|
}
|
|
)));
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Tab {
|
|
ws_idx: 1,
|
|
tab_idx: 1
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() {
|
|
let unique = format!(
|
|
"herdr-navigator-runtime-cwd-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
);
|
|
let root = std::env::temp_dir().join(unique);
|
|
let stale_cwd = root.join("issue-264-nix-support");
|
|
let live_cwd = root.join("herdr");
|
|
std::fs::create_dir_all(stale_cwd.join(".git")).unwrap();
|
|
std::fs::create_dir_all(live_cwd.join(".git")).unwrap();
|
|
|
|
let mut state = AppState::test_new();
|
|
let mut workspace = Workspace::test_new("stale-name");
|
|
workspace.custom_name = None;
|
|
workspace.identity_cwd = stale_cwd.clone();
|
|
let pane = workspace.tabs[0].root_pane;
|
|
state.workspaces = vec![workspace];
|
|
state.ensure_test_terminals();
|
|
let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap();
|
|
state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd;
|
|
|
|
let (events, _) = tokio::sync::mpsc::channel(4);
|
|
let runtime = crate::terminal::TerminalRuntime::spawn(
|
|
pane,
|
|
24,
|
|
80,
|
|
live_cwd.clone(),
|
|
0,
|
|
crate::terminal_theme::TerminalTheme::default(),
|
|
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
|
|
&crate::pane::PaneLaunchEnv::default(),
|
|
events,
|
|
std::sync::Arc::new(tokio::sync::Notify::new()),
|
|
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
)
|
|
.unwrap();
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
|
|
let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new();
|
|
runtime_registry.insert(terminal_id, runtime);
|
|
state.open_navigator_from(&runtime_registry);
|
|
state.navigator.query = "herdr".into();
|
|
let rows = state.navigator_rows_from(&runtime_registry);
|
|
|
|
for (_, runtime) in runtime_registry.drain() {
|
|
runtime.shutdown();
|
|
}
|
|
let _ = std::fs::remove_dir_all(root);
|
|
|
|
// The workspace matched by its live cwd label; its subtree cascades in
|
|
// as context.
|
|
assert_eq!(rows.len(), 2);
|
|
assert_eq!(rows[0].label, "herdr (1)");
|
|
assert!(rows[0].matched);
|
|
assert!(!rows[1].matched);
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_rows_include_shell_and_agent_panes() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let shell = state.workspaces[0].tabs[0].root_pane;
|
|
let agent = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
|
|
let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
|
|
let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap();
|
|
terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
|
|
|
|
state.open_navigator();
|
|
let rows = state.navigator_rows();
|
|
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
|
|
)));
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
|
|
) && row.meta.contains("claude")));
|
|
}
|
|
|
|
#[test]
|
|
fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let blocked = state.workspaces[1].tabs[0].root_pane;
|
|
let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&blocked_terminal_id)
|
|
.unwrap()
|
|
.set_detected_state(Some(Agent::Codex), AgentState::Blocked);
|
|
|
|
state.open_navigator();
|
|
let selected = state.navigator_rows()[state.navigator.selected].clone();
|
|
|
|
assert!(selected.is_current);
|
|
assert!(state
|
|
.navigator
|
|
.expanded_workspaces
|
|
.contains(&state.workspaces[0].id));
|
|
assert!(state
|
|
.navigator
|
|
.expanded_workspaces
|
|
.contains(&state.workspaces[1].id));
|
|
}
|
|
|
|
#[test]
|
|
fn accepting_navigator_pane_switches_workspace_tab_and_focus() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let target = state.workspaces[1].tabs[0].root_pane;
|
|
state.open_navigator();
|
|
state
|
|
.navigator
|
|
.expanded_workspaces
|
|
.insert(state.workspaces[1].id.clone());
|
|
state.navigator.selected = state
|
|
.navigator_rows()
|
|
.iter()
|
|
.position(|row| {
|
|
matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target
|
|
)
|
|
})
|
|
.unwrap();
|
|
|
|
assert!(state.accept_navigator_selection());
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(target));
|
|
assert_eq!(state.mode, Mode::Terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_idle_search_matches_idle_agents_not_plain_shells() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let shell = state.workspaces[0].tabs[0].root_pane;
|
|
let agent = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
|
|
let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&agent_terminal_id)
|
|
.unwrap()
|
|
.set_detected_state(Some(Agent::Claude), AgentState::Idle);
|
|
|
|
state.open_navigator();
|
|
state.navigator.query = "idle".into();
|
|
let rows = state.navigator_rows();
|
|
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
|
|
)));
|
|
assert!(!rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_search_only_matches_visible_row_text() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into();
|
|
|
|
state.open_navigator();
|
|
state.navigator.query = "work".into();
|
|
|
|
assert!(state.navigator_rows().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_state_filter_is_separate_from_text_search() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let shell = state.workspaces[0].tabs[0].root_pane;
|
|
let working = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
|
|
let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&shell_terminal_id)
|
|
.unwrap()
|
|
.set_manual_label("wheel notes".into());
|
|
let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&working_terminal_id)
|
|
.unwrap()
|
|
.set_detected_state(Some(Agent::Codex), AgentState::Working);
|
|
|
|
state.open_navigator();
|
|
state.navigator.state_filter = Some(NavigatorStateFilter::Working);
|
|
let state_rows = state.navigator_rows();
|
|
|
|
assert!(state_rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
|
|
)));
|
|
assert!(!state_rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
|
|
)));
|
|
|
|
state.navigator.state_filter = None;
|
|
state.navigator.query = "w".into();
|
|
let text_rows = state.navigator_rows();
|
|
|
|
assert!(text_rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
|
|
)));
|
|
assert!(
|
|
text_rows.iter().any(|row| matches!(
|
|
row.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
|
|
)),
|
|
"literal one-letter search may still match visible state text"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_search_filters_panes_but_keeps_workspace_context() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_manual_label("weekly review".into());
|
|
state.open_navigator();
|
|
state.navigator.query = "weekly".into();
|
|
|
|
let rows = state.navigator_rows();
|
|
|
|
assert!(rows.iter().any(|row| row.is_workspace));
|
|
assert!(rows
|
|
.iter()
|
|
.any(|row| !row.is_workspace && row.label.contains("weekly")));
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_workspace_match_cascades_full_subtree() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let extra = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
for pane in [root, extra] {
|
|
let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_manual_label("unrelated".into());
|
|
}
|
|
|
|
state.open_navigator();
|
|
state.navigator.query = "one".into();
|
|
let rows = state.navigator_rows();
|
|
|
|
// Both panes cascade in even though only the workspace label matched,
|
|
// and only the workspace carries the matched flag.
|
|
let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect();
|
|
assert_eq!(pane_rows.len(), 2);
|
|
assert!(pane_rows.iter().all(|row| !row.matched));
|
|
assert!(rows.iter().any(|row| row.is_workspace && row.matched));
|
|
assert!(!rows.iter().any(|row| row.label.starts_with("two")));
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_search_selects_first_self_match() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let pane = state.workspaces[1].tabs[0].root_pane;
|
|
state.ensure_test_terminals();
|
|
let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_manual_label("pi ui build".into());
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
state.open_navigator_from(&terminal_runtimes);
|
|
state.navigator.query = "ui".into();
|
|
state.select_first_navigator_match_from(&terminal_runtimes);
|
|
|
|
let rows = state.navigator_rows_from(&terminal_runtimes);
|
|
let selected = &rows[state.navigator.selected];
|
|
assert!(matches!(
|
|
selected.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn navigator_state_filter_selects_matching_pane_over_workspace() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let working = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_detected_state(Some(Agent::Codex), AgentState::Working);
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
state.open_navigator_from(&terminal_runtimes);
|
|
state.navigator.state_filter = Some(NavigatorStateFilter::Working);
|
|
state.select_first_navigator_match_from(&terminal_runtimes);
|
|
|
|
let rows = state.navigator_rows_from(&terminal_runtimes);
|
|
let selected = &rows[state.navigator.selected];
|
|
assert!(matches!(
|
|
selected.target,
|
|
crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_workspace_git_statuses_updates_matching_workspace() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let first_id = state.workspaces[0].id.clone();
|
|
let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
|
|
let second_id = state.workspaces[1].id.clone();
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
let changed = state.apply_workspace_git_statuses(
|
|
&terminal_runtimes,
|
|
vec![WorkspaceGitStatus {
|
|
workspace_id: first_id,
|
|
resolved_identity_cwd: first_cwd.clone(),
|
|
status_cache_key: first_cwd,
|
|
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
|
auto_label: "one".into(),
|
|
branch: Some("main".into()),
|
|
ahead_behind: Some((2, 1)),
|
|
space: None,
|
|
}],
|
|
);
|
|
|
|
assert!(changed);
|
|
assert_eq!(state.workspaces[0].branch().as_deref(), Some("main"));
|
|
assert_eq!(state.workspaces[0].git_ahead_behind(), Some((2, 1)));
|
|
assert_eq!(state.workspaces[1].id, second_id);
|
|
assert_eq!(state.workspaces[1].git_ahead_behind(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_workspace_git_statuses_ignores_stale_cwd() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let workspace_id = state.workspaces[0].id.clone();
|
|
state.workspaces[0].cached_git_branch = Some("old".into());
|
|
state.workspaces[0].cached_git_ahead_behind = Some((1, 0));
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
let changed = state.apply_workspace_git_statuses(
|
|
&terminal_runtimes,
|
|
vec![WorkspaceGitStatus {
|
|
workspace_id,
|
|
resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
|
|
status_cache_key: std::path::PathBuf::from("/definitely/not/current"),
|
|
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
|
auto_label: "stale".into(),
|
|
branch: Some("main".into()),
|
|
ahead_behind: Some((0, 1)),
|
|
space: None,
|
|
}],
|
|
);
|
|
|
|
assert!(!changed);
|
|
assert_eq!(state.workspaces[0].branch().as_deref(), Some("old"));
|
|
assert_eq!(state.workspaces[0].git_ahead_behind(), Some((1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_workspace_git_statuses_ignores_unrequested_branch_changes() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let workspace_id = state.workspaces[0].id.clone();
|
|
let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
|
|
state.workspaces[0].cached_auto_label = "one".into();
|
|
state.workspaces[0].cached_git_branch = Some("old".into());
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
let changed = state.apply_workspace_git_statuses(
|
|
&terminal_runtimes,
|
|
vec![WorkspaceGitStatus {
|
|
workspace_id,
|
|
resolved_identity_cwd: cwd.clone(),
|
|
status_cache_key: cwd,
|
|
demand: crate::workspace::GitStatusRefreshDemand {
|
|
branch: false,
|
|
ahead_behind: true,
|
|
},
|
|
auto_label: "one".into(),
|
|
branch: Some("new".into()),
|
|
ahead_behind: None,
|
|
space: None,
|
|
}],
|
|
);
|
|
|
|
assert!(!changed);
|
|
assert_eq!(state.workspaces[0].branch().as_deref(), Some("old"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_workspace_git_statuses_clears_missing_git_status() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let workspace_id = state.workspaces[0].id.clone();
|
|
let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
|
|
state.workspaces[0].cached_git_branch = Some("main".into());
|
|
state.workspaces[0].cached_git_ahead_behind = Some((1, 2));
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
let changed = state.apply_workspace_git_statuses(
|
|
&terminal_runtimes,
|
|
vec![WorkspaceGitStatus {
|
|
workspace_id,
|
|
resolved_identity_cwd: cwd.clone(),
|
|
status_cache_key: cwd,
|
|
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
|
auto_label: "one".into(),
|
|
branch: None,
|
|
ahead_behind: None,
|
|
space: None,
|
|
}],
|
|
);
|
|
|
|
assert!(changed);
|
|
assert_eq!(state.workspaces[0].branch(), None);
|
|
assert_eq!(state.workspaces[0].git_ahead_behind(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_workspace_git_statuses_does_not_change_worktree_membership() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
mark_linked_worktree(&mut state, 0);
|
|
let workspace_id = state.workspaces[0].id.clone();
|
|
let cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
|
|
let membership = state.workspaces[0].worktree_space().cloned();
|
|
|
|
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
|
let changed = state.apply_workspace_git_statuses(
|
|
&terminal_runtimes,
|
|
vec![WorkspaceGitStatus {
|
|
workspace_id,
|
|
resolved_identity_cwd: cwd.clone(),
|
|
status_cache_key: cwd,
|
|
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
|
auto_label: "other".into(),
|
|
branch: Some("scratch".into()),
|
|
ahead_behind: None,
|
|
space: Some(crate::workspace::GitSpaceMetadata {
|
|
key: "other-repo-key".into(),
|
|
checkout_key: "/other/checkout".into(),
|
|
label: "other".into(),
|
|
repo_root: "/other/repo".into(),
|
|
is_linked_worktree: false,
|
|
}),
|
|
}],
|
|
);
|
|
|
|
assert!(changed);
|
|
assert_eq!(state.workspaces[0].worktree_space().cloned(), membership);
|
|
}
|
|
|
|
fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) {
|
|
set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle);
|
|
}
|
|
|
|
fn set_agent_state(
|
|
state: &mut AppState,
|
|
ws_idx: usize,
|
|
tab_idx: usize,
|
|
pane_id: PaneId,
|
|
agent_state: AgentState,
|
|
) {
|
|
state.ensure_test_terminals();
|
|
let terminal_id = state.workspaces[ws_idx].tabs[tab_idx]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
|
|
terminal.set_detected_state(Some(Agent::Pi), agent_state);
|
|
}
|
|
}
|
|
|
|
fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) {
|
|
state
|
|
.update_terminal_state(pane_id, |terminal| {
|
|
Some(terminal.set_detected_state_with_screen_signals_at(
|
|
Some(Agent::Pi),
|
|
agent_state,
|
|
matches!(agent_state, AgentState::Blocked),
|
|
false,
|
|
false,
|
|
false,
|
|
std::time::Instant::now(),
|
|
))
|
|
})
|
|
.expect("agent state transition should update pane state");
|
|
}
|
|
|
|
#[test]
|
|
fn next_agent_cycles_agent_panel_entries() {
|
|
let mut first = Workspace::test_new("one");
|
|
let first_root = first.tabs[0].root_pane;
|
|
let first_second = first.test_split(Direction::Horizontal);
|
|
first.tabs[0].layout.focus_pane(first_root);
|
|
let second = Workspace::test_new("two");
|
|
let second_root = second.tabs[0].root_pane;
|
|
|
|
let mut state = AppState::test_new();
|
|
state.workspaces = vec![first, second];
|
|
state.ensure_test_terminals();
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
state.mode = Mode::Terminal;
|
|
mark_agent(&mut state, 0, 0, first_root);
|
|
mark_agent(&mut state, 0, 0, first_second);
|
|
mark_agent(&mut state, 1, 0, second_root);
|
|
|
|
state.next_agent();
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
|
|
|
|
state.next_agent();
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
|
|
|
state.previous_agent();
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn focus_agent_entry_uses_agent_panel_order() {
|
|
let mut first = Workspace::test_new("one");
|
|
let first_root = first.tabs[0].root_pane;
|
|
let first_second = first.test_split(Direction::Horizontal);
|
|
first.tabs[0].layout.focus_pane(first_root);
|
|
let second = Workspace::test_new("two");
|
|
let second_root = second.tabs[0].root_pane;
|
|
|
|
let mut state = AppState::test_new();
|
|
state.workspaces = vec![first, second];
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
state.mode = Mode::Terminal;
|
|
mark_agent(&mut state, 0, 0, first_root);
|
|
mark_agent(&mut state, 0, 0, first_second);
|
|
mark_agent(&mut state, 1, 0, second_root);
|
|
|
|
assert!(state.focus_agent_entry(2));
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn focus_agent_entry_succeeds_for_already_focused_agent() {
|
|
let mut state = app_with_workspaces(&["one"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
mark_agent(&mut state, 0, 0, root);
|
|
|
|
assert!(state.focus_agent_entry(0));
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn next_agent_cycles_priority_sorted_agent_panel_entries() {
|
|
let mut first = Workspace::test_new("one");
|
|
let first_root = first.tabs[0].root_pane;
|
|
let first_second = first.test_split(Direction::Horizontal);
|
|
first.tabs[0].layout.focus_pane(first_root);
|
|
let second = Workspace::test_new("two");
|
|
let second_root = second.tabs[0].root_pane;
|
|
|
|
let mut state = AppState::test_new();
|
|
state.workspaces = vec![first, second];
|
|
state.ensure_test_terminals();
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
state.mode = Mode::Terminal;
|
|
state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
|
|
set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle);
|
|
set_agent_state(&mut state, 0, 0, first_second, AgentState::Working);
|
|
set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked);
|
|
|
|
state.next_agent();
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() {
|
|
let mut workspace = Workspace::test_new("one");
|
|
let first = workspace.tabs[0].root_pane;
|
|
let second = workspace.test_split(Direction::Horizontal);
|
|
workspace.tabs[0].layout.focus_pane(first);
|
|
|
|
let mut state = AppState::test_new();
|
|
state.workspaces = vec![workspace];
|
|
state.ensure_test_terminals();
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
state.mode = Mode::Terminal;
|
|
state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
|
|
|
|
transition_agent_state(&mut state, first, AgentState::Idle);
|
|
transition_agent_state(&mut state, second, AgentState::Working);
|
|
assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
|
|
|
|
transition_agent_state(&mut state, second, AgentState::Idle);
|
|
|
|
assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() {
|
|
let mut workspace = Workspace::test_new("one");
|
|
let root = workspace.tabs[0].root_pane;
|
|
for idx in 1..8 {
|
|
workspace.test_add_tab(Some(&format!("tab-{idx}")));
|
|
}
|
|
|
|
let mut state = AppState::test_new();
|
|
state.workspaces = vec![workspace];
|
|
state.ensure_test_terminals();
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
state.mode = Mode::Terminal;
|
|
for tab_idx in 0..state.workspaces[0].tabs.len() {
|
|
let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
|
|
mark_agent(&mut state, 0, tab_idx, pane_id);
|
|
}
|
|
state.workspaces[0].tabs[0].layout.focus_pane(root);
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
|
|
|
|
state.previous_agent();
|
|
|
|
let last_idx = state.workspaces[0].tabs.len() - 1;
|
|
assert_eq!(state.workspaces[0].active_tab, last_idx);
|
|
assert!(state.agent_panel_scroll > 0);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn switch_workspace_updates_active_and_selected() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
state.switch_workspace(2);
|
|
assert_eq!(state.active, Some(2));
|
|
assert_eq!(state.selected, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn last_pane_toggles_to_previous_focus_in_active_tab() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
|
|
|
state.focus_pane_in_workspace(0, root);
|
|
state.focus_pane_in_workspace(0, right);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
|
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
|
|
}
|
|
|
|
#[test]
|
|
fn removing_background_pane_preserves_last_pane_history() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
|
let background = state.workspaces[0].test_split(Direction::Horizontal);
|
|
|
|
state.focus_pane_in_workspace(0, root);
|
|
state.focus_pane_in_workspace(0, right);
|
|
state.workspaces[0].remove_pane(background);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
|
}
|
|
|
|
#[test]
|
|
fn last_pane_jumps_across_workspaces_and_tabs() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let first_root = state.workspaces[0].tabs[0].root_pane;
|
|
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
|
|
let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
|
|
|
|
state.focus_pane_in_workspace(0, first_root);
|
|
state.focus_pane_in_workspace(1, second_tab_root);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].active_tab, 0);
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
|
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].active_tab, second_tab);
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
|
|
}
|
|
|
|
#[test]
|
|
fn last_pane_tracks_tab_and_workspace_switches() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let first_root = state.workspaces[0].tabs[0].root_pane;
|
|
let first_second_tab = state.workspaces[0].test_add_tab(Some("logs"));
|
|
let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane;
|
|
let second_root = state.workspaces[1].tabs[0].root_pane;
|
|
|
|
state.switch_tab(first_second_tab);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].active_tab, 0);
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
|
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].active_tab, first_second_tab);
|
|
assert_eq!(
|
|
state.workspaces[0].focused_pane_id(),
|
|
Some(first_second_root)
|
|
);
|
|
|
|
state.switch_workspace(1);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].active_tab, first_second_tab);
|
|
assert_eq!(
|
|
state.workspaces[0].focused_pane_id(),
|
|
Some(first_second_root)
|
|
);
|
|
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
|
}
|
|
|
|
#[test]
|
|
fn last_pane_tracks_cross_workspace_tab_selection() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let first_root = state.workspaces[0].tabs[0].root_pane;
|
|
let second_first_root = state.workspaces[1].tabs[0].root_pane;
|
|
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
|
|
let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
|
|
|
|
state.switch_workspace_tab(1, second_tab);
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
|
|
|
|
state.last_pane();
|
|
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].active_tab, second_tab);
|
|
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
|
|
assert_ne!(second_first_root, second_tab_root);
|
|
}
|
|
|
|
#[test]
|
|
fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]);
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
|
|
|
|
state.switch_workspace(7);
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
|
|
|
|
assert!(state
|
|
.view
|
|
.workspace_card_areas
|
|
.iter()
|
|
.any(|card| card.ws_idx == 7));
|
|
}
|
|
|
|
#[test]
|
|
fn switch_workspace_marks_panes_seen() {
|
|
let mut state = app_with_workspaces(&["a", "b"]);
|
|
// Mark a pane in workspace 1 as unseen
|
|
let id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
state.workspaces[1].panes.get_mut(&id).unwrap().seen = false;
|
|
|
|
state.switch_workspace(1);
|
|
assert!(state.workspaces[1].panes.get(&id).unwrap().seen);
|
|
}
|
|
|
|
#[test]
|
|
fn switch_workspace_out_of_bounds_is_noop() {
|
|
let mut state = app_with_workspaces(&["a"]);
|
|
state.switch_workspace(5);
|
|
assert_eq!(state.active, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn move_workspace_reorders_without_changing_logical_selection() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
let active_id = state.workspaces[1].id.clone();
|
|
let selected_id = state.workspaces[2].id.clone();
|
|
state.active = Some(1);
|
|
state.selected = 2;
|
|
|
|
state.move_workspace(1, 0);
|
|
|
|
let names: Vec<_> = state
|
|
.workspaces
|
|
.iter()
|
|
.map(|ws| ws.display_name())
|
|
.collect();
|
|
assert_eq!(names, vec!["b", "a", "c"]);
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.selected, 2);
|
|
assert_eq!(state.workspaces[state.active.unwrap()].id, active_id);
|
|
assert_eq!(state.workspaces[state.selected].id, selected_id);
|
|
}
|
|
|
|
#[test]
|
|
fn move_workspace_accepts_insert_at_end() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
|
|
state.move_workspace(0, state.workspaces.len());
|
|
|
|
let names: Vec<_> = state
|
|
.workspaces
|
|
.iter()
|
|
.map(|ws| ws.display_name())
|
|
.collect();
|
|
assert_eq!(names, vec!["b", "c", "a"]);
|
|
}
|
|
|
|
#[test]
|
|
fn move_workspace_block_collects_non_contiguous_members() {
|
|
let mut state =
|
|
app_with_workspaces(&["child-one", "normal", "parent", "child-two", "tail"]);
|
|
let parent_id = state.workspaces[2].id.clone();
|
|
let child_one_id = state.workspaces[0].id.clone();
|
|
let child_two_id = state.workspaces[3].id.clone();
|
|
let tail_id = state.workspaces[4].id.clone();
|
|
state.active = Some(0);
|
|
state.selected = 4;
|
|
|
|
assert!(state.move_workspace_block(
|
|
&[parent_id, child_one_id.clone(), child_two_id],
|
|
Some(&tail_id),
|
|
));
|
|
|
|
let names = state
|
|
.workspaces
|
|
.iter()
|
|
.map(|workspace| workspace.display_name())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
names,
|
|
["normal", "parent", "child-one", "child-two", "tail"]
|
|
);
|
|
assert_eq!(state.workspaces[state.active.unwrap()].id, child_one_id);
|
|
assert_eq!(state.workspaces[state.selected].id, tail_id);
|
|
}
|
|
|
|
#[test]
|
|
fn move_workspace_block_rejects_invalid_and_noop_orders() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
let ids = state
|
|
.workspaces
|
|
.iter()
|
|
.map(|workspace| workspace.id.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(!state.move_workspace_block(&[], None));
|
|
assert!(!state.move_workspace_block(&[ids[0].clone(), ids[0].clone()], None));
|
|
assert!(!state.move_workspace_block(&["missing".into()], None));
|
|
assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[0])));
|
|
assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[1])));
|
|
assert_eq!(
|
|
state
|
|
.workspaces
|
|
.iter()
|
|
.map(|workspace| workspace.display_name())
|
|
.collect::<Vec<_>>(),
|
|
["a", "b", "c"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn close_workspace_adjusts_indices() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
state.selected = 1;
|
|
state.active = Some(1);
|
|
|
|
state.close_selected_workspace();
|
|
|
|
assert_eq!(state.workspaces.len(), 2);
|
|
assert_eq!(state.selected, 1);
|
|
assert_eq!(state.active, Some(1));
|
|
assert_eq!(state.workspaces[1].custom_name.as_deref(), Some("c"));
|
|
}
|
|
|
|
#[test]
|
|
fn close_parent_worktree_workspace_closes_group() {
|
|
let mut state = app_with_workspaces(&["main", "issue", "notes"]);
|
|
state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
|
|
key: "repo-key".into(),
|
|
label: "herdr".into(),
|
|
repo_root: "/repo/herdr".into(),
|
|
checkout_path: "/repo/herdr".into(),
|
|
is_linked_worktree: false,
|
|
});
|
|
state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
|
|
key: "repo-key".into(),
|
|
label: "herdr".into(),
|
|
repo_root: "/repo/herdr".into(),
|
|
checkout_path: "/repo/herdr-issue".into(),
|
|
is_linked_worktree: true,
|
|
});
|
|
state.selected = 0;
|
|
state.active = Some(0);
|
|
|
|
state.close_selected_workspace();
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "notes");
|
|
assert_eq!(state.active, Some(0));
|
|
assert_eq!(state.selected, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn close_last_workspace_clears_active() {
|
|
let mut state = app_with_workspaces(&["only"]);
|
|
state.selected = 0;
|
|
state.close_selected_workspace();
|
|
|
|
assert!(state.workspaces.is_empty());
|
|
assert_eq!(state.active, None);
|
|
assert_eq!(state.selected, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn close_workspace_at_end_adjusts_selected() {
|
|
let mut state = app_with_workspaces(&["a", "b"]);
|
|
state.selected = 1;
|
|
state.active = Some(1);
|
|
|
|
state.close_selected_workspace();
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.selected, 0);
|
|
assert_eq!(state.active, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn close_non_focused_workspace_keeps_focus() {
|
|
let mut state = app_with_workspaces(&["a", "b", "c"]);
|
|
state.selected = 1;
|
|
state.active = Some(0);
|
|
|
|
state.close_selected_workspace();
|
|
|
|
assert_eq!(state.workspaces.len(), 2);
|
|
assert_eq!(state.workspaces[0].display_name(), "a");
|
|
assert_eq!(state.workspaces[1].display_name(), "c");
|
|
assert_eq!(state.selected, 0);
|
|
assert_eq!(state.active, Some(0));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_last_pane_removes_workspace() {
|
|
let mut state = app_with_workspaces(&["a", "b"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_pane_died(pane_id);
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].custom_name.as_deref(), Some("b"));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_last_workspace_enters_navigate() {
|
|
let mut state = app_with_workspaces(&["only"]);
|
|
state.mode = Mode::Terminal;
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_pane_died(pane_id);
|
|
|
|
assert!(state.workspaces.is_empty());
|
|
assert_eq!(state.mode, Mode::Navigate);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_multi_pane_keeps_workspace() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let second_id = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
|
|
state.handle_pane_died(second_id);
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].panes.len(), 1);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_unknown_pane_is_noop() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let fake_id = PaneId::from_raw(9999);
|
|
|
|
state.handle_pane_died(fake_id);
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_unrelated_pane_preserves_selection() {
|
|
// Two workspaces; user is selecting text in workspace 0.
|
|
// A pane in workspace 1 dies — selection must be preserved.
|
|
let mut state = app_with_workspaces(&["active", "bg"]);
|
|
let active_pane = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let bg_pane = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None));
|
|
state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
|
|
direction: crate::app::state::SelectionAutoscrollDirection::Down,
|
|
last_mouse_screen_col: 0,
|
|
last_mouse_screen_row: 23,
|
|
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
|
});
|
|
|
|
state.handle_pane_died(bg_pane);
|
|
|
|
assert!(state.selection.is_some());
|
|
assert!(state.selection_autoscroll.is_some());
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_died_same_pane_clears_selection() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let first_id = state.workspaces[0].tabs[0].root_pane;
|
|
let second_id = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
|
|
state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None));
|
|
state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
|
|
direction: crate::app::state::SelectionAutoscrollDirection::Down,
|
|
last_mouse_screen_col: 0,
|
|
last_mouse_screen_row: 23,
|
|
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
|
});
|
|
|
|
state.handle_pane_died(second_id);
|
|
|
|
// first_id still alive, workspace stays, but selection was on the dying pane
|
|
assert!(state.selection.is_none());
|
|
assert!(state.selection_autoscroll.is_none());
|
|
assert_eq!(state.workspaces[0].panes.len(), 1);
|
|
assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id);
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn state_changed_updates_pane() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Working,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let terminal_id = state.workspaces[0]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
let terminal = state.terminals.get(&terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Working);
|
|
assert_eq!(terminal.detected_agent, Some(Agent::Pi));
|
|
}
|
|
|
|
#[test]
|
|
fn state_changed_idle_in_background_marks_unseen() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.active = Some(0);
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
// First set it to Working
|
|
let bg_terminal_id = state.workspaces[1]
|
|
.panes
|
|
.get(&bg_pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working;
|
|
|
|
// Now transition to Idle while in background
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
|
|
assert!(!pane.seen);
|
|
assert!(matches!(
|
|
state.toast.as_ref().map(|toast| toast.kind),
|
|
Some(ToastKind::Finished)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn active_tab_completion_marks_pane_seen() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.outer_terminal_focus = Some(true);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
state.terminals.get_mut(&terminal_id).unwrap().state = AgentState::Working;
|
|
state.workspaces[0].panes.get_mut(&pane_id).unwrap().seen = false;
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let terminal = state.terminals.get(&terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Idle);
|
|
let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
|
|
assert!(pane.seen);
|
|
}
|
|
|
|
#[test]
|
|
fn initial_idle_in_background_stays_seen() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
|
|
assert!(pane.seen);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_after_known_unknown_agent_in_background_marks_done() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.active = Some(0);
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Unknown,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
|
|
assert!(!pane.seen);
|
|
}
|
|
|
|
#[test]
|
|
fn waiting_sound_plays_even_in_active_workspace() {
|
|
assert_eq!(
|
|
notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked),
|
|
Some(crate::sound::Sound::Request)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn done_sound_only_plays_in_background() {
|
|
assert_eq!(
|
|
notification_sound_for_state_change(false, AgentState::Working, AgentState::Idle),
|
|
Some(crate::sound::Sound::Done)
|
|
);
|
|
assert_eq!(
|
|
notification_sound_for_state_change(true, AgentState::Working, AgentState::Idle),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
notification_sound_for_state_change(false, AgentState::Unknown, AgentState::Idle),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn background_waiting_sets_attention_toast() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "pi needs attention");
|
|
assert_eq!(toast.context, "background · 2");
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_background_waiting_schedules_before_toast() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.toast_config.delay_seconds = 1;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
assert!(state.toast.is_none());
|
|
assert!(state.pending_agent_notifications.contains_key(&bg_pane_id));
|
|
|
|
let deadline = state.next_pending_agent_notification_deadline().unwrap();
|
|
let deliveries = state.drain_due_agent_notifications(deadline);
|
|
assert_eq!(deliveries.len(), 1);
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "pi needs attention");
|
|
assert_eq!(toast.context, "background · 2");
|
|
assert!(state.pending_agent_notifications.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_background_waiting_cancels_when_agent_resumes_working() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.toast_config.delay_seconds = 1;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
let deadline = state.next_pending_agent_notification_deadline().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Working,
|
|
visible_blocker: false,
|
|
visible_working: true,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
assert!(state.pending_agent_notifications.is_empty());
|
|
assert!(state.drain_due_agent_notifications(deadline).is_empty());
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_background_waiting_is_suppressed_if_pane_becomes_active() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.toast_config.delay_seconds = 1;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
let deadline = state.next_pending_agent_notification_deadline().unwrap();
|
|
state.active = Some(1);
|
|
|
|
assert!(state.drain_due_agent_notifications(deadline).is_empty());
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_active_tab_unfocused_keeps_client_notification_available() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.outer_terminal_focus = Some(false);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::System;
|
|
state.toast_config.delay_seconds = 1;
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let deadline = state.next_pending_agent_notification_deadline().unwrap();
|
|
let deliveries = state.drain_due_agent_notifications(deadline);
|
|
|
|
assert_eq!(deliveries.len(), 1);
|
|
assert!(deliveries[0].toast.is_none());
|
|
assert!(deliveries[0].client_notification.is_some());
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_background_waiting_is_cleared_when_pane_dies() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.toast_config.delay_seconds = 1;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
let deadline = state.next_pending_agent_notification_deadline().unwrap();
|
|
state.handle_app_event(AppEvent::PaneDied {
|
|
pane_id: bg_pane_id,
|
|
});
|
|
|
|
assert!(state.pending_agent_notifications.is_empty());
|
|
assert!(state.drain_due_agent_notifications(deadline).is_empty());
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_reported_unknown_agent_sets_toast_title_from_label() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id: bg_pane_id,
|
|
source: "custom:hermes".into(),
|
|
agent_label: "hermes".into(),
|
|
state: AgentState::Blocked,
|
|
message: None,
|
|
seq: None,
|
|
session_ref: None,
|
|
});
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "hermes needs attention");
|
|
assert_eq!(toast.context, "background · 2");
|
|
}
|
|
|
|
#[test]
|
|
fn visible_blocker_overrides_hook_working_and_notifies() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
let bg_terminal_id = state.workspaces[1]
|
|
.panes
|
|
.get(&bg_pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Codex),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id: bg_pane_id,
|
|
source: "herdr:codex".into(),
|
|
agent_label: "codex".into(),
|
|
state: AgentState::Working,
|
|
message: None,
|
|
seq: Some(1),
|
|
session_ref: None,
|
|
});
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Codex),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: true,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let terminal = state.terminals.get(&bg_terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Blocked);
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "codex needs attention");
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_native_state_report_does_not_override_screen_state() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Claude),
|
|
state: AgentState::Working,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id,
|
|
source: "herdr:claude".into(),
|
|
agent_label: "claude".into(),
|
|
state: AgentState::Blocked,
|
|
message: None,
|
|
seq: Some(1),
|
|
session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"),
|
|
});
|
|
let terminal = state.terminals.get(&terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Working);
|
|
assert!(terminal.hook_authority.is_none());
|
|
assert!(terminal.persisted_agent_session.is_some());
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Claude),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let terminal = state.terminals.get(&terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Idle);
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn official_release_preserves_process_owned_agent_identity() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Working,
|
|
visible_blocker: false,
|
|
visible_working: true,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
let terminal = state.terminals.get_mut(&terminal_id).unwrap();
|
|
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
|
source: "herdr:pi".into(),
|
|
agent: "pi".into(),
|
|
session_ref: crate::agent_resume::AgentSessionRef::path(
|
|
std::env::current_dir()
|
|
.unwrap()
|
|
.join("release-session.jsonl")
|
|
.display()
|
|
.to_string(),
|
|
)
|
|
.unwrap(),
|
|
});
|
|
terminal.set_hook_authority(
|
|
"herdr:pi".into(),
|
|
"pi".into(),
|
|
AgentState::Working,
|
|
None,
|
|
Some(1),
|
|
);
|
|
terminal.set_agent_name("reviewer".into());
|
|
state.session_dirty = false;
|
|
|
|
let updates = state.handle_app_event(AppEvent::HookAgentReleased {
|
|
pane_id,
|
|
source: "herdr:pi".into(),
|
|
agent_label: "pi".into(),
|
|
known_agent: Some(Agent::Pi),
|
|
seq: Some(2),
|
|
});
|
|
|
|
assert!(updates.is_empty());
|
|
let terminal = &state.terminals[&terminal_id];
|
|
assert_eq!(terminal.state, AgentState::Working);
|
|
assert_eq!(terminal.detected_agent, Some(Agent::Pi));
|
|
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
|
|
assert!(terminal.full_lifecycle_hook_authority_active());
|
|
assert!(!state.session_dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn devin_state_report_refreshes_session_without_overriding_screen_state() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.panes
|
|
.get(&pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Devin),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id,
|
|
source: "herdr:devin".into(),
|
|
agent_label: "devin".into(),
|
|
state: AgentState::Working,
|
|
message: None,
|
|
seq: Some(1),
|
|
session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"),
|
|
});
|
|
|
|
let terminal = state.terminals.get(&terminal_id).unwrap();
|
|
assert_eq!(terminal.state, AgentState::Idle);
|
|
assert!(terminal.hook_authority.is_none());
|
|
assert!(terminal.persisted_agent_session.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let test_dir = std::env::current_dir().unwrap();
|
|
let first_session = test_dir.join("one.jsonl").display().to_string();
|
|
let second_session = test_dir.join("two.jsonl").display().to_string();
|
|
|
|
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id,
|
|
source: "custom:pi".into(),
|
|
agent_label: "pi".into(),
|
|
state: AgentState::Working,
|
|
message: None,
|
|
seq: Some(20),
|
|
session_ref: crate::agent_resume::AgentSessionRef::path(first_session),
|
|
});
|
|
assert_eq!(first_updates.len(), 1);
|
|
state.session_dirty = false;
|
|
|
|
let second_updates = state.handle_app_event(AppEvent::HookStateReported {
|
|
pane_id,
|
|
source: "custom:pi".into(),
|
|
agent_label: "pi".into(),
|
|
state: AgentState::Working,
|
|
message: None,
|
|
seq: Some(21),
|
|
session_ref: crate::agent_resume::AgentSessionRef::path(second_session),
|
|
});
|
|
|
|
assert!(second_updates.is_empty());
|
|
assert!(state.session_dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_release_clears_report_owned_agent() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.pane_state(pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_hook_authority(
|
|
"custom:agent".into(),
|
|
"custom-agent".into(),
|
|
AgentState::Working,
|
|
None,
|
|
Some(1),
|
|
);
|
|
|
|
state.handle_app_event(AppEvent::HookAgentReleased {
|
|
pane_id,
|
|
source: "custom:agent".into(),
|
|
agent_label: "custom-agent".into(),
|
|
known_agent: None,
|
|
seq: Some(2),
|
|
});
|
|
|
|
let terminal = &state.terminals[&terminal_id];
|
|
assert!(terminal.hook_authority.is_none());
|
|
assert_eq!(terminal.state, AgentState::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
let terminal_id = state.workspaces[0]
|
|
.pane_state(pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
let cwd =
|
|
std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&cwd).unwrap();
|
|
state.session_dirty = false;
|
|
|
|
let updates = state.handle_app_event(AppEvent::TerminalCwdReported {
|
|
pane_id,
|
|
cwd: cwd.clone(),
|
|
});
|
|
|
|
assert!(updates.is_empty());
|
|
assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd);
|
|
assert!(state.session_dirty);
|
|
let _ = std::fs::remove_dir_all(cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn background_idle_sets_finished_toast() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
|
let bg_terminal_id = state.workspaces[1]
|
|
.panes
|
|
.get(&bg_pane_id)
|
|
.unwrap()
|
|
.attached_terminal_id
|
|
.clone();
|
|
state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working;
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Droid),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::Finished);
|
|
assert_eq!(toast.title, "droid finished");
|
|
assert_eq!(toast.context, "background · 2");
|
|
let target = toast.target.as_ref().expect("toast target");
|
|
assert_eq!(&target.workspace_id, &state.workspaces[1].id);
|
|
assert_eq!(target.pane_id, bg_pane_id);
|
|
}
|
|
|
|
#[test]
|
|
fn background_toast_includes_tab_name_when_workspace_has_multiple_tabs() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.workspaces[1].tabs[0].set_custom_name("main".into());
|
|
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
|
|
state.ensure_test_terminals();
|
|
let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane;
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "pi needs attention");
|
|
assert_eq!(toast.context, "background · 2 · logs");
|
|
}
|
|
|
|
#[test]
|
|
fn background_tab_in_active_workspace_still_sets_toast() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.workspaces[0].tabs[0].set_custom_name("main".into());
|
|
let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
|
|
state.ensure_test_terminals();
|
|
let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane;
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id: bg_pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
let toast = state.toast.as_ref().unwrap();
|
|
assert_eq!(toast.kind, ToastKind::NeedsAttention);
|
|
assert_eq!(toast.title, "pi needs attention");
|
|
assert_eq!(toast.context, "active · 1 · logs");
|
|
}
|
|
|
|
#[test]
|
|
fn active_workspace_active_tab_does_not_set_toast() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn active_workspace_active_tab_keeps_herdr_toast_suppressed_when_outer_terminal_is_unfocused() {
|
|
let mut state = app_with_workspaces(&["active"]);
|
|
state.active = Some(0);
|
|
state.outer_terminal_focus = Some(false);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
|
|
|
state.handle_app_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Blocked,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: false,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
assert!(state.toast.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn active_tab_suppression_preserves_unknown_focus_behavior() {
|
|
assert!(active_tab_suppresses_notifications(true, None));
|
|
assert!(active_tab_suppresses_notifications(true, Some(true)));
|
|
assert!(!active_tab_suppresses_notifications(true, Some(false)));
|
|
assert!(!active_tab_suppresses_notifications(false, None));
|
|
}
|
|
|
|
#[test]
|
|
fn update_ready_sets_manual_update_toast() {
|
|
let mut state = AppState::test_new();
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
|
|
let updates = state.handle_app_event(AppEvent::UpdateReady {
|
|
version: "0.5.0".into(),
|
|
install_command: "herdr update".into(),
|
|
});
|
|
|
|
assert!(updates.is_empty());
|
|
assert_eq!(state.update_available.as_deref(), Some("0.5.0"));
|
|
assert!(state.latest_release_notes_available);
|
|
assert!(state.update_dismissed);
|
|
let toast = state.toast.as_ref().expect("update toast");
|
|
assert_eq!(toast.kind, ToastKind::UpdateInstalled);
|
|
assert_eq!(toast.title, "v0.5.0 available");
|
|
assert_eq!(
|
|
toast.context,
|
|
"detach, run `herdr update`, then follow its restart guidance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_ready_uses_event_install_command_in_toast() {
|
|
let mut state = AppState::test_new();
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
|
|
state.handle_app_event(AppEvent::UpdateReady {
|
|
version: "0.5.0".into(),
|
|
install_command: "brew update && brew upgrade herdr".into(),
|
|
});
|
|
|
|
assert_eq!(
|
|
state.update_install_command,
|
|
"brew update && brew upgrade herdr"
|
|
);
|
|
let toast = state.toast.as_ref().expect("update toast");
|
|
assert_eq!(
|
|
toast.context,
|
|
"detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_detection_manifest_update_event_updates_status_and_toast() {
|
|
let mut state = AppState::test_new();
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
let status = crate::detect::manifest_update::ManifestUpdateStatus {
|
|
last_result: Some("checked".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated {
|
|
updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
|
|
agent: Agent::Codex,
|
|
version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
|
|
.unwrap(),
|
|
}],
|
|
status,
|
|
});
|
|
|
|
assert!(updates.is_empty());
|
|
assert_eq!(
|
|
state.agent_manifest_update_status.last_result.as_deref(),
|
|
Some("checked")
|
|
);
|
|
let toast = state.toast.as_ref().expect("manifest update toast");
|
|
assert_eq!(toast.kind, ToastKind::UpdateInstalled);
|
|
assert_eq!(toast.title, "Agent detection rules updated");
|
|
assert_eq!(toast.context, "codex 2026.06.10.1");
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_zoom_works() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
state.workspaces[0].test_split(Direction::Horizontal);
|
|
|
|
assert!(!state.workspaces[0].zoomed);
|
|
state.toggle_zoom();
|
|
assert!(state.workspaces[0].zoomed);
|
|
state.toggle_zoom();
|
|
assert!(!state.workspaces[0].zoomed);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_zoom_single_pane_noop() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
state.toggle_zoom();
|
|
assert!(!state.workspaces[0].zoomed);
|
|
}
|
|
|
|
#[test]
|
|
fn navigate_pane_changes_focus_while_zoomed() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.workspaces[0].layout.focus_pane(root);
|
|
state.workspaces[0].zoomed = true;
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
|
|
assert_eq!(state.view.pane_infos.len(), 1);
|
|
assert_eq!(state.view.pane_infos[0].id, root);
|
|
|
|
state.navigate_pane(NavDirection::Right);
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
|
|
assert!(state.workspaces[0].zoomed);
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
|
|
assert_eq!(state.view.pane_infos.len(), 1);
|
|
assert_eq!(state.view.pane_infos[0].id, right);
|
|
assert!(state.view.pane_infos[0].inner_rect.x > state.view.pane_infos[0].rect.x);
|
|
}
|
|
|
|
#[test]
|
|
fn swap_pane_direction_preserves_focus_and_swaps_layout_cells() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.workspaces[0].layout.focus_pane(root);
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
let before_root_rect = state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == root)
|
|
.unwrap()
|
|
.rect;
|
|
let before_right_rect = state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == right)
|
|
.unwrap()
|
|
.rect;
|
|
|
|
assert!(state.swap_pane(NavDirection::Right));
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
|
assert_eq!(
|
|
state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == root)
|
|
.unwrap()
|
|
.rect,
|
|
before_right_rect
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == right)
|
|
.unwrap()
|
|
.rect,
|
|
before_root_rect
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn swap_pane_direction_stays_zoomed_and_mutates_hidden_layout() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let root = state.workspaces[0].tabs[0].root_pane;
|
|
let right = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.workspaces[0].layout.focus_pane(root);
|
|
state.workspaces[0].zoomed = true;
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
|
|
assert!(state.swap_pane(NavDirection::Right));
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
|
|
assert!(state.workspaces[0].zoomed);
|
|
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
|
|
assert_eq!(state.view.pane_infos.len(), 1);
|
|
assert_eq!(state.view.pane_infos[0].id, root);
|
|
|
|
state.workspaces[0].zoomed = false;
|
|
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
|
let root_rect = state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == root)
|
|
.unwrap()
|
|
.rect;
|
|
let right_rect = state
|
|
.view
|
|
.pane_infos
|
|
.iter()
|
|
.find(|info| info.id == right)
|
|
.unwrap()
|
|
.rect;
|
|
|
|
assert!(root_rect.x > right_rect.x);
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_removes_from_workspace() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let closed = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
assert_eq!(state.workspaces[0].panes.len(), 2);
|
|
state.plugin_panes.insert(
|
|
closed,
|
|
crate::app::state::PluginPaneRecord {
|
|
plugin_id: "example.pane".into(),
|
|
entrypoint: "board".into(),
|
|
},
|
|
);
|
|
insert_test_pane_graphics_state(&mut state, closed);
|
|
|
|
state.close_pane();
|
|
assert_eq!(state.workspaces[0].panes.len(), 1);
|
|
assert!(!state.plugin_panes.contains_key(&closed));
|
|
assert!(!state.pane_graphics_layers.contains_key(&closed));
|
|
assert!(!state.pane_graphics_streams.contains_key(&closed));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn pane_process_exit_publish_marks_agent_idle_before_pane_removal() {
|
|
let mut state = app_with_workspaces(&["active", "background"]);
|
|
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
|
state.active = Some(1);
|
|
state.ensure_test_terminals();
|
|
let pane_id = state.workspaces[0].tabs[0].root_pane;
|
|
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
|
|
state
|
|
.terminals
|
|
.get_mut(&terminal_id)
|
|
.unwrap()
|
|
.set_detected_state(Some(Agent::Pi), AgentState::Working);
|
|
assert_eq!(
|
|
state.terminals.get(&terminal_id).unwrap().state,
|
|
AgentState::Working
|
|
);
|
|
|
|
let update = state
|
|
.publish_pane_process_exit_if_agent(pane_id)
|
|
.expect("process exit update");
|
|
|
|
assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id));
|
|
assert_eq!(update.previous_state, AgentState::Working);
|
|
assert_eq!(update.state, AgentState::Idle);
|
|
assert_eq!(update.agent_label.as_deref(), Some("pi"));
|
|
assert_eq!(update.known_agent, Some(Agent::Pi));
|
|
assert!(update.agent_released);
|
|
assert_eq!(
|
|
update.agent_release_status,
|
|
Some(crate::api::schema::AgentStatus::Done)
|
|
);
|
|
assert!(matches!(
|
|
state.toast.as_ref().map(|toast| toast.kind),
|
|
Some(ToastKind::Finished)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_removes_unattached_terminal_state() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let pane_id = state.workspaces[0].test_split(Direction::Horizontal);
|
|
state.ensure_test_terminals();
|
|
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
|
|
|
|
state.close_pane();
|
|
|
|
assert!(!state.terminals.contains_key(&terminal_id));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn close_tab_removes_unattached_terminal_states() {
|
|
let mut state = app_with_workspaces(&["test"]);
|
|
let tab_idx = state.workspaces[0].test_add_tab(Some("logs"));
|
|
state.ensure_test_terminals();
|
|
state.workspaces[0].switch_tab(tab_idx);
|
|
let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
|
|
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
|
|
state.plugin_panes.insert(
|
|
pane_id,
|
|
crate::app::state::PluginPaneRecord {
|
|
plugin_id: "example.pane".into(),
|
|
entrypoint: "board".into(),
|
|
},
|
|
);
|
|
insert_test_pane_graphics_state(&mut state, pane_id);
|
|
|
|
state.close_tab();
|
|
|
|
assert!(!state.terminals.contains_key(&terminal_id));
|
|
assert!(!state.plugin_panes.contains_key(&pane_id));
|
|
assert!(!state.pane_graphics_layers.contains_key(&pane_id));
|
|
assert!(!state.pane_graphics_streams.contains_key(&pane_id));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn close_workspace_removes_unattached_terminal_states() {
|
|
let mut state = app_with_workspaces(&["one", "two"]);
|
|
let pane_id = state.workspaces[0].tabs[0].root_pane;
|
|
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
|
|
state.plugin_panes.insert(
|
|
pane_id,
|
|
crate::app::state::PluginPaneRecord {
|
|
plugin_id: "example.pane".into(),
|
|
entrypoint: "board".into(),
|
|
},
|
|
);
|
|
insert_test_pane_graphics_state(&mut state, pane_id);
|
|
|
|
state.close_selected_workspace();
|
|
|
|
assert!(!state.terminals.contains_key(&terminal_id));
|
|
assert!(!state.plugin_panes.contains_key(&pane_id));
|
|
assert!(!state.pane_graphics_layers.contains_key(&pane_id));
|
|
assert!(!state.pane_graphics_streams.contains_key(&pane_id));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn close_tab_closes_active_workspace_not_selected_workspace() {
|
|
let mut state = app_with_workspaces(&["selected", "active"]);
|
|
let active_terminal_id = state
|
|
.terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane)
|
|
.unwrap();
|
|
state.active = Some(1);
|
|
state.selected = 0;
|
|
|
|
state.close_tab();
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "selected");
|
|
assert!(!state.terminals.contains_key(&active_terminal_id));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_last_pane_closes_active_workspace_not_selected_workspace() {
|
|
let mut state = app_with_workspaces(&["selected", "active"]);
|
|
let active_terminal_id = state
|
|
.terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane)
|
|
.unwrap();
|
|
state.active = Some(1);
|
|
state.selected = 0;
|
|
|
|
state.close_pane();
|
|
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "selected");
|
|
assert!(!state.terminals.contains_key(&active_terminal_id));
|
|
state.assert_invariants_for_test();
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_last_pane_in_parent_worktree_group_prompts() {
|
|
let mut state = app_with_workspaces(&["parent", "child"]);
|
|
mark_parent_worktree(&mut state, 0);
|
|
mark_linked_worktree(&mut state, 1);
|
|
state.active = Some(0);
|
|
state.selected = 1;
|
|
|
|
let deferred = state.close_pane();
|
|
|
|
assert!(deferred);
|
|
assert_eq!(state.mode, Mode::ConfirmClose);
|
|
assert_eq!(state.selected, 0);
|
|
assert_eq!(state.workspaces.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn close_tab_in_linked_worktree_closes_workspace_only() {
|
|
let mut state = app_with_workspaces(&["selected", "active"]);
|
|
mark_linked_worktree(&mut state, 1);
|
|
state.active = Some(1);
|
|
state.selected = 0;
|
|
|
|
state.close_tab();
|
|
|
|
assert_eq!(state.request_remove_linked_worktree, None);
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "selected");
|
|
}
|
|
|
|
#[test]
|
|
fn close_tab_last_tab_in_parent_worktree_group_prompts() {
|
|
let mut state = app_with_workspaces(&["parent", "child"]);
|
|
mark_parent_worktree(&mut state, 0);
|
|
mark_linked_worktree(&mut state, 1);
|
|
state.active = Some(0);
|
|
state.selected = 1;
|
|
|
|
let deferred = state.close_tab();
|
|
|
|
assert!(deferred);
|
|
assert_eq!(state.mode, Mode::ConfirmClose);
|
|
assert_eq!(state.selected, 0);
|
|
assert_eq!(state.workspaces.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_last_pane_in_linked_worktree_closes_workspace_only() {
|
|
let mut state = app_with_workspaces(&["selected", "active"]);
|
|
mark_linked_worktree(&mut state, 1);
|
|
state.active = Some(1);
|
|
state.selected = 0;
|
|
|
|
state.close_pane();
|
|
|
|
assert_eq!(state.request_remove_linked_worktree, None);
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "selected");
|
|
}
|
|
|
|
#[test]
|
|
fn close_pane_last_pane_in_parent_worktree_group_closes_when_confirmation_disabled() {
|
|
let mut state = app_with_workspaces(&["parent", "child", "notes"]);
|
|
mark_parent_worktree(&mut state, 0);
|
|
mark_linked_worktree(&mut state, 1);
|
|
state.confirm_close = false;
|
|
state.active = Some(0);
|
|
state.selected = 0;
|
|
|
|
let deferred = state.close_pane();
|
|
|
|
assert!(!deferred);
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].display_name(), "notes");
|
|
}
|
|
}
|