diff --git a/README.md b/README.md index ca8d541..cc4a41c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Keep the same broad product shape as Tabby—terminal tabs, nested split panes, ## Current implementation -- `rabby-core`: dependency-light Rust domain model for profiles, tabs, split panes, session restore primitives, and feature coverage tests. +- `rabby-core`: dependency-light Rust domain model for profiles, tabs, split panes, session restore primitives, and Tabby feature coverage tests. +- `rabby-core/src/feature`: structured Tabby-vs-Rabby feature catalog with honest status (`Modeled`, `UiPrototype`, `Planned`). +- `rabby-core/src/workspace`: refactored workspace/profile/pane domain state. - `src-tauri`: Tauri v2 desktop backend exposing the Rust feature matrix to the UI. - `ui`: static responsive UI mock/prototype matching Tabby-style terminal UX without a JavaScript build chain. - `docs/rabby-screenshot.svg`: screenshot artifact of the UI/UX direction. @@ -16,21 +18,38 @@ Keep the same broad product shape as Tabby—terminal tabs, nested split panes, ## Verification ```bash +cargo fmt --all -- --check cargo test -p rabby-core +cargo check -p rabby +cargo build --release -p rabby ``` The core crate is intentionally testable without launching the desktop shell. -## Tabby feature mapping +## Tabby feature comparison -| Tabby feature | Rabby implementation direction | -| --- | --- | -| Terminal emulator | Rust core models terminal sessions; Tauri UI presents panes/tabs. | -| SSH/Telnet/Serial | Typed connection profiles with validation and command boundaries. | -| Split panes/tabs | Recursive pane tree with tested split bounds and leaf counting. | -| Theming/shortcuts | Static UI theme now; config surface reserved in core. | -| Session restore | Workspace struct stores profiles, tabs, active tab, and quake mode. | -| Smaller footprint | No Electron/Angular runtime in this scaffold; static assets + Tauri. | +This table is based on Tabby's README and top-level modules (`tabby-terminal`, `tabby-local`, `tabby-ssh`, `tabby-telnet`, `tabby-serial`, `tabby-plugin-manager`, `tabby-settings`, `tabby-web`, etc.). The scaffold does **not** claim every production implementation is finished; it verifies that each class is represented in the Rust catalog and tests. + +| Tabby feature class | Rabby coverage now | Status | +| --- | --- | --- | +| VT terminal emulation | Terminal session/pane surface requirement in Rust catalog | Planned | +| Nested split panes | Recursive `PaneNode` with bounds and leaf-count tests | Modeled | +| Remembered tabs | `Workspace` stores tabs and active tab | Modeled | +| Quake console/global hotkey | `quake_mode` state plus UI control | Modeled | +| Progress/process notifications | `Tab.progress` state with validation | Modeled | +| Bracketed/multiline paste, RMB paste, copy-on-select | Terminal preference requirement in catalog | Planned | +| Unicode/double-width/ligatures | Renderer requirement in catalog | Planned | +| Local shell profiles | Typed `LocalShell` profile command | Modeled | +| SSH connection manager | Typed `Ssh` profile command boundary | Modeled | +| SSH forwarding/jump hosts/agent forwarding/login scripts | Advanced SSH backlog entry in catalog | Planned | +| Telnet | Typed `Telnet` profile | Modeled | +| Serial terminal | Typed `Serial` profile plus serial option backlog | Modeled | +| Zmodem/SFTP transfers | Transfer surface reserved for SSH sessions | Planned | +| Encrypted SSH secrets/config | Tauri/Rust secret-store boundary in catalog | Planned | +| Themes/color schemes | `Profile.color_scheme` plus UI theme prototype | Modeled | +| Configurable multi-chord shortcuts | Command palette/shortcut UX prototype entry | UI prototype | +| Plugin manager/ecosystem | Extension boundary reserved outside core | Planned | +| Portable/web surfaces | Static UI + Tauri shell; web surface tracked as platform backlog | UI prototype | ## Screenshot diff --git a/rabby-core/src/feature/mod.rs b/rabby-core/src/feature/mod.rs new file mode 100644 index 0000000..0faf727 --- /dev/null +++ b/rabby-core/src/feature/mod.rs @@ -0,0 +1,265 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FeatureStatus { + Modeled, + UiPrototype, + Planned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FeatureArea { + Terminal, + Connection, + UiUx, + Transfer, + Security, + Extensibility, + Platform, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FeatureSpec { + pub key: &'static str, + pub title: &'static str, + pub area: FeatureArea, + pub tabby_reference: &'static str, + pub rabby_design: &'static str, + pub status: FeatureStatus, +} + +pub const TABBY_FEATURES: &[FeatureSpec] = &[ + FeatureSpec { + key: "vt-terminal", + title: "VT terminal emulation", + area: FeatureArea::Terminal, + tabby_reference: "VT220 terminal + extensions", + rabby_design: "Terminal session model plus Tauri pane surface", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "nested-splits", + title: "Nested split panes", + area: FeatureArea::Terminal, + tabby_reference: "Multiple nested split panes", + rabby_design: "Recursive PaneNode tree with ratio bounds", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "tabs-any-side", + title: "Remembered tabs", + area: FeatureArea::Terminal, + tabby_reference: "Tabs on any side and remembered tabs", + rabby_design: "Workspace stores tabs and active tab", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "quake-hotkey", + title: "Quake console", + area: FeatureArea::UiUx, + tabby_reference: "Dockable window with global spawn hotkey", + rabby_design: "Workspace quake_mode flag and UI control", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "progress-notifications", + title: "Progress and process notifications", + area: FeatureArea::UiUx, + tabby_reference: "Progress detection and notification on process completion", + rabby_design: "Tab progress field and future notification boundary", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "paste-safety", + title: "Paste ergonomics", + area: FeatureArea::Terminal, + tabby_reference: "Bracketed paste, multiline paste warnings, RMB paste, copy-on-select", + rabby_design: "Terminal preference catalog entry", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "font-ligatures-unicode", + title: "Unicode and font ligatures", + area: FeatureArea::Terminal, + tabby_reference: "Full Unicode including double-width characters and font ligatures", + rabby_design: "Renderer requirement in feature catalog", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "local-shell-profiles", + title: "Local shell profiles", + area: FeatureArea::Connection, + tabby_reference: "Custom shell profiles and PowerShell/WSL/Git-Bash/Cygwin/MSYS2/Cmder/CMD support", + rabby_design: "Typed LocalShell profile command", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "ssh-client", + title: "SSH client and connection manager", + area: FeatureArea::Connection, + tabby_reference: "SSH2 client with connection manager", + rabby_design: "Typed SSH profile and command boundary", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "ssh-forwarding", + title: "SSH forwarding and jump hosts", + area: FeatureArea::Connection, + tabby_reference: "X11/port forwarding, automatic jump host management, agent forwarding, login scripts", + rabby_design: "Advanced SSH options reserved in catalog", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "telnet-client", + title: "Telnet client", + area: FeatureArea::Connection, + tabby_reference: "Integrated Telnet client", + rabby_design: "Typed Telnet profile", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "serial-terminal", + title: "Serial terminal", + area: FeatureArea::Connection, + tabby_reference: "Saved serial connections, readline input, hex/hexdump, newline conversion, auto reconnect", + rabby_design: "Typed Serial profile plus serial option backlog", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "zmodem-sftp", + title: "File transfer", + area: FeatureArea::Transfer, + tabby_reference: "Zmodem transfer plus SFTP/web SFTP plugins", + rabby_design: "Zmodem/SFTP transfer surface reserved for SSH sessions", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "encrypted-secrets", + title: "Encrypted secret container", + area: FeatureArea::Security, + tabby_reference: "Integrated encrypted container for SSH secrets and configuration", + rabby_design: "Tauri/Rust secret-store boundary", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "themes-color-schemes", + title: "Themes and color schemes", + area: FeatureArea::UiUx, + tabby_reference: "Theming and installable color schemes", + rabby_design: "Profile color_scheme and UI theme prototype", + status: FeatureStatus::Modeled, + }, + FeatureSpec { + key: "shortcuts", + title: "Configurable shortcuts", + area: FeatureArea::UiUx, + tabby_reference: "Fully configurable shortcuts and multi-chord shortcuts", + rabby_design: "Shortcut feature catalog and command palette UI", + status: FeatureStatus::UiPrototype, + }, + FeatureSpec { + key: "plugins", + title: "Plugins and themes installable from settings", + area: FeatureArea::Extensibility, + tabby_reference: "Plugin manager and plugin ecosystem", + rabby_design: "Extension boundary reserved outside core runtime", + status: FeatureStatus::Planned, + }, + FeatureSpec { + key: "portable-web", + title: "Portable and web surfaces", + area: FeatureArea::Platform, + tabby_reference: "Portable app mode and SSH/SFTP/Telnet web app", + rabby_design: "Static UI + Tauri shell; web surface tracked as platform backlog", + status: FeatureStatus::UiPrototype, + }, +]; + +pub fn all_features() -> &'static [FeatureSpec] { + TABBY_FEATURES +} + +pub fn feature_by_key(key: &str) -> Option<&'static FeatureSpec> { + TABBY_FEATURES.iter().find(|feature| feature.key == key) +} + +pub fn feature_matrix() -> Vec<(&'static str, &'static str)> { + TABBY_FEATURES + .iter() + .map(|feature| (feature.title, feature.rabby_design)) + .collect() +} + +pub fn missing_required_features<'a>(required_keys: &'a [&'a str]) -> Vec<&'a str> { + required_keys + .iter() + .copied() + .filter(|key| feature_by_key(key).is_none()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + const TABBY_README_REQUIREMENTS: &[&str] = &[ + "vt-terminal", + "nested-splits", + "tabs-any-side", + "quake-hotkey", + "progress-notifications", + "paste-safety", + "font-ligatures-unicode", + "local-shell-profiles", + "ssh-client", + "ssh-forwarding", + "telnet-client", + "serial-terminal", + "zmodem-sftp", + "encrypted-secrets", + "themes-color-schemes", + "shortcuts", + "plugins", + "portable-web", + ]; + + #[test] + fn catalog_covers_tabby_readme_feature_classes() { + assert_eq!( + missing_required_features(TABBY_README_REQUIREMENTS), + Vec::<&str>::new() + ); + } + + #[test] + fn catalog_keys_are_unique() { + let mut seen = HashSet::new(); + for feature in TABBY_FEATURES { + assert!( + seen.insert(feature.key), + "duplicate feature key {}", + feature.key + ); + } + } + + #[test] + fn catalog_has_no_empty_user_facing_fields() { + for feature in TABBY_FEATURES { + assert!(!feature.title.trim().is_empty()); + assert!(!feature.tabby_reference.trim().is_empty()); + assert!(!feature.rabby_design.trim().is_empty()); + } + } + + #[test] + fn catalog_is_honest_about_planned_work() { + let planned = TABBY_FEATURES + .iter() + .filter(|feature| feature.status == FeatureStatus::Planned) + .count(); + assert!( + planned >= 5, + "scaffold should not imply every Tabby feature is complete" + ); + } +} diff --git a/rabby-core/src/lib.rs b/rabby-core/src/lib.rs index 3f12ce3..7b02599 100644 --- a/rabby-core/src/lib.rs +++ b/rabby-core/src/lib.rs @@ -3,169 +3,17 @@ //! This crate is intentionally dependency-light so terminal/profile/session behavior can be //! unit-tested without starting the desktop shell. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConnectionKind { - LocalShell, - Ssh, - Telnet, - Serial, -} +pub mod feature; +pub mod workspace; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Profile { - pub id: String, - pub name: String, - pub kind: ConnectionKind, - pub command: String, - pub color_scheme: String, -} - -impl Profile { - pub fn new(id: &str, name: &str, kind: ConnectionKind, command: &str) -> Result { - if id.trim().is_empty() { - return Err("profile id is required".into()); - } - if name.trim().is_empty() { - return Err("profile name is required".into()); - } - if command.trim().is_empty() { - return Err("profile command is required".into()); - } - Ok(Self { - id: slug(id), - name: name.trim().to_string(), - kind, - command: command.trim().to_string(), - color_scheme: "Rabby Dark".to_string(), - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaneNode { - Leaf { - profile_id: String, - }, - Split { - axis: SplitAxis, - ratio_percent: u8, - first: Box, - second: Box, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SplitAxis { - Horizontal, - Vertical, -} - -impl PaneNode { - pub fn leaf(profile_id: impl Into) -> Self { - Self::Leaf { - profile_id: profile_id.into(), - } - } - - pub fn split( - self, - axis: SplitAxis, - other: PaneNode, - ratio_percent: u8, - ) -> Result { - if !(10..=90).contains(&ratio_percent) { - return Err("split ratio must be between 10 and 90 percent".into()); - } - Ok(Self::Split { - axis, - ratio_percent, - first: Box::new(self), - second: Box::new(other), - }) - } - - pub fn leaf_count(&self) -> usize { - match self { - PaneNode::Leaf { .. } => 1, - PaneNode::Split { first, second, .. } => first.leaf_count() + second.leaf_count(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Tab { - pub title: String, - pub root: PaneNode, - pub progress: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Workspace { - pub profiles: Vec, - pub tabs: Vec, - pub active_tab: usize, - pub quake_mode: bool, -} +pub use feature::{ + all_features, feature_by_key, feature_matrix, FeatureArea, FeatureSpec, FeatureStatus, +}; +pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace}; impl Workspace { - pub fn default_linux() -> Self { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); - let profile = Profile::new("local", "Local Shell", ConnectionKind::LocalShell, &shell) - .expect("static default profile is valid"); - Self { - profiles: vec![profile.clone()], - tabs: vec![Tab { - title: profile.name.clone(), - root: PaneNode::leaf(profile.id), - progress: None, - }], - active_tab: 0, - quake_mode: false, - } - } - - pub fn add_tab(&mut self, profile_id: &str) -> Result<(), String> { - let profile = self - .profiles - .iter() - .find(|p| p.id == profile_id) - .ok_or_else(|| format!("unknown profile: {profile_id}"))?; - self.tabs.push(Tab { - title: profile.name.clone(), - root: PaneNode::leaf(profile.id.clone()), - progress: None, - }); - self.active_tab = self.tabs.len() - 1; - Ok(()) - } - pub fn feature_matrix() -> Vec<(&'static str, &'static str)> { - vec![ - ( - "Terminal", - "VT-style terminal surface, tabs, nested split panes, Unicode-first rendering", - ), - ( - "Connections", - "Local shell, SSH, Telnet, and Serial profile model", - ), - ( - "UX", - "Command palette, configurable shortcuts, quake-mode window, remembered sessions", - ), - ( - "Transfers", - "Reserved Zmodem/SFTP transfer surface for SSH sessions", - ), - ( - "Security", - "Encrypted secret-store boundary in the Tauri/Rust backend", - ), - ( - "Footprint", - "Rust core + Tauri shell instead of Electron/Angular runtime", - ), - ] + feature_matrix() } } @@ -194,43 +42,7 @@ mod tests { } #[test] - fn profile_validation_rejects_empty_command() { - let err = Profile::new("p", "Prod", ConnectionKind::Ssh, " ").unwrap_err(); - assert!(err.contains("command")); - } - - #[test] - fn split_panes_count_nested_leaves() { - let root = PaneNode::leaf("local") - .split(SplitAxis::Horizontal, PaneNode::leaf("ssh"), 50) - .unwrap() - .split(SplitAxis::Vertical, PaneNode::leaf("serial"), 65) - .unwrap(); - assert_eq!(root.leaf_count(), 3); - } - - #[test] - fn split_ratio_has_sane_bounds() { - assert!(PaneNode::leaf("a") - .split(SplitAxis::Horizontal, PaneNode::leaf("b"), 5) - .is_err()); - assert!(PaneNode::leaf("a") - .split(SplitAxis::Horizontal, PaneNode::leaf("b"), 50) - .is_ok()); - } - - #[test] - fn workspace_adds_tabs_for_known_profiles() { - let mut ws = Workspace::default_linux(); - ws.profiles - .push(Profile::new("ssh-prod", "Prod SSH", ConnectionKind::Ssh, "ssh prod").unwrap()); - ws.add_tab("ssh-prod").unwrap(); - assert_eq!(ws.active_tab, 1); - assert_eq!(ws.tabs[1].title, "Prod SSH"); - } - - #[test] - fn feature_matrix_covers_tabby_readme_capabilities() { + fn exported_feature_matrix_mentions_tabby_capabilities() { let text = Workspace::feature_matrix() .iter() .map(|(a, b)| format!("{a} {b}")) @@ -238,7 +50,8 @@ mod tests { .join("\n") .to_lowercase(); for needle in [ - "terminal", "ssh", "telnet", "serial", "split", "unicode", "quake", "zmodem", + "terminal", "ssh", "telnet", "serial", "split", "unicode", "quake", "zmodem", "plugin", + "portable", "shortcut", "theme", "sftp", "secret", ] { assert!(text.contains(needle), "missing {needle}"); } diff --git a/rabby-core/src/workspace/mod.rs b/rabby-core/src/workspace/mod.rs new file mode 100644 index 0000000..bdb7201 --- /dev/null +++ b/rabby-core/src/workspace/mod.rs @@ -0,0 +1,242 @@ +use crate::slug; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionKind { + LocalShell, + Ssh, + Telnet, + Serial, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub id: String, + pub name: String, + pub kind: ConnectionKind, + pub command: String, + pub color_scheme: String, +} + +impl Profile { + pub fn new(id: &str, name: &str, kind: ConnectionKind, command: &str) -> Result { + if id.trim().is_empty() { + return Err("profile id is required".into()); + } + if name.trim().is_empty() { + return Err("profile name is required".into()); + } + if command.trim().is_empty() { + return Err("profile command is required".into()); + } + Ok(Self { + id: slug(id), + name: name.trim().to_string(), + kind, + command: command.trim().to_string(), + color_scheme: "Rabby Dark".to_string(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaneNode { + Leaf { + profile_id: String, + }, + Split { + axis: SplitAxis, + ratio_percent: u8, + first: Box, + second: Box, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitAxis { + Horizontal, + Vertical, +} + +impl PaneNode { + pub fn leaf(profile_id: impl Into) -> Self { + Self::Leaf { + profile_id: profile_id.into(), + } + } + + pub fn split( + self, + axis: SplitAxis, + other: PaneNode, + ratio_percent: u8, + ) -> Result { + if !(10..=90).contains(&ratio_percent) { + return Err("split ratio must be between 10 and 90 percent".into()); + } + Ok(Self::Split { + axis, + ratio_percent, + first: Box::new(self), + second: Box::new(other), + }) + } + + pub fn leaf_count(&self) -> usize { + match self { + PaneNode::Leaf { .. } => 1, + PaneNode::Split { first, second, .. } => first.leaf_count() + second.leaf_count(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tab { + pub title: String, + pub root: PaneNode, + pub progress: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Workspace { + pub profiles: Vec, + pub tabs: Vec, + pub active_tab: usize, + pub quake_mode: bool, +} + +impl Workspace { + pub fn default_linux() -> Self { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let profile = Profile::new("local", "Local Shell", ConnectionKind::LocalShell, &shell) + .expect("static default profile is valid"); + Self { + profiles: vec![profile.clone()], + tabs: vec![Tab { + title: profile.name.clone(), + root: PaneNode::leaf(profile.id), + progress: None, + }], + active_tab: 0, + quake_mode: false, + } + } + + pub fn add_profile(&mut self, profile: Profile) -> Result<(), String> { + if self + .profiles + .iter() + .any(|existing| existing.id == profile.id) + { + return Err(format!("duplicate profile: {}", profile.id)); + } + self.profiles.push(profile); + Ok(()) + } + + pub fn add_tab(&mut self, profile_id: &str) -> Result<(), String> { + let profile = self + .profiles + .iter() + .find(|p| p.id == profile_id) + .ok_or_else(|| format!("unknown profile: {profile_id}"))?; + self.tabs.push(Tab { + title: profile.name.clone(), + root: PaneNode::leaf(profile.id.clone()), + progress: None, + }); + self.active_tab = self.tabs.len() - 1; + Ok(()) + } + + pub fn set_tab_progress( + &mut self, + tab_index: usize, + progress: Option, + ) -> Result<(), String> { + if let Some(value) = progress { + if value > 100 { + return Err("progress must be between 0 and 100".into()); + } + } + let tab = self + .tabs + .get_mut(tab_index) + .ok_or_else(|| format!("unknown tab index: {tab_index}"))?; + tab.progress = progress; + Ok(()) + } + + pub fn set_quake_mode(&mut self, enabled: bool) { + self.quake_mode = enabled; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_validation_rejects_empty_command() { + let err = Profile::new("p", "Prod", ConnectionKind::Ssh, " ").unwrap_err(); + assert!(err.contains("command")); + } + + #[test] + fn split_panes_count_nested_leaves() { + let root = PaneNode::leaf("local") + .split(SplitAxis::Horizontal, PaneNode::leaf("ssh"), 50) + .unwrap() + .split(SplitAxis::Vertical, PaneNode::leaf("serial"), 65) + .unwrap(); + assert_eq!(root.leaf_count(), 3); + } + + #[test] + fn split_ratio_has_sane_bounds() { + assert!(PaneNode::leaf("a") + .split(SplitAxis::Horizontal, PaneNode::leaf("b"), 5) + .is_err()); + assert!(PaneNode::leaf("a") + .split(SplitAxis::Horizontal, PaneNode::leaf("b"), 50) + .is_ok()); + } + + #[test] + fn workspace_adds_tabs_for_known_profiles() { + let mut ws = Workspace::default_linux(); + ws.add_profile( + Profile::new("ssh-prod", "Prod SSH", ConnectionKind::Ssh, "ssh prod").unwrap(), + ) + .unwrap(); + ws.add_tab("ssh-prod").unwrap(); + assert_eq!(ws.active_tab, 1); + assert_eq!(ws.tabs[1].title, "Prod SSH"); + } + + #[test] + fn workspace_rejects_duplicate_profiles() { + let mut ws = Workspace::default_linux(); + let err = ws + .add_profile( + Profile::new( + "local", + "Another Local", + ConnectionKind::LocalShell, + "/bin/sh", + ) + .unwrap(), + ) + .unwrap_err(); + assert!(err.contains("duplicate")); + } + + #[test] + fn workspace_tracks_quake_mode_and_progress() { + let mut ws = Workspace::default_linux(); + ws.set_quake_mode(true); + ws.set_tab_progress(0, Some(42)).unwrap(); + assert!(ws.quake_mode); + assert_eq!(ws.tabs[0].progress, Some(42)); + assert!(ws.set_tab_progress(0, Some(101)).is_err()); + } +}