538 lines
17 KiB
Rust
538 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
use std::ops::{Deref, DerefMut};
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use ratatui::layout::Direction;
|
|
use tokio::sync::{mpsc, Notify};
|
|
use tracing::info;
|
|
|
|
use crate::events::AppEvent;
|
|
use crate::layout::PaneId;
|
|
#[cfg(test)]
|
|
use crate::layout::TileLayout;
|
|
use crate::pane::{PaneRuntime, PaneState};
|
|
|
|
mod aggregate;
|
|
mod git;
|
|
mod tab;
|
|
|
|
use self::git::git_ahead_behind;
|
|
pub use self::{
|
|
git::{derive_label_from_cwd, git_branch},
|
|
tab::Tab,
|
|
};
|
|
|
|
static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1);
|
|
|
|
pub(crate) fn generate_workspace_id() -> String {
|
|
let micros = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_micros())
|
|
.unwrap_or(0);
|
|
let counter = NEXT_WORKSPACE_ID.fetch_add(1, Ordering::Relaxed);
|
|
format!("w{micros:x}{counter:x}")
|
|
}
|
|
|
|
/// A named workspace containing tabs.
|
|
pub struct Workspace {
|
|
/// Stable public workspace identity, independent of display order.
|
|
pub id: String,
|
|
/// User-provided override. If set, auto-derived identity stops updating.
|
|
pub custom_name: Option<String>,
|
|
/// Fallback workspace identity source for tests, old snapshots, or missing runtimes.
|
|
pub identity_cwd: PathBuf,
|
|
/// Cached current git branch for the workspace repo.
|
|
pub(crate) cached_git_branch: Option<String>,
|
|
/// 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,
|
|
scrollback_limit_bytes: usize,
|
|
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
|
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,
|
|
scrollback_limit_bytes,
|
|
host_terminal_theme,
|
|
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 {
|
|
id: generate_workspace_id(),
|
|
custom_name: None,
|
|
identity_cwd: initial_cwd.clone(),
|
|
cached_git_branch: git_branch(&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_index(&self) -> usize {
|
|
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,
|
|
scrollback_limit_bytes: usize,
|
|
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
|
) -> 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,
|
|
scrollback_limit_bytes,
|
|
host_terminal_theme,
|
|
events,
|
|
render_notify,
|
|
render_dirty,
|
|
)?;
|
|
self.register_new_pane(tab.root_pane);
|
|
self.tabs.push(tab);
|
|
Ok(self.tabs.len() - 1)
|
|
}
|
|
|
|
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 move_tab(&mut self, source_idx: usize, insert_idx: usize) -> bool {
|
|
if source_idx >= self.tabs.len() || insert_idx > self.tabs.len() {
|
|
return false;
|
|
}
|
|
|
|
let target_idx = if source_idx < insert_idx {
|
|
insert_idx.saturating_sub(1)
|
|
} else {
|
|
insert_idx
|
|
}
|
|
.min(self.tabs.len().saturating_sub(1));
|
|
|
|
if source_idx == target_idx {
|
|
return false;
|
|
}
|
|
|
|
let active_root_pane = self.tabs.get(self.active_tab).map(|tab| tab.root_pane);
|
|
let tab = self.tabs.remove(source_idx);
|
|
self.tabs.insert(target_idx, tab);
|
|
self.renumber_tabs();
|
|
self.active_tab = active_root_pane
|
|
.and_then(|root_pane| self.tabs.iter().position(|tab| tab.root_pane == root_pane))
|
|
.unwrap_or(target_idx);
|
|
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>,
|
|
scrollback_limit_bytes: usize,
|
|
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
|
) -> 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,
|
|
scrollback_limit_bytes,
|
|
host_terminal_theme,
|
|
)?;
|
|
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 resolved_identity_cwd(&self) -> Option<PathBuf> {
|
|
self.tabs
|
|
.first()
|
|
.and_then(Tab::root_cwd)
|
|
.or_else(|| Some(self.identity_cwd.clone()))
|
|
}
|
|
|
|
pub fn display_name(&self) -> String {
|
|
if let Some(name) = &self.custom_name {
|
|
return name.clone();
|
|
}
|
|
|
|
self.resolved_identity_cwd()
|
|
.map(|cwd| derive_label_from_cwd(&cwd))
|
|
.unwrap_or_else(|| "workspace".into())
|
|
}
|
|
|
|
pub fn branch(&self) -> Option<String> {
|
|
self.cached_git_branch.clone()
|
|
}
|
|
|
|
pub fn git_ahead_behind(&self) -> Option<(usize, usize)> {
|
|
self.cached_git_ahead_behind
|
|
}
|
|
|
|
pub fn refresh_git_ahead_behind(&mut self) {
|
|
let cwd = self.resolved_identity_cwd();
|
|
self.cached_git_branch = cwd.as_deref().and_then(git_branch);
|
|
self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind);
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Workspace {
|
|
pub(crate) fn test_new(name: &str) -> Self {
|
|
let (events, _) = mpsc::channel(64);
|
|
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 pane_cwds = HashMap::new();
|
|
pane_cwds.insert(root_id, identity_cwd.clone());
|
|
let tab = Tab {
|
|
custom_name: None,
|
|
number: 1,
|
|
root_pane: root_id,
|
|
layout,
|
|
panes,
|
|
pane_cwds,
|
|
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 {
|
|
id: generate_workspace_id(),
|
|
custom_name: Some(name.to_string()),
|
|
identity_cwd: identity_cwd.clone(),
|
|
cached_git_branch: git_branch(&identity_cwd),
|
|
cached_git_ahead_behind: None,
|
|
public_pane_numbers,
|
|
next_public_pane_number: 2,
|
|
tabs: vec![tab],
|
|
active_tab: 0,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn test_split(&mut self, direction: Direction) -> PaneId {
|
|
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());
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
|
|
tab.pane_cwds.insert(new_id, cwd);
|
|
self.register_new_pane(new_id);
|
|
new_id
|
|
}
|
|
|
|
pub(crate) fn test_add_tab(&mut self, name: Option<&str>) -> usize {
|
|
let (events, _) = mpsc::channel(64);
|
|
let render_notify = Arc::new(Notify::new());
|
|
let render_dirty = Arc::new(AtomicBool::new(false));
|
|
let (layout, root_id) = TileLayout::new();
|
|
let mut panes = HashMap::new();
|
|
panes.insert(root_id, PaneState::new());
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
|
|
let mut pane_cwds = HashMap::new();
|
|
pane_cwds.insert(root_id, cwd);
|
|
let tab = Tab {
|
|
custom_name: name.map(str::to_string),
|
|
number: self.tabs.len() + 1,
|
|
root_pane: root_id,
|
|
layout,
|
|
panes,
|
|
pane_cwds,
|
|
runtimes: HashMap::new(),
|
|
zoomed: false,
|
|
events,
|
|
render_notify,
|
|
render_dirty,
|
|
};
|
|
self.register_new_pane(root_id);
|
|
self.tabs.push(tab);
|
|
self.tabs.len() - 1
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn workspace_identity_follows_first_tab_root_pane_cwd() {
|
|
let mut ws = Workspace::test_new("ignored");
|
|
ws.custom_name = None;
|
|
let root_pane = ws.tabs[0].root_pane;
|
|
ws.tabs[0]
|
|
.pane_cwds
|
|
.insert(root_pane, PathBuf::from("/tmp/pion"));
|
|
|
|
assert_eq!(ws.display_name(), "pion");
|
|
assert_eq!(ws.resolved_identity_cwd(), Some(PathBuf::from("/tmp/pion")));
|
|
}
|
|
|
|
#[test]
|
|
fn moving_tab_keeps_active_identity_and_renumbers_auto_tabs() {
|
|
let mut ws = Workspace::test_new("test");
|
|
let moved_root = ws.tabs[0].root_pane;
|
|
ws.test_add_tab(Some("foo"));
|
|
let final_auto_idx = ws.test_add_tab(None);
|
|
let active_root = ws.tabs[final_auto_idx].root_pane;
|
|
ws.switch_tab(final_auto_idx);
|
|
|
|
assert!(ws.move_tab(0, ws.tabs.len()));
|
|
|
|
let labels: Vec<_> = ws.tabs.iter().map(|tab| tab.display_name()).collect();
|
|
assert_eq!(labels, vec!["foo", "2", "3"]);
|
|
assert_eq!(ws.tabs[0].custom_name.as_deref(), Some("foo"));
|
|
assert!(ws.tabs[1].custom_name.is_none());
|
|
assert!(ws.tabs[2].custom_name.is_none());
|
|
assert_eq!(ws.tabs[2].root_pane, moved_root);
|
|
assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root);
|
|
}
|
|
}
|