This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-197
@@ -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<Self, String> {
|
||||
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<PaneNode>,
|
||||
second: Box<PaneNode>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SplitAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl PaneNode {
|
||||
pub fn leaf(profile_id: impl Into<String>) -> Self {
|
||||
Self::Leaf {
|
||||
profile_id: profile_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split(
|
||||
self,
|
||||
axis: SplitAxis,
|
||||
other: PaneNode,
|
||||
ratio_percent: u8,
|
||||
) -> Result<Self, String> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Workspace {
|
||||
pub profiles: Vec<Profile>,
|
||||
pub tabs: Vec<Tab>,
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<PaneNode>,
|
||||
second: Box<PaneNode>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SplitAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl PaneNode {
|
||||
pub fn leaf(profile_id: impl Into<String>) -> Self {
|
||||
Self::Leaf {
|
||||
profile_id: profile_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split(
|
||||
self,
|
||||
axis: SplitAxis,
|
||||
other: PaneNode,
|
||||
ratio_percent: u8,
|
||||
) -> Result<Self, String> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Workspace {
|
||||
pub profiles: Vec<Profile>,
|
||||
pub tabs: Vec<Tab>,
|
||||
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<u8>,
|
||||
) -> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user