feat: add tabs within workspaces
This commit is contained in:
parent
28fb23cdaf
commit
b695adee60
|
|
@ -3,10 +3,9 @@
|
|||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::detect::{Agent, AgentState};
|
||||
use crate::detect::AgentState;
|
||||
use crate::events::AppEvent;
|
||||
use crate::layout::{find_in_direction, NavDirection, PaneId};
|
||||
use crate::pane::EffectiveStateChange;
|
||||
|
||||
use super::state::{AppState, Mode, ToastKind, ToastNotification};
|
||||
|
||||
|
|
@ -44,32 +43,22 @@ fn notification_toast_for_state_change(
|
|||
}
|
||||
}
|
||||
|
||||
fn agent_label(agent: Agent) -> &'static str {
|
||||
fn agent_label(agent: crate::detect::Agent) -> &'static str {
|
||||
match agent {
|
||||
Agent::Pi => "pi",
|
||||
Agent::Claude => "claude",
|
||||
Agent::Codex => "codex",
|
||||
Agent::Gemini => "gemini",
|
||||
Agent::Cursor => "cursor",
|
||||
Agent::Cline => "cline",
|
||||
Agent::OpenCode => "opencode",
|
||||
Agent::GithubCopilot => "copilot",
|
||||
Agent::Kimi => "kimi",
|
||||
Agent::Droid => "droid",
|
||||
Agent::Amp => "amp",
|
||||
crate::detect::Agent::Pi => "pi",
|
||||
crate::detect::Agent::Claude => "claude",
|
||||
crate::detect::Agent::Codex => "codex",
|
||||
crate::detect::Agent::Gemini => "gemini",
|
||||
crate::detect::Agent::Cursor => "cursor",
|
||||
crate::detect::Agent::Cline => "cline",
|
||||
crate::detect::Agent::OpenCode => "opencode",
|
||||
crate::detect::Agent::GithubCopilot => "copilot",
|
||||
crate::detect::Agent::Kimi => "kimi",
|
||||
crate::detect::Agent::Droid => "droid",
|
||||
crate::detect::Agent::Amp => "amp",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PaneStateUpdate {
|
||||
pub pane_id: PaneId,
|
||||
pub ws_idx: usize,
|
||||
pub previous_agent: Option<Agent>,
|
||||
pub previous_state: AgentState,
|
||||
pub agent: Option<Agent>,
|
||||
pub state: AgentState,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -79,8 +68,56 @@ impl AppState {
|
|||
if idx < self.workspaces.len() {
|
||||
self.active = Some(idx);
|
||||
self.selected = idx;
|
||||
for pane in self.workspaces[idx].panes.values_mut() {
|
||||
pane.seen = true;
|
||||
if let Some(ws) = self.workspaces.get_mut(idx) {
|
||||
ws.switch_tab(ws.active_tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.switch_tab(idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_workspace(&mut self) {
|
||||
if !self.workspaces.is_empty() {
|
||||
let current = self.active.unwrap_or(self.selected);
|
||||
let next = (current + 1) % self.workspaces.len();
|
||||
self.switch_workspace(next);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_workspace(&mut self) {
|
||||
if !self.workspaces.is_empty() {
|
||||
let current = self.active.unwrap_or(self.selected);
|
||||
let prev = if current == 0 {
|
||||
self.workspaces.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
self.switch_workspace(prev);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if !ws.tabs.is_empty() {
|
||||
let next = (ws.active_tab + 1) % ws.tabs.len();
|
||||
ws.switch_tab(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_tab(&mut self) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if !ws.tabs.is_empty() {
|
||||
let prev = if ws.active_tab == 0 {
|
||||
ws.tabs.len() - 1
|
||||
} else {
|
||||
ws.active_tab - 1
|
||||
};
|
||||
ws.switch_tab(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,8 +150,12 @@ impl AppState {
|
|||
let panes = &self.view.pane_infos;
|
||||
if let Some(focused) = panes.iter().find(|p| p.is_focused) {
|
||||
if let Some(target) = find_in_direction(focused, direction, panes) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.layout.focus_pane(target);
|
||||
if let Some(tab) = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
tab.layout.focus_pane(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,33 +168,63 @@ impl AppState {
|
|||
.pane_infos
|
||||
.iter()
|
||||
.fold(first.rect, |acc, p| acc.union(p.rect));
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.layout.resize_focused(direction, 0.05, area);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_pane(&mut self, reverse: bool) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if let Some(tab) = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
if reverse {
|
||||
ws.layout.focus_prev();
|
||||
tab.layout.focus_prev();
|
||||
} else {
|
||||
ws.layout.focus_next();
|
||||
tab.layout.focus_next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_fullscreen(&mut self) {
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.pane_count() > 1 {
|
||||
ws.zoomed = !ws.zoomed;
|
||||
if let Some(tab) = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
.and_then(|ws| ws.active_tab_mut())
|
||||
{
|
||||
if tab.layout.pane_count() > 1 {
|
||||
tab.zoomed = !tab.zoomed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_pane(&mut self) {
|
||||
let should_close_workspace = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get_mut(i))
|
||||
.is_some_and(|ws| ws.close_focused());
|
||||
if should_close_workspace {
|
||||
self.close_selected_workspace();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_tab(&mut self) {
|
||||
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 {
|
||||
self.close_selected_workspace();
|
||||
return;
|
||||
}
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
ws.close_focused();
|
||||
ws.close_active_tab();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,7 +255,7 @@ impl AppState {
|
|||
None => return,
|
||||
};
|
||||
|
||||
let rt = match ws.runtimes.get(&sel.pane_id) {
|
||||
let rt = match ws.runtime(sel.pane_id) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
|
|
@ -204,12 +275,9 @@ impl AppState {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl AppState {
|
||||
pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> {
|
||||
pub fn handle_app_event(&mut self, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::PaneDied { pane_id } => {
|
||||
self.handle_pane_died(pane_id);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id),
|
||||
AppEvent::UpdateReady { version } => {
|
||||
self.update_available = Some(version.clone());
|
||||
self.update_dismissed = true;
|
||||
|
|
@ -218,115 +286,65 @@ impl AppState {
|
|||
title: format!("updated to v{version}"),
|
||||
context: "restart to use it".to_string(),
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent,
|
||||
state,
|
||||
} => self
|
||||
.update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source,
|
||||
agent,
|
||||
state,
|
||||
message,
|
||||
} => self
|
||||
.update_pane_state(pane_id, |pane| {
|
||||
pane.set_hook_authority(source, agent, state, message)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
AppEvent::HookAuthorityCleared { pane_id, source } => self
|
||||
.update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref()))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
AppEvent::HookAgentReleased {
|
||||
pane_id,
|
||||
source,
|
||||
agent,
|
||||
} => self
|
||||
.update_pane_state(pane_id, |pane| pane.release_agent(&source, agent))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
} => {
|
||||
for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() {
|
||||
let workspace_name = ws.display_name();
|
||||
let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(pane) = ws.tabs[tab_idx].panes.get_mut(&pane_id) {
|
||||
let is_active_ws = self.active == Some(ws_idx) && ws.active_tab == tab_idx;
|
||||
let prev_state = pane.state;
|
||||
|
||||
fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
|
||||
where
|
||||
F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>,
|
||||
{
|
||||
let ws_idx = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.position(|ws| ws.panes.contains_key(&pane_id))?;
|
||||
let workspace_name = self.workspaces[ws_idx].display_name();
|
||||
let change = {
|
||||
let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?;
|
||||
update(pane)?
|
||||
};
|
||||
self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change);
|
||||
Some(PaneStateUpdate {
|
||||
pane_id,
|
||||
ws_idx,
|
||||
previous_agent: change.previous_agent,
|
||||
previous_state: change.previous_state,
|
||||
agent: change.agent,
|
||||
state: change.state,
|
||||
})
|
||||
}
|
||||
// Mark unseen when transitioning to Idle in background
|
||||
if state == AgentState::Idle
|
||||
&& prev_state != AgentState::Idle
|
||||
&& !is_active_ws
|
||||
{
|
||||
pane.seen = false;
|
||||
}
|
||||
|
||||
fn apply_pane_state_change(
|
||||
&mut self,
|
||||
ws_idx: usize,
|
||||
pane_id: PaneId,
|
||||
workspace_name: &str,
|
||||
change: EffectiveStateChange,
|
||||
) {
|
||||
let is_active_ws = self.active == Some(ws_idx);
|
||||
let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else {
|
||||
return;
|
||||
};
|
||||
// Blocked prompts should always make noise; done sounds stay background-only.
|
||||
if self.sound.allows(agent) {
|
||||
if let Some(sound) =
|
||||
notification_sound_for_state_change(is_active_ws, prev_state, state)
|
||||
{
|
||||
crate::sound::play(sound);
|
||||
}
|
||||
}
|
||||
|
||||
if change.state == AgentState::Idle
|
||||
&& change.previous_state != AgentState::Idle
|
||||
&& !is_active_ws
|
||||
{
|
||||
pane.seen = false;
|
||||
}
|
||||
if self.toast_config.enabled {
|
||||
if let (Some(agent), Some(kind)) = (
|
||||
agent,
|
||||
notification_toast_for_state_change(
|
||||
is_active_ws,
|
||||
prev_state,
|
||||
state,
|
||||
),
|
||||
) {
|
||||
let event_text = match kind {
|
||||
ToastKind::NeedsAttention => "needs attention",
|
||||
ToastKind::Finished => "finished",
|
||||
ToastKind::UpdateInstalled => "updated",
|
||||
};
|
||||
self.toast = Some(ToastNotification {
|
||||
kind,
|
||||
title: format!("{} {}", agent_label(agent), event_text),
|
||||
context: format!("{} · {}", workspace_name, ws_idx + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if self.sound.allows(change.agent) {
|
||||
if let Some(sound) = notification_sound_for_state_change(
|
||||
is_active_ws,
|
||||
change.previous_state,
|
||||
change.state,
|
||||
) {
|
||||
crate::sound::play(sound);
|
||||
}
|
||||
}
|
||||
|
||||
if self.toast_config.enabled {
|
||||
if let (Some(agent), Some(kind)) = (
|
||||
change.agent,
|
||||
notification_toast_for_state_change(
|
||||
is_active_ws,
|
||||
change.previous_state,
|
||||
change.state,
|
||||
),
|
||||
) {
|
||||
let event_text = match kind {
|
||||
ToastKind::NeedsAttention => "needs attention",
|
||||
ToastKind::Finished => "finished",
|
||||
ToastKind::UpdateInstalled => "updated",
|
||||
};
|
||||
self.toast = Some(ToastNotification {
|
||||
kind,
|
||||
title: format!("{} {}", agent_label(agent), event_text),
|
||||
context: format!("{} · {}", workspace_name, ws_idx + 1),
|
||||
});
|
||||
pane.detected_agent = agent;
|
||||
pane.state = state;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -335,16 +353,19 @@ impl AppState {
|
|||
let ws_idx = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.position(|ws| ws.panes.contains_key(&pane_id));
|
||||
.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;
|
||||
};
|
||||
|
||||
let ws = &mut self.workspaces[ws_idx];
|
||||
let should_close_workspace = {
|
||||
let ws = &mut self.workspaces[ws_idx];
|
||||
ws.remove_pane(pane_id)
|
||||
};
|
||||
|
||||
if ws.layout.pane_count() <= 1 {
|
||||
if should_close_workspace {
|
||||
self.workspaces.remove(ws_idx);
|
||||
if self.workspaces.is_empty() {
|
||||
self.active = None;
|
||||
|
|
@ -362,8 +383,6 @@ impl AppState {
|
|||
self.selected = self.workspaces.len() - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws.remove_pane(pane_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -540,94 +559,6 @@ mod tests {
|
|||
assert!(!pane.seen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_authority_ignores_fallback_state_changes() {
|
||||
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::Idle,
|
||||
});
|
||||
state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent: Agent::Pi,
|
||||
state: AgentState::Working,
|
||||
message: None,
|
||||
});
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent: Some(Agent::Pi),
|
||||
state: AgentState::Idle,
|
||||
});
|
||||
|
||||
let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
|
||||
assert_eq!(pane.fallback_state, AgentState::Idle);
|
||||
assert_eq!(pane.state, AgentState::Working);
|
||||
assert!(pane.hook_authority.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_authority_clears_when_agent_disappears() {
|
||||
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::Idle,
|
||||
});
|
||||
state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent: Agent::Pi,
|
||||
state: AgentState::Working,
|
||||
message: None,
|
||||
});
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent: None,
|
||||
state: AgentState::Unknown,
|
||||
});
|
||||
|
||||
let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
|
||||
assert!(pane.hook_authority.is_none());
|
||||
assert_eq!(pane.detected_agent, None);
|
||||
assert_eq!(pane.state, AgentState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_agent_release_clears_identity_immediately() {
|
||||
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::Idle,
|
||||
});
|
||||
state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent: Agent::Pi,
|
||||
state: AgentState::Working,
|
||||
message: None,
|
||||
});
|
||||
state.handle_app_event(AppEvent::HookAgentReleased {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent: Agent::Pi,
|
||||
});
|
||||
|
||||
let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
|
||||
assert!(pane.hook_authority.is_none());
|
||||
assert_eq!(pane.detected_agent, None);
|
||||
assert_eq!(pane.fallback_state, AgentState::Unknown);
|
||||
assert_eq!(pane.state, AgentState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_sound_plays_even_in_active_workspace() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
258
src/app/input.rs
258
src/app/input.rs
|
|
@ -32,6 +32,35 @@ use super::App;
|
|||
// Key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
|
||||
let kb = &state.keybinds;
|
||||
if kb
|
||||
.previous_workspace
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::PreviousWorkspace);
|
||||
}
|
||||
if kb
|
||||
.next_workspace
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::NextWorkspace);
|
||||
}
|
||||
if kb
|
||||
.previous_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::PreviousTab);
|
||||
}
|
||||
if kb
|
||||
.next_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::NextTab);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
|
||||
match self.state.mode {
|
||||
|
|
@ -42,7 +71,9 @@ impl App {
|
|||
Mode::Onboarding => self.handle_onboarding_key(key),
|
||||
Mode::ReleaseNotes => self.handle_release_notes_key(key),
|
||||
Mode::Navigate => handle_navigate_key(&mut self.state, key),
|
||||
Mode::RenameSession => handle_rename_key(&mut self.state, key),
|
||||
Mode::RenameWorkspace | Mode::RenameTab => {
|
||||
handle_rename_key(&mut self.state, key)
|
||||
}
|
||||
Mode::Resize => handle_resize_key(&mut self.state, key),
|
||||
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
|
||||
Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
|
||||
|
|
@ -223,6 +254,11 @@ impl App {
|
|||
|
||||
let key_event = key.as_key_event();
|
||||
|
||||
if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) {
|
||||
execute_navigate_action(&mut self.state, action);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state.is_prefix(&key_event) {
|
||||
self.state.mode = Mode::Navigate;
|
||||
return;
|
||||
|
|
@ -446,6 +482,13 @@ enum NavigateAction {
|
|||
NewWorkspace,
|
||||
RenameWorkspace,
|
||||
CloseWorkspace,
|
||||
PreviousWorkspace,
|
||||
NextWorkspace,
|
||||
NewTab,
|
||||
RenameTab,
|
||||
PreviousTab,
|
||||
NextTab,
|
||||
CloseTab,
|
||||
SplitVertical,
|
||||
SplitHorizontal,
|
||||
ClosePane,
|
||||
|
|
@ -465,6 +508,45 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
|
|||
if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
|
||||
return Some(NavigateAction::CloseWorkspace);
|
||||
}
|
||||
if kb
|
||||
.previous_workspace
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::PreviousWorkspace);
|
||||
}
|
||||
if kb
|
||||
.next_workspace
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::NextWorkspace);
|
||||
}
|
||||
if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
|
||||
return Some(NavigateAction::NewTab);
|
||||
}
|
||||
if kb
|
||||
.rename_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::RenameTab);
|
||||
}
|
||||
if kb
|
||||
.previous_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::PreviousTab);
|
||||
}
|
||||
if kb
|
||||
.next_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::NextTab);
|
||||
}
|
||||
if kb
|
||||
.close_tab
|
||||
.is_some_and(|(code, mods)| key_matches(key, code, mods))
|
||||
{
|
||||
return Some(NavigateAction::CloseTab);
|
||||
}
|
||||
if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
|
||||
return Some(NavigateAction::SplitVertical);
|
||||
}
|
||||
|
|
@ -495,7 +577,7 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
|
|||
NavigateAction::RenameWorkspace => {
|
||||
if !state.workspaces.is_empty() {
|
||||
state.name_input = state.workspaces[state.selected].display_name();
|
||||
state.mode = Mode::RenameSession;
|
||||
state.mode = Mode::RenameWorkspace;
|
||||
}
|
||||
}
|
||||
NavigateAction::CloseWorkspace => {
|
||||
|
|
@ -508,6 +590,38 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
|
|||
}
|
||||
}
|
||||
}
|
||||
NavigateAction::PreviousWorkspace => {
|
||||
state.previous_workspace();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::NextWorkspace => {
|
||||
state.next_workspace();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::NewTab => {
|
||||
state.request_new_tab = true;
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::RenameTab => {
|
||||
if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) {
|
||||
if let Some(name) = ws.active_tab_display_name() {
|
||||
state.name_input = name;
|
||||
state.mode = Mode::RenameTab;
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigateAction::PreviousTab => {
|
||||
state.previous_tab();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::NextTab => {
|
||||
state.next_tab();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::CloseTab => {
|
||||
state.close_tab();
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
NavigateAction::SplitVertical => {
|
||||
state.split_pane(Direction::Horizontal);
|
||||
leave_navigate_mode(state);
|
||||
|
|
@ -567,8 +681,20 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
|
|||
} else {
|
||||
state.name_input.trim().to_string()
|
||||
};
|
||||
if !new_name.is_empty() && !state.workspaces.is_empty() {
|
||||
state.workspaces[state.selected].set_custom_name(new_name);
|
||||
if !new_name.is_empty() {
|
||||
match state.mode {
|
||||
Mode::RenameWorkspace if !state.workspaces.is_empty() => {
|
||||
state.workspaces[state.selected].set_custom_name(new_name);
|
||||
}
|
||||
Mode::RenameTab => {
|
||||
if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
|
||||
if let Some(tab) = ws.active_tab_mut() {
|
||||
tab.set_custom_name(new_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
state.name_input.clear();
|
||||
state.mode = Mode::Navigate;
|
||||
|
|
@ -577,6 +703,9 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
|
|||
state.name_input.clear();
|
||||
state.mode = Mode::Navigate;
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers == crossterm::event::KeyModifiers::CONTROL => {
|
||||
state.name_input.clear();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
state.name_input.pop();
|
||||
}
|
||||
|
|
@ -653,7 +782,7 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx:
|
|||
(ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
|
||||
state.selected = ws_idx;
|
||||
state.name_input = state.workspaces[ws_idx].display_name();
|
||||
state.mode = Mode::RenameSession;
|
||||
state.mode = Mode::RenameWorkspace;
|
||||
}
|
||||
(ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
|
||||
state.selected = ws_idx;
|
||||
|
|
@ -664,6 +793,35 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx:
|
|||
state.mode = Mode::Navigate;
|
||||
}
|
||||
}
|
||||
(ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
|
||||
state.selected = ws_idx;
|
||||
state.active = Some(ws_idx);
|
||||
state.switch_tab(tab_idx);
|
||||
state.request_new_tab = true;
|
||||
state.mode = Mode::Terminal;
|
||||
}
|
||||
(ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
|
||||
state.selected = ws_idx;
|
||||
state.active = Some(ws_idx);
|
||||
state.switch_tab(tab_idx);
|
||||
if let Some(ws) = state.workspaces.get(ws_idx) {
|
||||
if let Some(name) = ws.active_tab_display_name() {
|
||||
state.name_input = name;
|
||||
state.mode = Mode::RenameTab;
|
||||
}
|
||||
}
|
||||
}
|
||||
(ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
|
||||
state.selected = ws_idx;
|
||||
state.active = Some(ws_idx);
|
||||
state.switch_tab(tab_idx);
|
||||
state.close_tab();
|
||||
state.mode = if state.active.is_some() {
|
||||
Mode::Terminal
|
||||
} else {
|
||||
Mode::Navigate
|
||||
};
|
||||
}
|
||||
(ContextMenuKind::Pane, Some("Split vertical")) => {
|
||||
state.split_pane(Direction::Horizontal);
|
||||
state.mode = Mode::Terminal;
|
||||
|
|
@ -760,6 +918,30 @@ impl AppState {
|
|||
&& row < button.y + button.height
|
||||
}
|
||||
|
||||
fn rename_modal_inner(&self) -> Option<Rect> {
|
||||
self.onboarding_modal_inner(56, 7)
|
||||
}
|
||||
|
||||
fn rename_button_at(&self, col: u16, row: u16) -> Option<&'static str> {
|
||||
let inner = self.rename_modal_inner()?;
|
||||
if inner.height < 4 || inner.width < 28 {
|
||||
return None;
|
||||
}
|
||||
let (save, clear, cancel) = crate::ui::rename_button_rects(inner);
|
||||
if row != save.y {
|
||||
return None;
|
||||
}
|
||||
if col >= save.x && col < save.x + save.width {
|
||||
Some("save")
|
||||
} else if col >= clear.x && col < clear.x + clear.width {
|
||||
Some("clear")
|
||||
} else if col >= cancel.x && col < cancel.x + cancel.width {
|
||||
Some("cancel")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn release_notes_body_rect(&self) -> Option<Rect> {
|
||||
let inner = self.release_notes_modal_inner()?;
|
||||
if inner.height < 8 || inner.width < 4 {
|
||||
|
|
@ -1082,6 +1264,19 @@ impl AppState {
|
|||
return None;
|
||||
}
|
||||
|
||||
if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
|
||||
match self.rename_button_at(mouse.column, mouse.row) {
|
||||
Some("save") => handle_rename_key(self, KeyEvent::from(KeyCode::Enter)),
|
||||
Some("clear") => self.name_input.clear(),
|
||||
Some("cancel") => handle_rename_key(self, KeyEvent::from(KeyCode::Esc)),
|
||||
None => {
|
||||
handle_rename_key(self, KeyEvent::from(KeyCode::Esc));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.mode == Mode::ContextMenu {
|
||||
let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
|
||||
if let Some(menu) = self.context_menu.take() {
|
||||
|
|
@ -1139,6 +1334,17 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) {
|
||||
self.switch_tab(tab_idx);
|
||||
self.mode = Mode::Terminal;
|
||||
return None;
|
||||
}
|
||||
if self.on_new_tab_button(mouse.column, mouse.row) {
|
||||
self.request_new_tab = true;
|
||||
self.mode = Mode::Terminal;
|
||||
return None;
|
||||
}
|
||||
|
||||
if in_sidebar {
|
||||
if self.sidebar_collapsed {
|
||||
let idx = (mouse.row - sidebar.y) as usize;
|
||||
|
|
@ -1300,6 +1506,23 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
MouseEventKind::Down(MouseButton::Right)
|
||||
if self.tab_at(mouse.column, mouse.row).is_some() =>
|
||||
{
|
||||
if let (Some(ws_idx), Some(tab_idx)) =
|
||||
(self.active, self.tab_at(mouse.column, mouse.row))
|
||||
{
|
||||
self.switch_tab(tab_idx);
|
||||
self.context_menu = Some(ContextMenuState {
|
||||
kind: ContextMenuKind::Tab { ws_idx, tab_idx },
|
||||
x: mouse.column,
|
||||
y: mouse.row,
|
||||
selected: 0,
|
||||
});
|
||||
self.mode = Mode::ContextMenu;
|
||||
}
|
||||
}
|
||||
|
||||
MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
|
||||
if self.pane_at(mouse.column, mouse.row).is_some() {
|
||||
self.context_menu = Some(ContextMenuState {
|
||||
|
|
@ -1337,6 +1560,29 @@ impl AppState {
|
|||
}
|
||||
|
||||
/// Find which workspace index a sidebar row belongs to (two-section layout).
|
||||
fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
|
||||
self.view
|
||||
.tab_hit_areas
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(idx, area)| {
|
||||
(row >= area.y
|
||||
&& row < area.y + area.height
|
||||
&& col >= area.x
|
||||
&& col < area.x + area.width)
|
||||
.then_some(idx)
|
||||
})
|
||||
}
|
||||
|
||||
fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
|
||||
let area = self.view.new_tab_hit_area;
|
||||
area.width > 0
|
||||
&& row >= area.y
|
||||
&& row < area.y + area.height
|
||||
&& col >= area.x
|
||||
&& col < area.x + area.width
|
||||
}
|
||||
|
||||
fn workspace_at_row(&self, row: u16) -> Option<usize> {
|
||||
let sidebar = self.view.sidebar_rect;
|
||||
let total_h = sidebar.height as usize;
|
||||
|
|
@ -1744,7 +1990,7 @@ mod tests {
|
|||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
|
||||
);
|
||||
|
||||
assert_eq!(state.mode, Mode::RenameSession);
|
||||
assert_eq!(state.mode, Mode::RenameWorkspace);
|
||||
assert_eq!(state.name_input, "test");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ impl App {
|
|||
mode,
|
||||
should_quit: false,
|
||||
request_new_workspace: false,
|
||||
request_new_tab: false,
|
||||
request_complete_onboarding: false,
|
||||
name_input: String::new(),
|
||||
onboarding_step: 0,
|
||||
|
|
@ -198,6 +199,9 @@ impl App {
|
|||
}),
|
||||
view: state::ViewState {
|
||||
sidebar_rect: Rect::default(),
|
||||
tab_bar_rect: Rect::default(),
|
||||
tab_hit_areas: Vec::new(),
|
||||
new_tab_hit_area: Rect::default(),
|
||||
terminal_area: Rect::default(),
|
||||
pane_infos: Vec::new(),
|
||||
split_borders: Vec::new(),
|
||||
|
|
@ -250,7 +254,7 @@ impl App {
|
|||
state
|
||||
.workspaces
|
||||
.get(idx)
|
||||
.map(|ws| (idx, ws.layout.focused()))
|
||||
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
|
||||
});
|
||||
|
||||
Self {
|
||||
|
|
@ -319,6 +323,12 @@ impl App {
|
|||
needs_render = true;
|
||||
}
|
||||
|
||||
if self.state.request_new_tab {
|
||||
self.state.request_new_tab = false;
|
||||
self.create_tab();
|
||||
needs_render = true;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
self.sync_animation_timer(now);
|
||||
|
||||
|
|
@ -682,7 +692,7 @@ impl App {
|
|||
self.state
|
||||
.workspaces
|
||||
.get(idx)
|
||||
.map(|ws| (idx, ws.layout.focused()))
|
||||
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
|
||||
});
|
||||
if current_focus == self.last_focus {
|
||||
return;
|
||||
|
|
@ -717,7 +727,7 @@ impl App {
|
|||
.workspaces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(ws_idx, ws)| ws.panes.get(&pane_id).map(|pane| (ws_idx, pane)))
|
||||
.find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane)))
|
||||
}
|
||||
|
||||
fn public_workspace_id(&self, ws_idx: usize) -> String {
|
||||
|
|
@ -1451,6 +1461,38 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn create_tab(&mut self) {
|
||||
let initial_cwd = self
|
||||
.state
|
||||
.active
|
||||
.and_then(|i| self.state.workspaces.get(i))
|
||||
.and_then(|ws| ws.focused_runtime())
|
||||
.and_then(|rt| rt.cwd())
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/"));
|
||||
if let Err(e) = self.create_tab_with_options(initial_cwd, true) {
|
||||
error!(err = %e, "failed to create tab");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tab_with_options(
|
||||
&mut self,
|
||||
initial_cwd: std::path::PathBuf,
|
||||
focus: bool,
|
||||
) -> std::io::Result<usize> {
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return self.create_workspace_with_options(initial_cwd, focus);
|
||||
};
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let ws = &mut self.state.workspaces[ws_idx];
|
||||
let idx = ws.create_tab(rows, cols, initial_cwd)?;
|
||||
if focus {
|
||||
ws.switch_tab(idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
fn create_workspace_with_options(
|
||||
&mut self,
|
||||
initial_cwd: std::path::PathBuf,
|
||||
|
|
@ -1493,9 +1535,9 @@ impl App {
|
|||
));
|
||||
};
|
||||
Ok(ws
|
||||
.layout
|
||||
.pane_ids()
|
||||
.into_iter()
|
||||
.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.layout.pane_ids().into_iter())
|
||||
.filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
|
||||
.collect())
|
||||
} else {
|
||||
|
|
@ -1505,9 +1547,9 @@ impl App {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ws_idx, ws)| {
|
||||
ws.layout
|
||||
.pane_ids()
|
||||
.into_iter()
|
||||
ws.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.layout.pane_ids().into_iter())
|
||||
.filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
|
||||
})
|
||||
.collect())
|
||||
|
|
@ -1520,12 +1562,16 @@ impl App {
|
|||
pane_id: crate::layout::PaneId,
|
||||
) -> Option<crate::api::schema::PaneInfo> {
|
||||
let ws = self.state.workspaces.get(ws_idx)?;
|
||||
let pane = ws.panes.get(&pane_id)?;
|
||||
let runtime = ws.runtimes.get(&pane_id);
|
||||
let pane = ws.pane_state(pane_id)?;
|
||||
let runtime = ws.runtime(pane_id);
|
||||
let focused = self.state.active == Some(ws_idx)
|
||||
&& ws
|
||||
.focused_pane_id()
|
||||
.is_some_and(|focused| focused == pane_id);
|
||||
Some(crate::api::schema::PaneInfo {
|
||||
pane_id: self.public_pane_id(ws_idx, pane_id)?,
|
||||
workspace_id: self.public_workspace_id(ws_idx),
|
||||
focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id,
|
||||
focused,
|
||||
cwd: runtime
|
||||
.and_then(|rt| rt.cwd())
|
||||
.map(|cwd| cwd.display().to_string()),
|
||||
|
|
@ -1541,7 +1587,7 @@ impl App {
|
|||
pane_id: crate::layout::PaneId,
|
||||
) -> Option<(&crate::pane::PaneRuntime, String)> {
|
||||
let ws = self.state.workspaces.get(ws_idx)?;
|
||||
let runtime = ws.runtimes.get(&pane_id)?;
|
||||
let runtime = ws.runtime(pane_id)?;
|
||||
Some((runtime, self.public_workspace_id(ws_idx)))
|
||||
}
|
||||
|
||||
|
|
@ -1554,7 +1600,7 @@ impl App {
|
|||
&crate::pane::PaneRuntime,
|
||||
)> {
|
||||
let ws = self.state.workspaces.get(ws_idx)?;
|
||||
let runtime = ws.runtimes.get(&pane_id)?;
|
||||
let runtime = ws.runtime(pane_id)?;
|
||||
Some((&runtime.sender, runtime))
|
||||
}
|
||||
|
||||
|
|
@ -1566,7 +1612,7 @@ impl App {
|
|||
number: index + 1,
|
||||
label: ws.display_name(),
|
||||
focused: self.state.active == Some(index),
|
||||
pane_count: ws.panes.len(),
|
||||
pane_count: ws.public_pane_numbers.len(),
|
||||
agent_state: pane_agent_state(agg_state),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,9 @@ impl Palette {
|
|||
/// Updated before each render, consumed by render and mouse handling.
|
||||
pub struct ViewState {
|
||||
pub sidebar_rect: Rect,
|
||||
pub tab_bar_rect: Rect,
|
||||
pub tab_hit_areas: Vec<Rect>,
|
||||
pub new_tab_hit_area: Rect,
|
||||
pub terminal_area: Rect,
|
||||
pub pane_infos: Vec<PaneInfo>,
|
||||
pub split_borders: Vec<SplitBorder>,
|
||||
|
|
@ -335,7 +338,8 @@ pub enum Mode {
|
|||
ReleaseNotes,
|
||||
Navigate,
|
||||
Terminal,
|
||||
RenameSession,
|
||||
RenameWorkspace,
|
||||
RenameTab,
|
||||
Resize,
|
||||
ConfirmClose,
|
||||
ContextMenu,
|
||||
|
|
@ -414,6 +418,7 @@ pub(crate) struct DragState {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextMenuKind {
|
||||
Workspace { ws_idx: usize },
|
||||
Tab { ws_idx: usize, tab_idx: usize },
|
||||
Pane,
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +434,7 @@ impl ContextMenuState {
|
|||
pub fn items(&self) -> &'static [&'static str] {
|
||||
match self.kind {
|
||||
ContextMenuKind::Workspace { .. } => &["Rename", "Close"],
|
||||
ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"],
|
||||
ContextMenuKind::Pane => &[
|
||||
"Split vertical",
|
||||
"Split horizontal",
|
||||
|
|
@ -469,6 +475,7 @@ pub struct AppState {
|
|||
pub mode: Mode,
|
||||
pub should_quit: bool,
|
||||
pub request_new_workspace: bool,
|
||||
pub request_new_tab: bool,
|
||||
pub request_complete_onboarding: bool,
|
||||
pub name_input: String,
|
||||
pub onboarding_step: usize,
|
||||
|
|
@ -559,6 +566,7 @@ impl AppState {
|
|||
mode: Mode::Navigate,
|
||||
should_quit: false,
|
||||
request_new_workspace: false,
|
||||
request_new_tab: false,
|
||||
request_complete_onboarding: false,
|
||||
name_input: String::new(),
|
||||
onboarding_step: 0,
|
||||
|
|
@ -566,6 +574,9 @@ impl AppState {
|
|||
release_notes: None,
|
||||
view: ViewState {
|
||||
sidebar_rect: Rect::default(),
|
||||
tab_bar_rect: Rect::default(),
|
||||
tab_hit_areas: Vec::new(),
|
||||
new_tab_hit_area: Rect::default(),
|
||||
terminal_area: Rect::default(),
|
||||
pane_infos: Vec::new(),
|
||||
split_borders: Vec::new(),
|
||||
|
|
@ -597,6 +608,14 @@ impl AppState {
|
|||
rename_workspace_label: "shift+n".into(),
|
||||
close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()),
|
||||
close_workspace_label: "d".into(),
|
||||
previous_workspace: None,
|
||||
next_workspace: None,
|
||||
new_tab: (KeyCode::Char('c'), KeyModifiers::empty()),
|
||||
new_tab_label: "c".into(),
|
||||
rename_tab: None,
|
||||
previous_tab: None,
|
||||
next_tab: None,
|
||||
close_tab: None,
|
||||
split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()),
|
||||
split_vertical_label: "v".into(),
|
||||
split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()),
|
||||
|
|
|
|||
115
src/config.rs
115
src/config.rs
|
|
@ -2,6 +2,8 @@ use std::path::PathBuf;
|
|||
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
|
||||
use tracing::warn;
|
||||
|
||||
use crate::detect::Agent;
|
||||
|
|
@ -80,6 +82,20 @@ pub struct KeysConfig {
|
|||
pub rename_workspace: String,
|
||||
/// Close the selected workspace. Default: "d"
|
||||
pub close_workspace: String,
|
||||
/// Select the previous workspace. Unset by default.
|
||||
pub previous_workspace: String,
|
||||
/// Select the next workspace. Unset by default.
|
||||
pub next_workspace: String,
|
||||
/// Create a new tab in the active workspace. Default: "c"
|
||||
pub new_tab: String,
|
||||
/// Rename the active tab. Unset by default.
|
||||
pub rename_tab: String,
|
||||
/// Select the previous tab. Unset by default.
|
||||
pub previous_tab: String,
|
||||
/// Select the next tab. Unset by default.
|
||||
pub next_tab: String,
|
||||
/// Close the active tab. Unset by default.
|
||||
pub close_tab: String,
|
||||
/// Split pane vertically (side by side). Default: "v"
|
||||
pub split_vertical: String,
|
||||
/// Split pane horizontally (stacked). Default: "-"
|
||||
|
|
@ -184,6 +200,13 @@ impl Default for KeysConfig {
|
|||
new_workspace: "n".into(),
|
||||
rename_workspace: "shift+n".into(),
|
||||
close_workspace: "d".into(),
|
||||
previous_workspace: "".into(),
|
||||
next_workspace: "".into(),
|
||||
new_tab: "c".into(),
|
||||
rename_tab: "".into(),
|
||||
previous_tab: "".into(),
|
||||
next_tab: "".into(),
|
||||
close_tab: "".into(),
|
||||
split_vertical: "v".into(),
|
||||
split_horizontal: "-".into(),
|
||||
close_pane: "x".into(),
|
||||
|
|
@ -343,6 +366,27 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
fn optional_binding(
|
||||
field: &'static str,
|
||||
configured_label: &str,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) -> (Option<(KeyCode, KeyModifiers)>, String) {
|
||||
if configured_label.trim().is_empty() {
|
||||
return (None, String::new());
|
||||
}
|
||||
let (value, diag) = parse_key_combo_with_diagnostic(
|
||||
configured_label,
|
||||
field,
|
||||
(KeyCode::Null, KeyModifiers::empty()),
|
||||
);
|
||||
if let Some(diag) = diag {
|
||||
diagnostics.push(diag);
|
||||
(None, String::new())
|
||||
} else {
|
||||
(Some(value), configured_label.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let mut bindings = vec![
|
||||
binding(
|
||||
"keys.new_workspace",
|
||||
|
|
@ -365,6 +409,13 @@ impl Config {
|
|||
(KeyCode::Char('d'), KeyModifiers::empty()),
|
||||
&mut diagnostics,
|
||||
),
|
||||
binding(
|
||||
"keys.new_tab",
|
||||
&self.keys.new_tab,
|
||||
"c",
|
||||
(KeyCode::Char('c'), KeyModifiers::empty()),
|
||||
&mut diagnostics,
|
||||
),
|
||||
binding(
|
||||
"keys.split_vertical",
|
||||
&self.keys.split_vertical,
|
||||
|
|
@ -409,6 +460,27 @@ impl Config {
|
|||
),
|
||||
];
|
||||
|
||||
let optional_bindings = [
|
||||
optional_binding(
|
||||
"keys.previous_workspace",
|
||||
&self.keys.previous_workspace,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
"keys.next_workspace",
|
||||
&self.keys.next_workspace,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding("keys.rename_tab", &self.keys.rename_tab, &mut diagnostics),
|
||||
optional_binding(
|
||||
"keys.previous_tab",
|
||||
&self.keys.previous_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics),
|
||||
optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics),
|
||||
];
|
||||
|
||||
use std::collections::HashMap;
|
||||
let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new();
|
||||
for binding in &mut bindings {
|
||||
|
|
@ -433,18 +505,26 @@ impl Config {
|
|||
rename_workspace_label: bindings[1].label.clone(),
|
||||
close_workspace: bindings[2].value,
|
||||
close_workspace_label: bindings[2].label.clone(),
|
||||
split_vertical: bindings[3].value,
|
||||
split_vertical_label: bindings[3].label.clone(),
|
||||
split_horizontal: bindings[4].value,
|
||||
split_horizontal_label: bindings[4].label.clone(),
|
||||
close_pane: bindings[5].value,
|
||||
close_pane_label: bindings[5].label.clone(),
|
||||
fullscreen: bindings[6].value,
|
||||
fullscreen_label: bindings[6].label.clone(),
|
||||
resize_mode: bindings[7].value,
|
||||
resize_mode_label: bindings[7].label.clone(),
|
||||
toggle_sidebar: bindings[8].value,
|
||||
toggle_sidebar_label: bindings[8].label.clone(),
|
||||
previous_workspace: optional_bindings[0].0,
|
||||
next_workspace: optional_bindings[1].0,
|
||||
new_tab: bindings[3].value,
|
||||
new_tab_label: bindings[3].label.clone(),
|
||||
rename_tab: optional_bindings[2].0,
|
||||
previous_tab: optional_bindings[3].0,
|
||||
next_tab: optional_bindings[4].0,
|
||||
close_tab: optional_bindings[5].0,
|
||||
split_vertical: bindings[4].value,
|
||||
split_vertical_label: bindings[4].label.clone(),
|
||||
split_horizontal: bindings[5].value,
|
||||
split_horizontal_label: bindings[5].label.clone(),
|
||||
close_pane: bindings[6].value,
|
||||
close_pane_label: bindings[6].label.clone(),
|
||||
fullscreen: bindings[7].value,
|
||||
fullscreen_label: bindings[7].label.clone(),
|
||||
resize_mode: bindings[8].value,
|
||||
resize_mode_label: bindings[8].label.clone(),
|
||||
toggle_sidebar: bindings[9].value,
|
||||
toggle_sidebar_label: bindings[9].label.clone(),
|
||||
};
|
||||
|
||||
(prefix_diag, prefix, diagnostics, keybinds)
|
||||
|
|
@ -460,6 +540,14 @@ pub struct Keybinds {
|
|||
pub rename_workspace_label: String,
|
||||
pub close_workspace: (KeyCode, KeyModifiers),
|
||||
pub close_workspace_label: String,
|
||||
pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
|
||||
pub next_workspace: Option<(KeyCode, KeyModifiers)>,
|
||||
pub new_tab: (KeyCode, KeyModifiers),
|
||||
pub new_tab_label: String,
|
||||
pub rename_tab: Option<(KeyCode, KeyModifiers)>,
|
||||
pub previous_tab: Option<(KeyCode, KeyModifiers)>,
|
||||
pub next_tab: Option<(KeyCode, KeyModifiers)>,
|
||||
pub close_tab: Option<(KeyCode, KeyModifiers)>,
|
||||
pub split_vertical: (KeyCode, KeyModifiers),
|
||||
pub split_vertical_label: String,
|
||||
pub split_horizontal: (KeyCode, KeyModifiers),
|
||||
|
|
@ -554,6 +642,9 @@ pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std:
|
|||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(dir).join("herdr/config.toml")
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
|
|
|
|||
10
src/main.rs
10
src/main.rs
|
|
@ -107,6 +107,13 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# new_workspace = "n"
|
||||
# rename_workspace = "shift+n"
|
||||
# close_workspace = "d"
|
||||
# previous_workspace = "" # optional, unset by default
|
||||
# next_workspace = "" # optional, unset by default
|
||||
# new_tab = "c"
|
||||
# rename_tab = "" # optional, unset by default
|
||||
# previous_tab = "" # optional, unset by default
|
||||
# next_tab = "" # optional, unset by default
|
||||
# close_tab = "" # optional, unset by default
|
||||
# split_vertical = "v"
|
||||
# split_horizontal = "-"
|
||||
# close_pane = "x"
|
||||
|
|
@ -204,8 +211,9 @@ fn main() -> io::Result<()> {
|
|||
println!(" --show-changelog Preview the current version's release notes");
|
||||
println!(" --help, -h Show this help");
|
||||
println!();
|
||||
println!("Config: ~/.config/herdr/config.toml");
|
||||
println!("Config: {}", config::config_path().display());
|
||||
println!("Logs: ~/.config/herdr/herdr.log");
|
||||
println!("Env: HERDR_CONFIG_PATH overrides config file path");
|
||||
println!("Home: https://herdr.dev");
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
137
src/persist.rs
137
src/persist.rs
|
|
@ -19,7 +19,7 @@ use crate::pane::{PaneRuntime, PaneState};
|
|||
use crate::workspace::Workspace;
|
||||
|
||||
/// Current snapshot format version.
|
||||
const SNAPSHOT_VERSION: u32 = 2;
|
||||
const SNAPSHOT_VERSION: u32 = 3;
|
||||
|
||||
/// Serializable snapshot of the entire herdr session.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -34,15 +34,23 @@ pub struct SessionSnapshot {
|
|||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WorkspaceSnapshot {
|
||||
#[serde(default)]
|
||||
pub custom_name: Option<String>,
|
||||
pub identity_cwd: PathBuf,
|
||||
pub tabs: Vec<TabSnapshot>,
|
||||
#[serde(default)]
|
||||
pub active_tab: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TabSnapshot {
|
||||
#[serde(default)]
|
||||
pub custom_name: Option<String>,
|
||||
pub layout: LayoutSnapshot,
|
||||
pub panes: HashMap<u32, PaneSnapshot>,
|
||||
pub zoomed: bool,
|
||||
/// Raw pane ID that was focused when saved.
|
||||
#[serde(default)]
|
||||
pub focused: Option<u32>,
|
||||
/// Raw pane ID used as the workspace identity source.
|
||||
#[serde(default)]
|
||||
pub root_pane: Option<u32>,
|
||||
}
|
||||
|
|
@ -87,22 +95,31 @@ pub fn capture(
|
|||
}
|
||||
|
||||
fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
|
||||
WorkspaceSnapshot {
|
||||
custom_name: ws.custom_name.clone(),
|
||||
identity_cwd: ws.identity_cwd.clone(),
|
||||
tabs: ws.tabs.iter().map(capture_tab).collect(),
|
||||
active_tab: ws.active_tab,
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
|
||||
let mut panes = HashMap::new();
|
||||
for id in ws.panes.keys() {
|
||||
let cwd = ws
|
||||
for id in tab.panes.keys() {
|
||||
let cwd = tab
|
||||
.runtimes
|
||||
.get(id)
|
||||
.and_then(|rt| rt.cwd())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
panes.insert(id.raw(), PaneSnapshot { cwd });
|
||||
}
|
||||
WorkspaceSnapshot {
|
||||
custom_name: ws.custom_name.clone(),
|
||||
layout: capture_node(ws.layout.root()),
|
||||
TabSnapshot {
|
||||
custom_name: tab.custom_name.clone(),
|
||||
layout: capture_node(tab.layout.root()),
|
||||
panes,
|
||||
zoomed: ws.zoomed,
|
||||
focused: Some(ws.layout.focused().raw()),
|
||||
root_pane: Some(ws.root_pane.raw()),
|
||||
zoomed: tab.zoomed,
|
||||
focused: Some(tab.layout.focused().raw()),
|
||||
root_pane: Some(tab.root_pane.raw()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,10 +178,53 @@ fn restore_workspace(
|
|||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> Option<Workspace> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
let mut next_public_pane_number = 1;
|
||||
|
||||
for (idx, tab_snap) in snap.tabs.iter().enumerate() {
|
||||
let tab = restore_tab(
|
||||
tab_snap,
|
||||
idx + 1,
|
||||
rows,
|
||||
cols,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
)?;
|
||||
for pane_id in tab.layout.pane_ids() {
|
||||
public_pane_numbers.insert(pane_id, next_public_pane_number);
|
||||
next_public_pane_number += 1;
|
||||
}
|
||||
tabs.push(tab);
|
||||
}
|
||||
|
||||
if tabs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Workspace {
|
||||
custom_name: snap.custom_name.clone(),
|
||||
identity_cwd: snap.identity_cwd.clone(),
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number,
|
||||
active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
|
||||
tabs,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_tab(
|
||||
snap: &TabSnapshot,
|
||||
number: usize,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> Option<crate::workspace::Tab> {
|
||||
let (node, id_map) = restore_node_remapped(&snap.layout);
|
||||
let pane_ids = collect_pane_ids(&node);
|
||||
|
||||
// Restore focused pane: map saved raw ID to new ID, fall back to first pane
|
||||
let focus = snap
|
||||
.focused
|
||||
.and_then(|old_raw| id_map.get(&old_raw).copied())
|
||||
|
|
@ -198,7 +258,7 @@ fn restore_workspace(
|
|||
runtimes.insert(*id, runtime);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(workspace = ?snap.custom_name, err = %e, "failed to restore pane");
|
||||
error!(tab = ?snap.custom_name, err = %e, "failed to restore pane");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
|
@ -210,20 +270,11 @@ fn restore_workspace(
|
|||
.or_else(|| pane_ids.first().copied())
|
||||
.unwrap_or(PaneId::from_raw(0));
|
||||
|
||||
let public_pane_numbers = layout
|
||||
.pane_ids()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, pane_id)| (pane_id, index + 1))
|
||||
.collect();
|
||||
|
||||
Some(Workspace {
|
||||
Some(crate::workspace::Tab {
|
||||
custom_name: snap.custom_name.clone(),
|
||||
number,
|
||||
root_pane,
|
||||
layout,
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number: panes.len() + 1,
|
||||
panes,
|
||||
runtimes,
|
||||
zoomed: snap.zoomed,
|
||||
|
|
@ -421,16 +472,21 @@ mod tests {
|
|||
let snap = SessionSnapshot {
|
||||
workspaces: vec![WorkspaceSnapshot {
|
||||
custom_name: Some("pi-mono".to_string()),
|
||||
layout: LayoutSnapshot::Split {
|
||||
direction: DirectionSnapshot::Horizontal,
|
||||
ratio: 0.5,
|
||||
first: Box::new(LayoutSnapshot::Pane(0)),
|
||||
second: Box::new(LayoutSnapshot::Pane(1)),
|
||||
},
|
||||
panes,
|
||||
zoomed: false,
|
||||
focused: Some(0),
|
||||
root_pane: Some(0),
|
||||
identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
|
||||
tabs: vec![TabSnapshot {
|
||||
custom_name: Some("api".to_string()),
|
||||
layout: LayoutSnapshot::Split {
|
||||
direction: DirectionSnapshot::Horizontal,
|
||||
ratio: 0.5,
|
||||
first: Box::new(LayoutSnapshot::Pane(0)),
|
||||
second: Box::new(LayoutSnapshot::Pane(1)),
|
||||
},
|
||||
panes,
|
||||
zoomed: false,
|
||||
focused: Some(0),
|
||||
root_pane: Some(0),
|
||||
}],
|
||||
active_tab: 0,
|
||||
}],
|
||||
active: Some(0),
|
||||
selected: 0,
|
||||
|
|
@ -445,9 +501,10 @@ mod tests {
|
|||
restored.workspaces[0].custom_name.as_deref(),
|
||||
Some("pi-mono")
|
||||
);
|
||||
assert_eq!(restored.workspaces[0].panes.len(), 2);
|
||||
assert_eq!(restored.workspaces[0].tabs.len(), 1);
|
||||
assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2);
|
||||
assert_eq!(
|
||||
restored.workspaces[0].panes[&0].cwd,
|
||||
restored.workspaces[0].tabs[0].panes[&0].cwd,
|
||||
PathBuf::from("/home/can/Projects/herdr")
|
||||
);
|
||||
}
|
||||
|
|
@ -494,9 +551,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn focused_pane_default_is_none() {
|
||||
let json = r#"{"name":"test","layout":{"Pane":0},"panes":{},"zoomed":false}"#;
|
||||
fn active_tab_default_is_zero() {
|
||||
let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#;
|
||||
let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(ws.focused, None); // #[serde(default)]
|
||||
assert_eq!(ws.active_tab, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
|
|||
let Some((event, consumed)) = extract_one_event(buffer) else {
|
||||
break;
|
||||
};
|
||||
tracing::debug!(
|
||||
raw_bytes = ?&buffer[..consumed],
|
||||
event = ?event,
|
||||
"raw input event parsed"
|
||||
);
|
||||
buffer.drain(..consumed);
|
||||
let _ = tx.blocking_send(event);
|
||||
}
|
||||
|
|
|
|||
215
src/ui.rs
215
src/ui.rs
|
|
@ -42,20 +42,35 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
|
|||
let [sidebar_area, main_area] =
|
||||
Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
|
||||
|
||||
let terminal_area = main_area;
|
||||
let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some();
|
||||
let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 {
|
||||
let [tab_bar_rect, terminal_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
|
||||
(tab_bar_rect, terminal_area)
|
||||
} else {
|
||||
(Rect::default(), main_area)
|
||||
};
|
||||
|
||||
let tab_hit_areas = app
|
||||
.active
|
||||
.and_then(|i| app.workspaces.get(i))
|
||||
.map(|ws| compute_tab_hit_areas(ws, tab_bar_rect))
|
||||
.unwrap_or_default();
|
||||
let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect);
|
||||
|
||||
// Compute split borders
|
||||
let split_borders = app
|
||||
.active
|
||||
.and_then(|i| app.workspaces.get(i))
|
||||
.map(|ws| ws.layout.splits(terminal_area))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Compute pane layout + reconcile sizes
|
||||
let pane_infos = compute_pane_infos(app, terminal_area);
|
||||
|
||||
app.view = crate::app::ViewState {
|
||||
sidebar_rect: sidebar_area,
|
||||
tab_bar_rect,
|
||||
tab_hit_areas,
|
||||
new_tab_hit_area,
|
||||
terminal_area,
|
||||
pane_infos,
|
||||
split_borders,
|
||||
|
|
@ -65,6 +80,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
|
|||
/// Render the UI — reads AppState but does not mutate it.
|
||||
pub fn render(app: &AppState, frame: &mut Frame) {
|
||||
let sidebar_area = app.view.sidebar_rect;
|
||||
let tab_bar_area = app.view.tab_bar_rect;
|
||||
let terminal_area = app.view.terminal_area;
|
||||
|
||||
if app.sidebar_collapsed {
|
||||
|
|
@ -72,6 +88,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
|
|||
} else {
|
||||
render_sidebar(app, frame, sidebar_area);
|
||||
}
|
||||
render_tab_bar(app, frame, tab_bar_area);
|
||||
render_panes(app, frame, terminal_area);
|
||||
|
||||
match app.mode {
|
||||
|
|
@ -85,7 +102,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
|
|||
render_context_menu(app, frame);
|
||||
}
|
||||
Mode::Settings => render_settings_overlay(app, frame, frame.area()),
|
||||
Mode::RenameSession => {}
|
||||
Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()),
|
||||
Mode::Terminal => {}
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +122,87 @@ pub fn render(app: &AppState, frame: &mut Frame) {
|
|||
}
|
||||
}
|
||||
|
||||
const MIN_TAB_WIDTH: u16 = 8;
|
||||
const NEW_TAB_WIDTH: u16 = 3;
|
||||
|
||||
fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut x = area.x;
|
||||
let mut rects = Vec::new();
|
||||
let right = area.x + area.width;
|
||||
for tab in &ws.tabs {
|
||||
if x >= right.saturating_sub(NEW_TAB_WIDTH) {
|
||||
break;
|
||||
}
|
||||
let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH);
|
||||
let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x);
|
||||
let width = desired.min(remaining).max(1);
|
||||
rects.push(Rect::new(x, area.y, width, 1));
|
||||
x = x.saturating_add(width + 1);
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return Rect::default();
|
||||
}
|
||||
let x = tab_hit_areas
|
||||
.last()
|
||||
.map(|rect| rect.x + rect.width + 1)
|
||||
.unwrap_or(area.x)
|
||||
.min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH));
|
||||
Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1)
|
||||
}
|
||||
|
||||
fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(ws_idx) = app.active else {
|
||||
return;
|
||||
};
|
||||
let Some(ws) = app.workspaces.get(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let p = &app.palette;
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
|
||||
area,
|
||||
);
|
||||
|
||||
for (idx, tab) in ws.tabs.iter().enumerate() {
|
||||
let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
|
||||
break;
|
||||
};
|
||||
let active = idx == ws.active_tab;
|
||||
let style = if active {
|
||||
Style::default()
|
||||
.fg(p.panel_bg)
|
||||
.bg(p.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(p.overlay1).bg(p.surface0)
|
||||
};
|
||||
let width = rect.width as usize;
|
||||
let name = tab.display_name();
|
||||
let text = format!(" {:width$}", name, width = width.saturating_sub(1));
|
||||
frame.render_widget(Paragraph::new(text).style(style), rect);
|
||||
}
|
||||
|
||||
if app.view.new_tab_hit_area.width > 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
|
||||
app.view.new_tab_hit_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute pane layout info and resize pane runtimes to match.
|
||||
fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
|
||||
let Some(ws_idx) = app.active else {
|
||||
|
|
@ -217,7 +315,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
let is_navigating = matches!(
|
||||
app.mode,
|
||||
Mode::Navigate
|
||||
| Mode::RenameSession
|
||||
| Mode::RenameWorkspace
|
||||
| Mode::Resize
|
||||
| Mode::ConfirmClose
|
||||
| Mode::ContextMenu
|
||||
|
|
@ -284,7 +382,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
let is_navigating = matches!(
|
||||
app.mode,
|
||||
Mode::Navigate
|
||||
| Mode::RenameSession
|
||||
| Mode::RenameWorkspace
|
||||
| Mode::Resize
|
||||
| Mode::ConfirmClose
|
||||
| Mode::ContextMenu
|
||||
|
|
@ -357,8 +455,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
|
|||
let (agg_state, agg_seen) = ws.aggregate_state();
|
||||
|
||||
// Determine row height for background fill
|
||||
let has_second_line =
|
||||
ws.branch().is_some() || (app.mode == Mode::RenameSession && i == app.selected);
|
||||
let has_second_line = ws.branch().is_some();
|
||||
let row_height: u16 = if has_second_line { 2 } else { 1 };
|
||||
|
||||
// Background fill: selected gets brighter surface, active gets subtle surface
|
||||
|
|
@ -419,17 +516,9 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
|
|||
);
|
||||
row_y += 1;
|
||||
|
||||
// Line 2: branch or rename input
|
||||
// Line 2: branch
|
||||
if row_y < list_bottom {
|
||||
if app.mode == Mode::RenameSession && i == app.selected {
|
||||
let text = format!(" {}\u{2588}", app.name_input);
|
||||
frame.render_widget(Clear, Rect::new(area.x, row_y, area.width, 1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).style(Style::default().fg(p.yellow)),
|
||||
Rect::new(area.x, row_y, area.width, 1),
|
||||
);
|
||||
row_y += 1;
|
||||
} else if let Some(branch) = ws.branch() {
|
||||
if let Some(branch) = ws.branch() {
|
||||
let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
|
||||
let mut parts = Vec::new();
|
||||
if ahead > 0 {
|
||||
|
|
@ -1314,11 +1403,13 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
let kb = &app.keybinds;
|
||||
let line1 = Line::from(vec![
|
||||
Span::styled(format!(" {}", kb.new_workspace_label), key),
|
||||
Span::styled(" new ", dim),
|
||||
Span::styled(" new ws ", dim),
|
||||
Span::styled(kb.rename_workspace_label.as_str(), key),
|
||||
Span::styled(" rename ", dim),
|
||||
Span::styled(" rename ws ", dim),
|
||||
Span::styled(kb.close_workspace_label.as_str(), key),
|
||||
Span::styled(" close ws ", dim),
|
||||
Span::styled(kb.new_tab_label.as_str(), key),
|
||||
Span::styled(" new tab ", dim),
|
||||
Span::styled(kb.split_vertical_label.as_str(), key),
|
||||
Span::styled(" split│ ", dim),
|
||||
Span::styled(kb.split_horizontal_label.as_str(), key),
|
||||
|
|
@ -1435,6 +1526,90 @@ fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
|||
}
|
||||
|
||||
/// Centered popup confirmation dialog with dimmed background.
|
||||
pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
|
||||
let save_w = 10u16;
|
||||
let clear_w = 11u16;
|
||||
let cancel_w = 12u16;
|
||||
let gap = 2u16;
|
||||
let total_w = save_w + gap + clear_w + gap + cancel_w;
|
||||
let x = inner.x + inner.width.saturating_sub(total_w) / 2;
|
||||
let y = inner.y + 3;
|
||||
(
|
||||
Rect::new(x, y, save_w, 1),
|
||||
Rect::new(x + save_w + gap, y, clear_w, 1),
|
||||
Rect::new(x + save_w + gap + clear_w + gap, y, cancel_w, 1),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
dim_background(frame, area);
|
||||
|
||||
let title = match app.mode {
|
||||
Mode::RenameWorkspace => "rename workspace",
|
||||
Mode::RenameTab => "rename tab",
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
|
||||
return;
|
||||
};
|
||||
if inner.height < 4 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas::<5>(inner);
|
||||
|
||||
render_modal_header(frame, rows[0], title, &app.palette);
|
||||
|
||||
let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
|
||||
frame.render_widget(Clear, input_rect);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(" {}█", app.name_input)).style(
|
||||
Style::default()
|
||||
.fg(app.palette.text)
|
||||
.bg(app.palette.surface0),
|
||||
),
|
||||
input_rect,
|
||||
);
|
||||
|
||||
let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(" ↵ save ").style(
|
||||
Style::default()
|
||||
.fg(app.palette.panel_bg)
|
||||
.bg(app.palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
save_rect,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(" ^c clear ").style(
|
||||
Style::default()
|
||||
.fg(app.palette.text)
|
||||
.bg(app.palette.surface0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
clear_rect,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(" esc cancel ").style(
|
||||
Style::default()
|
||||
.fg(app.palette.text)
|
||||
.bg(app.palette.surface0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
cancel_rect,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let ws_name = app
|
||||
.workspaces
|
||||
|
|
|
|||
590
src/workspace.rs
590
src/workspace.rs
|
|
@ -1,4 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -12,19 +13,12 @@ use crate::events::AppEvent;
|
|||
use crate::layout::{PaneId, TileLayout};
|
||||
use crate::pane::{PaneRuntime, PaneState};
|
||||
|
||||
/// A named workspace containing tiled terminal panes.
|
||||
pub struct Workspace {
|
||||
/// User-provided override. If set, auto-derived identity stops updating.
|
||||
pub struct Tab {
|
||||
pub custom_name: Option<String>,
|
||||
/// Identity source for this workspace.
|
||||
pub number: usize,
|
||||
/// Identity source for this tab's pane tree.
|
||||
pub root_pane: PaneId,
|
||||
pub layout: TileLayout,
|
||||
/// Cached ahead/behind counts for the root repo's current branch upstream.
|
||||
pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
|
||||
/// Stable-ish public pane numbers within this workspace.
|
||||
/// New panes append at the end; closing a pane compacts higher numbers down.
|
||||
pub public_pane_numbers: HashMap<PaneId, usize>,
|
||||
pub(crate) next_public_pane_number: usize,
|
||||
/// Pane state — always present, testable without PTYs.
|
||||
pub panes: HashMap<PaneId, PaneState>,
|
||||
/// Pane runtimes — only present in production (empty in tests).
|
||||
|
|
@ -35,7 +29,7 @@ pub struct Workspace {
|
|||
pub(crate) render_dirty: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for Workspace {
|
||||
impl Drop for Tab {
|
||||
fn drop(&mut self) {
|
||||
let runtimes = std::mem::take(&mut self.runtimes);
|
||||
for (pane_id, runtime) in runtimes {
|
||||
|
|
@ -44,8 +38,9 @@ impl Drop for Workspace {
|
|||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
impl Tab {
|
||||
pub fn new(
|
||||
number: usize,
|
||||
initial_cwd: PathBuf,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
|
|
@ -66,19 +61,14 @@ impl Workspace {
|
|||
|
||||
let mut panes = HashMap::new();
|
||||
panes.insert(root_id, PaneState::new());
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
public_pane_numbers.insert(root_id, 1);
|
||||
let mut runtimes = HashMap::new();
|
||||
runtimes.insert(root_id, runtime);
|
||||
|
||||
info!(root_pane = root_id.raw(), "workspace created");
|
||||
Ok(Self {
|
||||
custom_name: None,
|
||||
number,
|
||||
root_pane: root_id,
|
||||
layout,
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number: 2,
|
||||
panes,
|
||||
runtimes,
|
||||
zoomed: false,
|
||||
|
|
@ -88,7 +78,16 @@ impl Workspace {
|
|||
})
|
||||
}
|
||||
|
||||
/// Split the focused pane. Returns the new pane id.
|
||||
pub fn display_name(&self) -> String {
|
||||
self.custom_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.number.to_string())
|
||||
}
|
||||
|
||||
pub fn set_custom_name(&mut self, name: String) {
|
||||
self.custom_name = Some(name);
|
||||
}
|
||||
|
||||
pub fn split_focused(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
|
|
@ -109,35 +108,22 @@ impl Workspace {
|
|||
self.render_dirty.clone(),
|
||||
)?;
|
||||
self.panes.insert(new_id, PaneState::new());
|
||||
self.public_pane_numbers
|
||||
.insert(new_id, self.next_public_pane_number);
|
||||
self.next_public_pane_number += 1;
|
||||
self.runtimes.insert(new_id, runtime);
|
||||
self.zoomed = false;
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// Close the focused pane. Returns the removed pane id, or None if last pane.
|
||||
pub fn close_focused(&mut self) -> Option<PaneId> {
|
||||
pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
|
||||
let pane_id = self.layout.focused();
|
||||
self.close_pane(pane_id)
|
||||
self.detach_pane(pane_id)
|
||||
}
|
||||
|
||||
/// Close a specific pane and terminate everything running inside it.
|
||||
/// Returns None if it's the last pane and the whole workspace should close.
|
||||
pub fn close_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
|
||||
let (removed, runtime) = self.detach_pane(pane_id)?;
|
||||
if let Some(runtime) = runtime {
|
||||
runtime.shutdown(pane_id);
|
||||
}
|
||||
Some(removed)
|
||||
pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
|
||||
self.detach_pane(pane_id)
|
||||
}
|
||||
|
||||
/// Remove a specific pane from this workspace without terminating its runtime.
|
||||
/// Used when the pane process has already exited and we are only cleaning up state.
|
||||
/// Returns None if it's the last pane and the whole workspace should close.
|
||||
pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
|
||||
self.detach_pane(pane_id).map(|(removed, _)| removed)
|
||||
pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
|
||||
self.detach_pane(pane_id)
|
||||
}
|
||||
|
||||
fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
|
||||
|
|
@ -156,14 +142,6 @@ impl Workspace {
|
|||
self.layout.focus_pane(prev_focus);
|
||||
}
|
||||
|
||||
if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) {
|
||||
for number in self.public_pane_numbers.values_mut() {
|
||||
if *number > removed_number {
|
||||
*number -= 1;
|
||||
}
|
||||
}
|
||||
self.next_public_pane_number = self.public_pane_numbers.len() + 1;
|
||||
}
|
||||
self.panes.remove(&pane_id);
|
||||
let runtime = self.runtimes.remove(&pane_id);
|
||||
self.zoomed = false;
|
||||
|
|
@ -180,52 +158,18 @@ impl Workspace {
|
|||
self.layout.pane_ids().into_iter().find(|id| *id != closing)
|
||||
}
|
||||
|
||||
pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
|
||||
self.public_pane_numbers.get(&pane_id).copied()
|
||||
}
|
||||
|
||||
/// Get the runtime for the focused pane.
|
||||
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
|
||||
self.runtimes.get(&self.layout.focused())
|
||||
}
|
||||
|
||||
pub fn set_custom_name(&mut self, name: String) {
|
||||
self.custom_name = Some(name);
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> String {
|
||||
if let Some(name) = &self.custom_name {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
self.root_cwd()
|
||||
.as_deref()
|
||||
.map(derive_label_from_cwd)
|
||||
.unwrap_or_else(|| "shell".to_string())
|
||||
}
|
||||
|
||||
pub fn root_cwd(&self) -> Option<PathBuf> {
|
||||
self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd())
|
||||
}
|
||||
|
||||
/// Aggregate workspace signal for sidebar triage.
|
||||
/// Returns the most urgent pane's state + seen flag.
|
||||
pub fn aggregate_state(&self) -> (AgentState, bool) {
|
||||
self.panes
|
||||
.values()
|
||||
.map(|pane| (pane.state, pane.seen))
|
||||
.max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
|
||||
.unwrap_or((AgentState::Unknown, true))
|
||||
}
|
||||
|
||||
pub fn has_working_pane(&self) -> bool {
|
||||
self.panes
|
||||
.values()
|
||||
.any(|pane| pane.state == AgentState::Working)
|
||||
}
|
||||
|
||||
/// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom).
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)] // retained for focused layout-order assertions in tests
|
||||
pub fn pane_states(&self) -> Vec<(AgentState, bool)> {
|
||||
self.layout
|
||||
.pane_ids()
|
||||
|
|
@ -239,7 +183,6 @@ impl Workspace {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Per-pane detail for the agent detail panel, in stable layout order.
|
||||
pub fn pane_details(&self) -> Vec<PaneDetail> {
|
||||
self.layout
|
||||
.pane_ids()
|
||||
|
|
@ -261,28 +204,336 @@ impl Workspace {
|
|||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the git branch for this workspace's root cwd.
|
||||
pub fn branch(&self) -> Option<String> {
|
||||
self.root_cwd().and_then(|cwd| git_branch(&cwd))
|
||||
/// A named workspace containing tabs.
|
||||
pub struct Workspace {
|
||||
/// User-provided override. If set, auto-derived identity stops updating.
|
||||
pub custom_name: Option<String>,
|
||||
/// Fixed workspace identity source, seeded when the workspace is created.
|
||||
pub identity_cwd: PathBuf,
|
||||
/// Cached ahead/behind counts for the workspace repo's current branch upstream.
|
||||
pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
|
||||
/// Stable-ish public pane numbers within this workspace.
|
||||
/// New panes append at the end; closing a pane compacts higher numbers down.
|
||||
pub public_pane_numbers: HashMap<PaneId, usize>,
|
||||
pub(crate) next_public_pane_number: usize,
|
||||
pub tabs: Vec<Tab>,
|
||||
pub active_tab: usize,
|
||||
}
|
||||
|
||||
impl Deref for Workspace {
|
||||
type Target = Tab;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.active_tab()
|
||||
.expect("workspace must always have at least one active tab")
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for Workspace {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.active_tab_mut()
|
||||
.expect("workspace must always have at least one active tab")
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(
|
||||
initial_cwd: PathBuf,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<Self> {
|
||||
let tab = Tab::new(
|
||||
1,
|
||||
initial_cwd.clone(),
|
||||
rows,
|
||||
cols,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)?;
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
public_pane_numbers.insert(tab.root_pane, 1);
|
||||
info!(root_pane = tab.root_pane.raw(), "workspace created");
|
||||
Ok(Self {
|
||||
custom_name: None,
|
||||
identity_cwd: initial_cwd,
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number: 2,
|
||||
tabs: vec![tab],
|
||||
active_tab: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_tab(&self) -> Option<&Tab> {
|
||||
self.tabs.get(self.active_tab)
|
||||
}
|
||||
|
||||
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
|
||||
self.tabs.get_mut(self.active_tab)
|
||||
}
|
||||
|
||||
pub fn active_tab_display_name(&self) -> Option<String> {
|
||||
self.active_tab().map(Tab::display_name)
|
||||
}
|
||||
|
||||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
if idx < self.tabs.len() {
|
||||
self.active_tab = idx;
|
||||
if let Some(tab) = self.tabs.get_mut(idx) {
|
||||
for pane in tab.panes.values_mut() {
|
||||
pane.seen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_tab(&mut self, rows: u16, cols: u16, cwd: PathBuf) -> std::io::Result<usize> {
|
||||
let number = self.tabs.len() + 1;
|
||||
let events = self
|
||||
.active_tab()
|
||||
.map(|tab| tab.events.clone())
|
||||
.expect("workspace must always have at least one tab");
|
||||
let render_notify = self
|
||||
.active_tab()
|
||||
.map(|tab| tab.render_notify.clone())
|
||||
.expect("workspace must always have at least one tab");
|
||||
let render_dirty = self
|
||||
.active_tab()
|
||||
.map(|tab| tab.render_dirty.clone())
|
||||
.expect("workspace must always have at least one tab");
|
||||
|
||||
let tab = Tab::new(number, cwd, rows, cols, events, render_notify, render_dirty)?;
|
||||
self.register_new_pane(tab.root_pane);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
Ok(self.active_tab)
|
||||
}
|
||||
|
||||
pub fn close_tab(&mut self, idx: usize) -> bool {
|
||||
if self.tabs.len() <= 1 || idx >= self.tabs.len() {
|
||||
return false;
|
||||
}
|
||||
let tab = self.tabs.remove(idx);
|
||||
for pane_id in tab.panes.keys() {
|
||||
self.unregister_pane(*pane_id);
|
||||
}
|
||||
self.renumber_tabs();
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
} else if idx <= self.active_tab && self.active_tab > 0 {
|
||||
self.active_tab -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn close_active_tab(&mut self) -> bool {
|
||||
self.close_tab(self.active_tab)
|
||||
}
|
||||
|
||||
pub fn split_focused(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> std::io::Result<PaneId> {
|
||||
let new_id = self
|
||||
.active_tab_mut()
|
||||
.expect("workspace must always have at least one tab")
|
||||
.split_focused(direction, rows, cols, cwd)?;
|
||||
self.register_new_pane(new_id);
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// Close the focused pane. Returns true if the workspace should close.
|
||||
pub fn close_focused(&mut self) -> bool {
|
||||
let pane_count = self
|
||||
.active_tab()
|
||||
.map(|tab| tab.layout.pane_count())
|
||||
.unwrap_or(0);
|
||||
let tab_count = self.tabs.len();
|
||||
if pane_count <= 1 {
|
||||
return tab_count <= 1 || self.close_active_tab_and_report();
|
||||
}
|
||||
|
||||
if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) {
|
||||
self.unregister_pane(removed);
|
||||
if let Some(runtime) = runtime {
|
||||
runtime.shutdown(removed);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove a specific pane from this workspace without terminating its runtime.
|
||||
/// Returns true if the workspace should close.
|
||||
pub fn remove_pane(&mut self, pane_id: PaneId) -> bool {
|
||||
let Some(tab_idx) = self.find_tab_index_for_pane(pane_id) else {
|
||||
return false;
|
||||
};
|
||||
let pane_count = self.tabs[tab_idx].layout.pane_count();
|
||||
let tab_count = self.tabs.len();
|
||||
if pane_count <= 1 {
|
||||
if tab_count <= 1 {
|
||||
return true;
|
||||
}
|
||||
self.tabs.remove(tab_idx);
|
||||
self.unregister_pane(pane_id);
|
||||
self.renumber_tabs();
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
} else if tab_idx <= self.active_tab && self.active_tab > 0 {
|
||||
self.active_tab -= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) {
|
||||
self.unregister_pane(removed);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
|
||||
self.public_pane_numbers.get(&pane_id).copied()
|
||||
}
|
||||
|
||||
pub fn set_custom_name(&mut self, name: String) {
|
||||
self.custom_name = Some(name);
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> String {
|
||||
if let Some(name) = &self.custom_name {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
derive_label_from_cwd(&self.identity_cwd)
|
||||
}
|
||||
|
||||
pub fn branch(&self) -> Option<String> {
|
||||
git_branch(&self.identity_cwd)
|
||||
}
|
||||
|
||||
/// Cached ahead/behind counts for this workspace's current branch upstream.
|
||||
pub fn git_ahead_behind(&self) -> Option<(usize, usize)> {
|
||||
self.cached_git_ahead_behind
|
||||
}
|
||||
|
||||
/// Refresh cached ahead/behind counts from the workspace's current cwd.
|
||||
pub fn refresh_git_ahead_behind(&mut self) {
|
||||
self.cached_git_ahead_behind = self.root_cwd().and_then(|cwd| git_ahead_behind(&cwd));
|
||||
self.cached_git_ahead_behind = git_ahead_behind(&self.identity_cwd);
|
||||
}
|
||||
|
||||
pub fn aggregate_state(&self) -> (AgentState, bool) {
|
||||
self.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.panes.values())
|
||||
.map(|pane| (pane.state, pane.seen))
|
||||
.max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
|
||||
.unwrap_or((AgentState::Unknown, true))
|
||||
}
|
||||
|
||||
pub fn has_working_pane(&self) -> bool {
|
||||
self.tabs.iter().any(Tab::has_working_pane)
|
||||
}
|
||||
|
||||
pub fn pane_details(&self) -> Vec<PaneDetail> {
|
||||
self.active_tab().map(Tab::pane_details).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
|
||||
self.active_tab().and_then(Tab::focused_runtime)
|
||||
}
|
||||
|
||||
pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.panes.contains_key(&pane_id))
|
||||
}
|
||||
|
||||
pub fn pane_state(&self, pane_id: PaneId) -> Option<&PaneState> {
|
||||
self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id))
|
||||
}
|
||||
|
||||
pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> {
|
||||
self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id))
|
||||
}
|
||||
|
||||
pub fn focused_pane_id(&self) -> Option<PaneId> {
|
||||
self.active_tab().map(|tab| tab.layout.focused())
|
||||
}
|
||||
|
||||
pub fn close_pane(&mut self, pane_id: PaneId) -> bool {
|
||||
let tab_idx = match self.find_tab_index_for_pane(pane_id) {
|
||||
Some(idx) => idx,
|
||||
None => return false,
|
||||
};
|
||||
let pane_count = self.tabs[tab_idx].layout.pane_count();
|
||||
let tab_count = self.tabs.len();
|
||||
if pane_count <= 1 {
|
||||
if tab_count <= 1 {
|
||||
return true;
|
||||
}
|
||||
self.tabs.remove(tab_idx);
|
||||
self.unregister_pane(pane_id);
|
||||
self.renumber_tabs();
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len() - 1;
|
||||
} else if tab_idx <= self.active_tab && self.active_tab > 0 {
|
||||
self.active_tab -= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) {
|
||||
self.unregister_pane(removed);
|
||||
if let Some(runtime) = runtime {
|
||||
runtime.shutdown(removed);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn register_new_pane(&mut self, pane_id: PaneId) {
|
||||
self.public_pane_numbers
|
||||
.insert(pane_id, self.next_public_pane_number);
|
||||
self.next_public_pane_number += 1;
|
||||
}
|
||||
|
||||
fn unregister_pane(&mut self, pane_id: PaneId) {
|
||||
if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) {
|
||||
for number in self.public_pane_numbers.values_mut() {
|
||||
if *number > removed_number {
|
||||
*number -= 1;
|
||||
}
|
||||
}
|
||||
self.next_public_pane_number = self.public_pane_numbers.len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn renumber_tabs(&mut self) {
|
||||
for (idx, tab) in self.tabs.iter_mut().enumerate() {
|
||||
tab.number = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn close_active_tab_and_report(&mut self) -> bool {
|
||||
if self.tabs.len() <= 1 {
|
||||
return true;
|
||||
}
|
||||
self.close_active_tab();
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail info for a single pane, used by the agent detail panel.
|
||||
pub struct PaneDetail {
|
||||
pub label: String,
|
||||
/// The detected agent, if any. Will be used for context extraction.
|
||||
#[allow(dead_code)] // used later for triage line extraction
|
||||
#[allow(dead_code)]
|
||||
pub agent: Option<Agent>,
|
||||
pub state: AgentState,
|
||||
pub seen: bool,
|
||||
|
|
@ -291,7 +542,7 @@ pub struct PaneDetail {
|
|||
fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
|
||||
match (state, seen) {
|
||||
(AgentState::Blocked, _) => 4,
|
||||
(AgentState::Idle, false) => 3, // done, waiting for you to look
|
||||
(AgentState::Idle, false) => 3,
|
||||
(AgentState::Working, _) => 2,
|
||||
(AgentState::Idle, true) => 1,
|
||||
(AgentState::Unknown, _) => 0,
|
||||
|
|
@ -314,7 +565,7 @@ fn agent_name(agent: Agent) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn derive_label_from_cwd(cwd: &Path) -> String {
|
||||
pub fn derive_label_from_cwd(cwd: &Path) -> String {
|
||||
if let Some(repo_root) = git_repo_root(cwd) {
|
||||
if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
|
||||
return name.to_string();
|
||||
|
|
@ -335,8 +586,6 @@ fn derive_label_from_cwd(cwd: &Path) -> String {
|
|||
.unwrap_or_else(|| cwd.display().to_string())
|
||||
}
|
||||
|
||||
/// Read the current git branch name from .git/HEAD.
|
||||
/// Returns None if not in a git repo or HEAD is detached.
|
||||
pub fn git_branch(cwd: &Path) -> Option<String> {
|
||||
let repo_root = git_repo_root(cwd)?;
|
||||
let head_path = repo_root.join(".git").join("HEAD");
|
||||
|
|
@ -364,7 +613,6 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read ahead/behind counts relative to the current branch upstream.
|
||||
fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> {
|
||||
git_repo_root(cwd)?;
|
||||
|
||||
|
|
@ -390,45 +638,46 @@ fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
|
|||
Some((ahead, behind))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers — construct workspaces without PTYs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
impl Workspace {
|
||||
/// Create a test workspace with one pane, no PTY runtime.
|
||||
pub fn test_new(name: &str) -> Self {
|
||||
let (events, _) = mpsc::channel(64);
|
||||
let (layout, root_id) = TileLayout::new();
|
||||
let render_notify = Arc::new(Notify::new());
|
||||
let render_dirty = Arc::new(AtomicBool::new(false));
|
||||
let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
|
||||
let (layout, root_id) = TileLayout::new();
|
||||
let mut panes = HashMap::new();
|
||||
panes.insert(root_id, PaneState::new());
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
public_pane_numbers.insert(root_id, 1);
|
||||
Self {
|
||||
custom_name: Some(name.to_string()),
|
||||
let tab = Tab {
|
||||
custom_name: None,
|
||||
number: 1,
|
||||
root_pane: root_id,
|
||||
layout,
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number: 2,
|
||||
panes,
|
||||
runtimes: HashMap::new(),
|
||||
zoomed: false,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
};
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
public_pane_numbers.insert(tab.root_pane, 1);
|
||||
Self {
|
||||
custom_name: Some(name.to_string()),
|
||||
identity_cwd,
|
||||
cached_git_ahead_behind: None,
|
||||
public_pane_numbers,
|
||||
next_public_pane_number: 2,
|
||||
tabs: vec![tab],
|
||||
active_tab: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a test pane (splits focused, no PTY runtime).
|
||||
pub fn test_split(&mut self, direction: Direction) -> PaneId {
|
||||
let new_id = self.layout.split_focused(direction);
|
||||
self.panes.insert(new_id, PaneState::new());
|
||||
self.public_pane_numbers
|
||||
.insert(new_id, self.next_public_pane_number);
|
||||
self.next_public_pane_number += 1;
|
||||
let tab = self.active_tab_mut().expect("workspace must have tab");
|
||||
let new_id = tab.layout.split_focused(direction);
|
||||
tab.panes.insert(new_id, PaneState::new());
|
||||
self.register_new_pane(new_id);
|
||||
new_id
|
||||
}
|
||||
}
|
||||
|
|
@ -450,10 +699,14 @@ mod tests {
|
|||
fn aggregate_state_priority() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let id2 = ws.test_split(Direction::Horizontal);
|
||||
|
||||
let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
|
||||
ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
|
||||
ws.panes.get_mut(&id2).unwrap().state = AgentState::Working;
|
||||
let root_id = ws.tabs[0]
|
||||
.panes
|
||||
.keys()
|
||||
.find(|id| **id != id2)
|
||||
.copied()
|
||||
.unwrap();
|
||||
ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
|
||||
ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
|
||||
|
||||
let (state, seen) = ws.aggregate_state();
|
||||
assert_eq!(state, AgentState::Working);
|
||||
|
|
@ -464,104 +717,19 @@ mod tests {
|
|||
fn aggregate_state_done_unseen_beats_working() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let id2 = ws.test_split(Direction::Horizontal);
|
||||
|
||||
let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
|
||||
let root = ws.panes.get_mut(&root_id).unwrap();
|
||||
let root_id = ws.tabs[0]
|
||||
.panes
|
||||
.keys()
|
||||
.find(|id| **id != id2)
|
||||
.copied()
|
||||
.unwrap();
|
||||
let root = ws.tabs[0].panes.get_mut(&root_id).unwrap();
|
||||
root.state = AgentState::Idle;
|
||||
root.seen = false;
|
||||
ws.panes.get_mut(&id2).unwrap().state = AgentState::Working;
|
||||
ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
|
||||
|
||||
let (state, seen) = ws.aggregate_state();
|
||||
assert_eq!(state, AgentState::Idle);
|
||||
assert!(!seen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_state_blocked_beats_done_unseen() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let id2 = ws.test_split(Direction::Horizontal);
|
||||
|
||||
let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
|
||||
let root = ws.panes.get_mut(&root_id).unwrap();
|
||||
root.state = AgentState::Idle;
|
||||
root.seen = false;
|
||||
ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
|
||||
|
||||
let (state, seen) = ws.aggregate_state();
|
||||
assert_eq!(state, AgentState::Blocked);
|
||||
assert!(seen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_focused_removes_pane() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let _id2 = ws.test_split(Direction::Horizontal);
|
||||
assert_eq!(ws.panes.len(), 2);
|
||||
|
||||
let closed = ws.close_focused();
|
||||
assert!(closed.is_some());
|
||||
assert_eq!(ws.panes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_focused_last_pane_returns_none() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
assert_eq!(ws.panes.len(), 1);
|
||||
|
||||
let closed = ws.close_focused();
|
||||
assert!(closed.is_none());
|
||||
assert_eq!(ws.panes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_states_matches_layout_order() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let id2 = ws.test_split(Direction::Horizontal);
|
||||
|
||||
ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
|
||||
|
||||
let states = ws.pane_states();
|
||||
assert_eq!(states.len(), 2);
|
||||
assert_eq!(states[0].0, AgentState::Unknown);
|
||||
assert_eq!(states[1].0, AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_details_stay_in_layout_order() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let id2 = ws.test_split(Direction::Horizontal);
|
||||
|
||||
let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
|
||||
ws.panes.get_mut(&root_id).unwrap().detected_agent = Some(Agent::Pi);
|
||||
ws.panes.get_mut(&root_id).unwrap().state = AgentState::Working;
|
||||
ws.panes.get_mut(&id2).unwrap().detected_agent = Some(Agent::Claude);
|
||||
ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
|
||||
|
||||
let details = ws.pane_details();
|
||||
assert_eq!(details.len(), 2);
|
||||
assert_eq!(details[0].label, "pi");
|
||||
assert_eq!(details[0].state, AgentState::Working);
|
||||
assert_eq!(details[1].label, "claude");
|
||||
assert_eq!(details[1].state, AgentState::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_root_promotes_another_pane() {
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let root = ws.root_pane;
|
||||
let other = ws.test_split(Direction::Horizontal);
|
||||
ws.layout.focus_pane(root);
|
||||
ws.remove_pane(root);
|
||||
assert_eq!(ws.root_pane, other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_ahead_behind_output_maps_first_field_to_ahead() {
|
||||
assert_eq!(parse_git_ahead_behind_output("7\t0\n"), Some((7, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_ahead_behind_output_maps_second_field_to_behind() {
|
||||
assert_eq!(parse_git_ahead_behind_output("0 3\n"), Some((0, 3)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ fn workspace_and_pane_management_commands_work() {
|
|||
let runtime_dir = base.join("runtime");
|
||||
let socket_path = runtime_dir.join("herdr.sock");
|
||||
|
||||
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let listed = run_cli(&socket_path, &["workspace", "list"]);
|
||||
|
|
@ -206,7 +206,7 @@ fn pane_run_read_and_wait_commands_work() {
|
|||
let runtime_dir = base.join("runtime");
|
||||
let socket_path = runtime_dir.join("herdr.sock");
|
||||
|
||||
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let created = send_request(
|
||||
|
|
@ -272,7 +272,7 @@ fn closing_pane_terminates_processes_inside_it() {
|
|||
let runtime_dir = base.join("runtime");
|
||||
let socket_path = runtime_dir.join("herdr.sock");
|
||||
|
||||
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let created = run_cli(
|
||||
|
|
@ -335,7 +335,7 @@ fn closing_workspace_terminates_processes_inside_it() {
|
|||
let runtime_dir = base.join("runtime");
|
||||
let socket_path = runtime_dir.join("herdr.sock");
|
||||
|
||||
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let created = run_cli(
|
||||
|
|
@ -390,7 +390,7 @@ fn ids_are_compact_and_positional() {
|
|||
let runtime_dir = base.join("runtime");
|
||||
let socket_path = runtime_dir.join("herdr.sock");
|
||||
|
||||
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
|
||||
wait_for_socket(&socket_path, Duration::from_secs(5));
|
||||
|
||||
let ws1 = run_cli(
|
||||
|
|
@ -577,7 +577,7 @@ fn wait_agent_state_exits_when_state_matches() {
|
|||
),
|
||||
);
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
let mut herdr = SpawnedHerdr {
|
||||
let herdr = SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
child,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue