use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
fn truncate_to_width(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if UnicodeWidthStr::width(text) <= width {
return text.to_string();
}
let content_width = width.saturating_sub(1);
let mut result = String::new();
let mut used = 0;
for grapheme in text.graphemes(true) {
let next = used + UnicodeWidthStr::width(grapheme);
if next > content_width {
break;
}
result.push_str(grapheme);
used = next;
}
result.push('…');
result
}
use std::path::{Path, PathBuf};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Paragraph, StatefulWidget, Widget},
};
use ratatui_cheese::{
field::ValidationKind,
input::{Input, InputState},
list::{List, ListItem, ListItemContext, ListState},
select::{Select, SelectOption, SelectState},
};
use serde::{Deserialize, Serialize};
use crate::{config::McPaths, persistence::atomic_write};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct WorkspaceStore {
#[serde(default)]
workspaces: Vec<PathBuf>,
}
fn workspaces_file(paths: &McPaths) -> PathBuf {
paths.root.join("workspaces.json")
}
fn load_workspaces(paths: &McPaths) -> Vec<PathBuf> {
std::fs::read_to_string(workspaces_file(paths))
.ok()
.and_then(|contents| serde_json::from_str::<WorkspaceStore>(&contents).ok())
.map(|store| store.workspaces)
.unwrap_or_default()
}
fn save_workspaces(paths: &McPaths, workspaces: &[PathBuf]) -> anyhow::Result<()> {
let store = WorkspaceStore {
workspaces: workspaces.to_vec(),
};
let json = serde_json::to_string_pretty(&store)?;
if let Some(parent) = workspaces_file(paths).parent() {
std::fs::create_dir_all(parent)?;
}
atomic_write(&workspaces_file(paths), json.as_bytes())?;
Ok(())
}
fn expand_path(raw: &str) -> PathBuf {
let trimmed = raw.trim();
if trimmed == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
}
if let Some(rest) = trimmed.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
PathBuf::from(trimmed)
}
fn workspace_label(path: &Path) -> String {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unnamed)")
.to_string()
}
#[derive(Debug, Clone)]
struct WorktreeEntry {
path: PathBuf,
branch: Option<String>,
is_default: bool,
}
fn detect_worktrees(path: &Path) -> Vec<WorktreeEntry> {
let inside = std::process::Command::new("git")
.args(["-C"])
.arg(path)
.args(["rev-parse", "--is-inside-work-tree"])
.output();
let Ok(output) = inside else {
return Vec::new();
};
if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "true" {
return Vec::new();
}
let list = std::process::Command::new("git")
.args(["-C"])
.arg(path)
.args(["worktree", "list", "--porcelain"])
.output();
let Ok(output) = list else { return Vec::new() };
if !output.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
for (i, block) in text.split("\n\n").enumerate() {
let mut wt_path = None;
let mut branch = None;
for line in block.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
wt_path = Some(PathBuf::from(p));
} else if let Some(b) = line.strip_prefix("branch ") {
branch = Some(b.trim_start_matches("refs/heads/").to_string());
}
}
if let Some(path) = wt_path {
entries.push(WorktreeEntry {
path,
branch,
is_default: i == 0,
});
}
}
entries
}
struct WorktreeItem {
label: String,
branch: Option<String>,
is_default: bool,
}
impl WorktreeItem {
fn new(entry: &WorktreeEntry) -> Self {
let label = entry
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unknown)")
.to_string();
Self {
label,
branch: entry.branch.clone(),
is_default: entry.is_default,
}
}
}
impl ListItem for WorktreeItem {
fn height(&self) -> u16 {
2
}
fn render(&self, area: Rect, buf: &mut Buffer, ctx: &ListItemContext) {
let accent = Color::LightBlue;
let selected_bg = Color::Rgb(35, 35, 60);
if ctx.selected {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(x, y)) {
cell.set_bg(selected_bg);
}
}
}
}
let prefix = if self.is_default { "* " } else { " " };
let label_style = if ctx.selected {
Style::default()
.fg(accent)
.add_modifier(Modifier::BOLD)
.bg(selected_bg)
} else {
Style::default().fg(Color::Gray)
};
let left = match &self.branch {
Some(b) => format!("{prefix}{} {b}", self.label),
None => format!("{prefix}{}", self.label),
};
let display = truncate_to_width(&left, area.width as usize);
buf.set_string(area.x, area.y, &display, label_style);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceView {
Empty,
AddingPath,
Selector,
Loaded,
}
pub struct WorkspaceState {
pub view: WorkspaceView,
workspaces: Vec<PathBuf>,
input_state: InputState,
select_state: SelectState,
loaded_index: Option<usize>,
paths: McPaths,
worktrees: Vec<WorktreeEntry>,
worktree_list_state: ListState,
sidebar_collapsed: bool,
sidebar_can_expand: bool,
}
impl WorkspaceState {
pub fn new(paths: McPaths) -> Self {
let workspaces = load_workspaces(&paths);
let view = if workspaces.is_empty() {
WorkspaceView::Empty
} else {
WorkspaceView::Selector
};
let select_state = SelectState::new(workspaces.len());
Self {
view,
workspaces,
input_state: InputState::new(),
select_state,
loaded_index: None,
paths,
worktrees: Vec::new(),
worktree_list_state: ListState::new(0),
sidebar_collapsed: false,
sidebar_can_expand: false,
}
}
pub fn handle_key(&mut self, code: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
match self.view {
WorkspaceView::Empty => match code {
KeyCode::Char('a') | KeyCode::Enter => {
self.start_add();
true
}
_ => false,
},
WorkspaceView::AddingPath => match code {
KeyCode::Esc => {
self.cancel_add();
true
}
KeyCode::Enter => {
self.submit_path();
true
}
KeyCode::Char(c) => {
self.input_state.insert_char(c);
true
}
KeyCode::Backspace => {
self.input_state.delete_before();
true
}
KeyCode::Delete => {
self.input_state.delete_at();
true
}
KeyCode::Left => {
self.input_state.move_left();
true
}
KeyCode::Right => {
self.input_state.move_right();
true
}
KeyCode::Home => {
self.input_state.home();
true
}
KeyCode::End => {
self.input_state.end();
true
}
_ => true,
},
WorkspaceView::Selector => match code {
KeyCode::Down | KeyCode::Char('j') => {
self.select_state.next();
true
}
KeyCode::Up | KeyCode::Char('k') => {
self.select_state.prev();
true
}
KeyCode::Enter => {
self.load_selected();
true
}
KeyCode::Char('a') => {
self.start_add();
true
}
_ => false,
},
WorkspaceView::Loaded => match code {
KeyCode::Esc => {
self.loaded_index = None;
self.view = if self.workspaces.is_empty() {
WorkspaceView::Empty
} else {
WorkspaceView::Selector
};
true
}
KeyCode::Backspace => {
self.loaded_index = None;
self.view = if self.workspaces.is_empty() {
WorkspaceView::Empty
} else {
WorkspaceView::Selector
};
true
}
KeyCode::Char('c') if self.sidebar_can_expand => {
self.sidebar_collapsed = !self.sidebar_collapsed;
true
}
KeyCode::Down | KeyCode::Char('j')
if !self.sidebar_collapsed && self.worktrees.len() > 1 =>
{
self.worktree_list_state
.select_next(self.worktrees.len(), false);
true
}
KeyCode::Up | KeyCode::Char('k')
if !self.sidebar_collapsed && self.worktrees.len() > 1 =>
{
self.worktree_list_state
.select_prev(self.worktrees.len(), false);
true
}
_ => false,
},
}
}
fn start_add(&mut self) {
self.input_state.set_value(String::new());
self.input_state.set_validation(None);
self.input_state.set_focused(true);
self.view = WorkspaceView::AddingPath;
}
fn cancel_add(&mut self) {
self.input_state.set_focused(false);
self.view = if self.workspaces.is_empty() {
WorkspaceView::Empty
} else {
WorkspaceView::Selector
};
}
fn submit_path(&mut self) {
let raw = self.input_state.value().to_string();
let path = expand_path(&raw);
if !path.exists() {
self.input_state.set_validation(Some((
ValidationKind::Error,
format!("Path does not exist: {}", path.display()),
)));
return;
}
if !path.is_dir() {
self.input_state.set_validation(Some((
ValidationKind::Error,
"Path is not a directory".to_string(),
)));
return;
}
let canonical = path.canonicalize().unwrap_or(path);
if !self.workspaces.contains(&canonical) {
self.workspaces.push(canonical);
let _ = save_workspaces(&self.paths, &self.workspaces);
}
let labels: Vec<String> = self.workspaces.iter().map(|p| workspace_label(p)).collect();
let opts: Vec<SelectOption> = labels
.iter()
.map(|l| SelectOption::new(l.as_str()))
.collect();
self.select_state.sync_options(&opts);
self.input_state.set_focused(false);
self.view = WorkspaceView::Selector;
}
fn load_selected(&mut self) {
if self.workspaces.is_empty() {
return;
}
let idx = self.select_state.selected();
let path = self.workspaces[idx].clone();
let worktrees = detect_worktrees(&path);
let is_repo = !worktrees.is_empty();
self.worktrees = worktrees;
self.sidebar_can_expand = is_repo;
self.sidebar_collapsed = !is_repo;
self.worktree_list_state = ListState::new(if is_repo { self.worktrees.len() } else { 0 });
self.loaded_index = Some(idx);
self.view = WorkspaceView::Loaded;
}
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
match self.view {
WorkspaceView::Empty => self.render_empty(area, buf),
WorkspaceView::AddingPath => self.render_adding_path(area, buf),
WorkspaceView::Selector => self.render_selector(area, buf),
WorkspaceView::Loaded => self.render_loaded(area, buf),
}
}
fn render_empty(&self, area: Rect, buf: &mut Buffer) {
let dim = Style::default().fg(Color::DarkGray);
let accent = Style::default().fg(Color::LightBlue);
let lines = vec![
Line::styled("No workspaces yet", accent.add_modifier(Modifier::BOLD)),
Line::styled("", dim),
Line::styled("Press 'a' or Enter to add your first workspace.", dim),
Line::styled(
"A workspace is a folder or git repository on your machine.",
dim,
),
];
let paragraph = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
let centered = center_rect(area, 60, 30);
Widget::render(¶graph, centered, buf);
}
fn render_adding_path(&mut self, area: Rect, buf: &mut Buffer) {
let input = Input::new("Add Workspace")
.description("Enter the absolute path to a folder or git repository")
.placeholder("~/Dev/my-project")
.prompt("\u{2192}");
let centered = center_rect(area, 60, 20);
StatefulWidget::render(&input, centered, buf, &mut self.input_state);
}
fn render_selector(&mut self, area: Rect, buf: &mut Buffer) {
let centered = center_rect(area, 60, 40);
let labels: Vec<String> = self.workspaces.iter().map(|p| workspace_label(p)).collect();
let opts: Vec<SelectOption> = labels
.iter()
.map(|l| SelectOption::new(l.as_str()))
.collect();
let select = Select::new("Select Workspace", &opts)
.description("Choose a workspace to load")
.cursor_indicator("\u{2192}");
StatefulWidget::render(&select, centered, buf, &mut self.select_state);
let hint = "Press 'a' to add another workspace";
let hint_y = area.bottom().saturating_sub(1);
if hint_y > area.y {
let hint_style = Style::default().fg(Color::DarkGray);
buf.set_string(area.x, hint_y, hint, hint_style);
}
}
fn render_loaded(&mut self, area: Rect, buf: &mut Buffer) {
let idx = self.loaded_index.unwrap_or(0);
let path = self.workspaces[idx].clone();
let name = workspace_label(&path);
let (sidebar_area, main_area) = if self.sidebar_collapsed {
let [_, main] =
Layout::horizontal([Constraint::Length(0), Constraint::Fill(1)]).areas(area);
(Rect::default(), main)
} else {
let [sidebar, _gap, main] = Layout::horizontal([
Constraint::Percentage(16),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
(sidebar, main)
};
if !self.sidebar_collapsed && self.sidebar_can_expand {
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(50, 50, 80)))
.title(Line::styled(
" Worktrees ",
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(sidebar_area);
Widget::render(&block, sidebar_area, buf);
self.render_sidebar(inner, buf);
}
let lines = vec![
Line::styled(
format!("Workspace: {name}"),
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
),
Line::styled(
format!("cwd: {}", path.display()),
Style::default().fg(Color::Gray),
),
Line::styled("", Style::default()),
Line::styled(
"This is where workspace content will appear.",
Style::default().fg(Color::DarkGray),
),
];
let main = Paragraph::new(lines);
Widget::render(&main, main_area, buf);
let hint = if self.sidebar_can_expand {
if self.sidebar_collapsed {
"press 'c' to expand worktrees"
} else {
"press 'c' to collapse worktrees | j/k navigate"
}
} else {
"not a git repository"
};
let hint_y = area.bottom().saturating_sub(1);
if hint_y > area.y {
buf.set_string(area.x, hint_y, hint, Style::default().fg(Color::DarkGray));
}
}
fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer) {
let dim = Style::default().fg(Color::DarkGray);
let items: Vec<WorktreeItem> = self.worktrees.iter().map(WorktreeItem::new).collect();
if items.is_empty() {
buf.set_string(area.x, area.y, "No worktrees", dim);
return;
}
let list = List::new(&items)
.selection_indicator("\u{203a}")
.item_spacing(0);
StatefulWidget::render(&list, area, buf, &mut self.worktree_list_state);
}
}
fn center_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let pop_w = area.width.saturating_mul(percent_x) / 100;
let pop_h = area.height.saturating_mul(percent_y) / 100;
let x = area.x + (area.width.saturating_sub(pop_w)) / 2;
let y = area.y + (area.height.saturating_sub(pop_h)) / 2;
Rect::new(x, y, pop_w, pop_h)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_to_width_respects_unicode_display_cells() {
for (text, width) in [
("ascii label", 5),
("日本語", 4),
("e\u{301}clair", 3),
("👩💻 developer", 4),
] {
let truncated = truncate_to_width(text, width);
assert!(UnicodeWidthStr::width(truncated.as_str()) <= width);
assert!(!truncated.contains('\u{fffd}'));
}
assert_eq!(truncate_to_width("日本語", 0), "");
assert_eq!(truncate_to_width("日本語", 1), "…");
assert_eq!(truncate_to_width("ascii", 4), "asc…");
assert_eq!(truncate_to_width("e\u{301}clair", 3), "e\u{301}c…");
assert_eq!(truncate_to_width("👩💻 developer", 3), "👩💻…");
}
}