This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "rabby-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Core feature model for Rabby, a Rust/Tauri terminal workspace inspired by Tabby.
|
||||
//!
|
||||
//! 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,
|
||||
}
|
||||
|
||||
#[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_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",
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
for ch in input.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slug_normalizes_profile_ids() {
|
||||
assert_eq!(slug(" SSH: Prod Box "), "ssh-prod-box");
|
||||
}
|
||||
|
||||
#[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() {
|
||||
let text = Workspace::feature_matrix()
|
||||
.iter()
|
||||
.map(|(a, b)| format!("{a} {b}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.to_lowercase();
|
||||
for needle in [
|
||||
"terminal", "ssh", "telnet", "serial", "split", "unicode", "quake", "zmodem",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user