diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-12 19:07:07 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-12 19:10:09 +0300 |
| commit | f7a7da03fc27cc6fe4e6a44dabddcd303f939bb9 (patch) | |
| tree | 69a00241e4500703bb723b64dd0060cc83730a0b /src/workspace.rs | |
| parent | 53429206b736aa0fcfb1c5bfc1c0b11dcca3a059 (diff) | |
fix: stabilize public pane and tab ids
refs #569
Diffstat (limited to 'src/workspace.rs')
| -rw-r--r-- | src/workspace.rs | 265 |
1 files changed, 229 insertions, 36 deletions
diff --git a/src/workspace.rs b/src/workspace.rs index b01b5891..9e342ffd 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -3,7 +3,6 @@ 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}; @@ -71,14 +70,70 @@ impl WorkspaceGitStatusSnapshot { } static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1); +const PUBLIC_ID_ALPHABET: &[u8; 32] = b"123456789ABCDEFGHJKMNPQRSTVWXYZ0"; 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}") + format!("w{}", encode_public_number(counter as usize)) +} + +pub(crate) fn encode_public_number(mut value: usize) -> String { + if value == 0 { + return "0".to_string(); + } + + let mut encoded = Vec::new(); + while value > 0 { + let digit = (value - 1) % PUBLIC_ID_ALPHABET.len(); + encoded.push(PUBLIC_ID_ALPHABET[digit] as char); + value = (value - 1) / PUBLIC_ID_ALPHABET.len(); + } + encoded.iter().rev().collect() +} + +pub(crate) fn decode_public_number(value: &str) -> Option<usize> { + let mut decoded = 0usize; + for ch in value.chars() { + let digit = PUBLIC_ID_ALPHABET + .iter() + .position(|candidate| *candidate as char == ch)?; + decoded = decoded + .checked_mul(PUBLIC_ID_ALPHABET.len())? + .checked_add(digit + 1)?; + } + Some(decoded) +} + +pub(crate) fn public_workspace_number(id: &str) -> Option<usize> { + id.strip_prefix('w').and_then(decode_public_number) +} + +fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String { + format!("{workspace_id}:p{}", encode_public_number(pane_number)) +} + +pub(crate) fn reserve_workspace_ids(workspaces: &[Workspace]) { + let Some(next) = workspaces + .iter() + .filter_map(|workspace| public_workspace_number(&workspace.id)) + .max() + .and_then(|max| u64::try_from(max.checked_add(1)?).ok()) + else { + return; + }; + + let mut current = NEXT_WORKSPACE_ID.load(Ordering::Relaxed); + while current < next { + match NEXT_WORKSPACE_ID.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } } /// A named workspace containing tabs. @@ -97,10 +152,10 @@ pub struct Workspace { pub(crate) cached_git_space: Option<GitSpaceMetadata>, /// Explicit Herdr-managed worktree grouping provenance. pub worktree_space: Option<WorktreeSpaceMembership>, - /// Stable-ish public pane numbers within this workspace. - /// New panes append at the end; closing a pane compacts higher numbers down. + /// Public pane numbers within this workspace. Closed pane numbers are not reused. pub public_pane_numbers: HashMap<PaneId, usize>, pub(crate) next_public_pane_number: usize, + pub(crate) next_public_tab_number: usize, pub tabs: Vec<Tab>, pub active_tab: usize, #[cfg(test)] @@ -187,6 +242,8 @@ impl Workspace { render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + let id = generate_workspace_id(); + let root_public_pane_id = public_pane_id_for_number(&id, 1); let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( 1, @@ -199,6 +256,7 @@ impl Workspace { events, render_notify, render_dirty, + Some(&root_public_pane_id), )? } else { Tab::new( @@ -212,13 +270,14 @@ impl Workspace { events, render_notify, render_dirty, + Some(&root_public_pane_id), )? }; let mut public_pane_numbers = HashMap::new(); public_pane_numbers.insert(tab.root_pane, 1); Ok(( Self { - id: generate_workspace_id(), + id, custom_name: None, identity_cwd: initial_cwd.clone(), cached_git_branch: git_branch(&initial_cwd), @@ -227,6 +286,7 @@ impl Workspace { worktree_space: None, public_pane_numbers, next_public_pane_number: 2, + next_public_tab_number: 2, tabs: vec![tab], active_tab: 0, #[cfg(test)] @@ -294,7 +354,10 @@ impl Workspace { shell_config: crate::pane::PaneShellConfig<'_>, argv: Option<&[String]>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { - let number = self.tabs.len() + 1; + let number = self.next_public_tab_number; + self.next_public_tab_number += 1; + let pane_number = self.next_public_pane_number; + let public_pane_id = public_pane_id_for_number(&self.id, pane_number); let events = self .active_tab() .map(|tab| tab.events.clone()) @@ -320,6 +383,7 @@ impl Workspace { events, render_notify, render_dirty, + Some(&public_pane_id), )? } else { Tab::new( @@ -333,9 +397,10 @@ impl Workspace { events, render_notify, render_dirty, + Some(&public_pane_id), )? }; - self.register_new_pane(tab.root_pane); + self.register_new_pane_with_number(tab.root_pane, pane_number); self.tabs.push(tab); Ok((self.tabs.len() - 1, terminal, runtime)) } @@ -348,7 +413,6 @@ impl Workspace { 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 { @@ -376,7 +440,6 @@ impl Workspace { 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); @@ -397,6 +460,8 @@ impl Workspace { host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, ) -> std::io::Result<crate::workspace::tab::NewPane> { + let pane_number = self.next_public_pane_number; + let public_pane_id = public_pane_id_for_number(&self.id, pane_number); let new_pane = self .active_tab_mut() .expect("workspace must always have at least one tab") @@ -408,8 +473,41 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + Some(&public_pane_id), )?; - self.register_new_pane(new_pane.pane_id); + self.register_new_pane_with_number(new_pane.pane_id, pane_number); + Ok(new_pane) + } + + #[allow(clippy::too_many_arguments)] + pub fn split_focused_command( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + command: &str, + extra_env: &[(String, String)], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result<crate::workspace::tab::NewPane> { + let pane_number = self.next_public_pane_number; + let public_pane_id = public_pane_id_for_number(&self.id, pane_number); + let new_pane = self + .active_tab_mut() + .expect("workspace must always have at least one tab") + .split_focused_command( + direction, + rows, + cols, + cwd, + command, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + Some(&public_pane_id), + )?; + self.register_new_pane_with_number(new_pane.pane_id, pane_number); Ok(new_pane) } @@ -513,6 +611,8 @@ impl Workspace { argv: Option<&[String]>, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { let tab_idx = self.find_tab_index_for_pane(pane_id)?; + let pane_number = self.next_public_pane_number; + let public_pane_id = public_pane_id_for_number(&self.id, pane_number); let tab = &mut self.tabs[tab_idx]; let previous_focus = tab.layout.focused(); tab.layout.focus_pane(pane_id); @@ -525,6 +625,7 @@ impl Workspace { argv, scrollback_limit_bytes, host_terminal_theme, + Some(&public_pane_id), ) } else { match ratio { @@ -537,6 +638,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + Some(&public_pane_id), ), None => tab.split_focused( direction, @@ -546,6 +648,7 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, shell_config, + Some(&public_pane_id), ), } } { @@ -558,7 +661,7 @@ impl Workspace { if !focus_new_pane { tab.layout.focus_pane(previous_focus); } - self.register_new_pane(new_pane.pane_id); + self.register_new_pane_with_number(new_pane.pane_id, pane_number); Some(Ok((tab_idx, new_pane))) } @@ -593,7 +696,6 @@ impl Workspace { } 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 { @@ -612,6 +714,16 @@ impl Workspace { self.public_pane_numbers.get(&pane_id).copied() } + pub fn public_tab_number(&self, tab_idx: usize) -> Option<usize> { + self.tabs.get(tab_idx).map(|tab| tab.number) + } + + #[cfg(test)] + pub fn public_tab_number_for_pane(&self, pane_id: PaneId) -> Option<usize> { + let tab_idx = self.find_tab_index_for_pane(pane_id)?; + self.public_tab_number(tab_idx) + } + pub fn set_custom_name(&mut self, name: String) { self.custom_name = Some(name); } @@ -717,7 +829,6 @@ impl Workspace { } 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 { @@ -732,27 +843,18 @@ impl Workspace { false } + #[cfg(test)] 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; + self.register_new_pane_with_number(pane_id, self.next_public_pane_number); } - 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 register_new_pane_with_number(&mut self, pane_id: PaneId, number: usize) { + self.public_pane_numbers.insert(pane_id, number); + self.next_public_pane_number = self.next_public_pane_number.max(number + 1); } - fn renumber_tabs(&mut self) { - for (idx, tab) in self.tabs.iter_mut().enumerate() { - tab.number = idx + 1; - } + fn unregister_pane(&mut self, pane_id: PaneId) { + self.public_pane_numbers.remove(&pane_id); } fn close_active_tab_and_report(&mut self) -> bool { @@ -799,6 +901,7 @@ impl Workspace { worktree_space: None, public_pane_numbers, next_public_pane_number: 2, + next_public_tab_number: 2, tabs: vec![tab], active_tab: 0, test_runtimes: HashMap::new(), @@ -827,7 +930,7 @@ impl Workspace { panes.insert(root_id, PaneState::new(TerminalId::alloc())); let tab = Tab { custom_name: name.map(str::to_string), - number: self.tabs.len() + 1, + number: self.next_public_tab_number, root_pane: root_id, layout, panes, @@ -837,6 +940,7 @@ impl Workspace { render_notify, render_dirty, }; + self.next_public_tab_number += 1; self.register_new_pane(root_id); self.tabs.push(tab); self.tabs.len() - 1 @@ -848,6 +952,92 @@ mod tests { use super::*; #[test] + fn generated_workspace_ids_are_short_base32_handles() { + let first = generate_workspace_id(); + let second = generate_workspace_id(); + + assert!(first.starts_with('w')); + assert!(second.starts_with('w')); + assert_ne!(first, second); + assert!(first.len() <= 3, "unexpectedly long workspace id: {first}"); + assert!( + second.len() <= 3, + "unexpectedly long workspace id: {second}" + ); + } + + #[test] + fn public_numbers_round_trip_readable_base32_handles() { + assert_eq!(encode_public_number(1), "1"); + assert_eq!(encode_public_number(9), "9"); + assert_eq!(encode_public_number(10), "A"); + assert_eq!(encode_public_number(31), "Z"); + assert_eq!(encode_public_number(32), "0"); + assert_eq!(encode_public_number(33), "11"); + + for value in [1, 9, 10, 31, 32, 33, 1024, 1025] { + let encoded = encode_public_number(value); + assert_eq!(decode_public_number(&encoded), Some(value)); + } + } + + #[test] + fn reserving_restored_workspace_ids_prevents_reuse() { + let mut restored = Workspace::test_new("restored"); + restored.id = "wZ".to_string(); + + reserve_workspace_ids(&[restored]); + + let generated = generate_workspace_id(); + assert_ne!(generated, "wZ"); + assert!(public_workspace_number(&generated) > public_workspace_number("wZ")); + } + + #[test] + fn pane_public_numbers_are_stable_and_not_reused_after_close() { + let mut ws = Workspace::test_new("test"); + let root = ws.tabs[0].root_pane; + let second = ws.test_split(Direction::Horizontal); + let third = ws.test_split(Direction::Vertical); + + assert_eq!(ws.public_pane_number(root), Some(1)); + assert_eq!(ws.public_pane_number(second), Some(2)); + assert_eq!(ws.public_pane_number(third), Some(3)); + + assert!(!ws.close_pane(second)); + + assert_eq!(ws.public_pane_number(root), Some(1)); + assert_eq!(ws.public_pane_number(second), None); + assert_eq!(ws.public_pane_number(third), Some(3)); + + let fourth = ws.test_split(Direction::Horizontal); + assert_eq!(ws.public_pane_number(fourth), Some(4)); + } + + #[test] + fn tab_public_numbers_are_stable_and_not_reused_after_close() { + let mut ws = Workspace::test_new("test"); + let first_root = ws.tabs[0].root_pane; + let second_tab = ws.test_add_tab(None); + let second_root = ws.tabs[second_tab].root_pane; + let third_tab = ws.test_add_tab(None); + let third_root = ws.tabs[third_tab].root_pane; + + assert_eq!(ws.public_tab_number_for_pane(first_root), Some(1)); + assert_eq!(ws.public_tab_number_for_pane(second_root), Some(2)); + assert_eq!(ws.public_tab_number_for_pane(third_root), Some(3)); + + assert!(ws.close_tab(second_tab)); + + assert_eq!(ws.public_tab_number_for_pane(first_root), Some(1)); + assert_eq!(ws.public_tab_number_for_pane(third_root), Some(3)); + + let fourth_tab = ws.test_add_tab(None); + let fourth_root = ws.tabs[fourth_tab].root_pane; + assert_eq!(ws.public_tab_number_for_pane(fourth_root), Some(4)); + } + + #[test] fn workspace_identity_follows_first_tab_root_pane_cwd() { let mut ws = Workspace::test_new("ignored"); ws.custom_name = None; @@ -868,7 +1058,7 @@ mod tests { } #[test] - fn moving_tab_keeps_active_identity_and_renumbers_auto_tabs() { + fn moving_tab_keeps_active_identity_and_stable_tab_numbers() { let mut ws = Workspace::test_new("test"); let moved_root = ws.tabs[0].root_pane; ws.test_add_tab(Some("foo")); @@ -879,10 +1069,13 @@ mod tests { 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!(labels, vec!["foo", "3", "1"]); 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[0].number, 2); + assert_eq!(ws.tabs[1].number, 3); + assert_eq!(ws.tabs[2].number, 1); assert_eq!(ws.tabs[2].root_pane, moved_root); assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root); } |
