diff --git a/crates/spec-forest-tui/src/action.rs b/crates/spec-forest-tui/src/action.rs index a8f0141..54ef21f 100644 --- a/crates/spec-forest-tui/src/action.rs +++ b/crates/spec-forest-tui/src/action.rs @@ -166,6 +166,17 @@ pub enum Action { SessionPickerDismiss, DismissNotification, + // Members screen + OpenMembers, + MembersActivateInput, + MembersDeactivateInput, + MembersRemoveMember, + MembersInputChar(char), + MembersInputBackspace, + MembersInputSubmit, + MembersUp, + MembersDown, + // Help ToggleHelp, diff --git a/crates/spec-forest-tui/src/app.rs b/crates/spec-forest-tui/src/app.rs index 7e5bbbb..2580f83 100644 --- a/crates/spec-forest-tui/src/app.rs +++ b/crates/spec-forest-tui/src/app.rs @@ -99,6 +99,12 @@ pub struct App { pub sim_notifications: Vec, pub session_picker: Option, pub show_help: bool, + // Members screen + pub members: Vec, + pub members_creator: String, + pub members_selected: usize, + pub members_input: String, + pub members_input_active: bool, } #[derive(Clone)] @@ -110,6 +116,7 @@ pub enum Screen { SpecOptionsPicker, SpecView { spec_id: String }, SpecSettings { spec_id: String }, + SpecMembers { spec_id: String }, SyncConfig, SyncPasswordInput, ModelConfig, @@ -201,6 +208,11 @@ impl App { sim_notifications: Vec::new(), session_picker: None, show_help: false, + members: Vec::new(), + members_creator: String::new(), + members_selected: 0, + members_input: String::new(), + members_input_active: false, } } @@ -318,13 +330,22 @@ impl App { && self .sim_state .as_ref() - .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)); + .map_or(false, |s| s.mode == crate::simulation::SimInputMode::Insert)) + || (matches!(self.screen, Screen::SpecMembers { .. }) && self.members_input_active); if !is_text_input { self.show_help = true; return; } } + // Members screen has dual-mode input (normal vs text) + if matches!(self.screen, Screen::SpecMembers { .. }) { + let is_creator = self.state.user_name() == self.members_creator; + let action = input::map_spec_members_key(key, self.members_input_active, is_creator); + self.execute_action(action).await; + return; + } + // Scenario input screen needs modifiers for Shift+Enter if matches!(self.screen, Screen::SimScenario { .. }) { let action = input::map_sim_scenario_key(key, modifiers); @@ -669,6 +690,101 @@ impl App { } } + // Members + Action::OpenMembers => { + let spec_id = match &self.screen { + Screen::SpecView { spec_id } | Screen::SpecSettings { spec_id } => spec_id.clone(), + _ => return, + }; + let spec_name = self.specs.iter() + .find(|s| s.id == spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::list_members(&self.state, &spec_name).await { + Ok((members, creator)) => { + self.members = members; + self.members_creator = creator; + self.members_selected = 0; + self.members_input.clear(); + self.members_input_active = false; + self.screen = Screen::SpecMembers { spec_id }; + } + Err(e) => { + self.message = Some(format!("Failed to load members: {e}")); + } + } + } + Action::MembersUp => { + self.members_selected = self.members_selected.saturating_sub(1); + } + Action::MembersDown => { + if !self.members.is_empty() { + self.members_selected = (self.members_selected + 1).min(self.members.len() - 1); + } + } + Action::MembersActivateInput => { + self.members_input_active = true; + self.members_input.clear(); + } + Action::MembersDeactivateInput => { + self.members_input_active = false; + self.members_input.clear(); + } + Action::MembersInputChar(c) => { + self.members_input.push(c); + } + Action::MembersInputBackspace => { + self.members_input.pop(); + } + Action::MembersInputSubmit => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + let username = self.members_input.trim().to_string(); + if !username.is_empty() { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::grant_access(&self.state, &spec_name, &username).await { + Ok(_) => { + self.members.push(username); + self.members_input.clear(); + self.members_input_active = false; + self.message = Some("Member added".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to add member: {e}")); + } + } + } + } + } + Action::MembersRemoveMember => { + if let Screen::SpecMembers { ref spec_id } = self.screen { + if let Some(member) = self.members.get(self.members_selected).cloned() { + if member == self.members_creator { + self.message = Some("Cannot remove the creator".to_string()); + } else { + let spec_name = self.specs.iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + match commands::revoke_access(&self.state, &spec_name, &member).await { + Ok(_) => { + self.members.remove(self.members_selected); + if self.members_selected >= self.members.len() && self.members_selected > 0 { + self.members_selected -= 1; + } + self.message = Some("Member removed".to_string()); + } + Err(e) => { + self.message = Some(format!("Failed to remove member: {e}")); + } + } + } + } + } + } + // Candidates Action::CandidateNext => { if !self.candidates.is_empty() { @@ -1470,6 +1586,12 @@ impl App { let spec_id = spec_id.clone(); self.screen = Screen::SpecView { spec_id }; } + Screen::SpecMembers { spec_id } => { + let spec_id = spec_id.clone(); + self.members_input_active = false; + self.members_input.clear(); + self.screen = Screen::SpecView { spec_id }; + } _ => {} } } diff --git a/crates/spec-forest-tui/src/commands.rs b/crates/spec-forest-tui/src/commands.rs index 7b7d270..5aab5a4 100644 --- a/crates/spec-forest-tui/src/commands.rs +++ b/crates/spec-forest-tui/src/commands.rs @@ -194,6 +194,37 @@ pub async fn connect_sync( .map_err(|e| TuiError::Api(e.to_string())) } +// ── Members ─────────────────────────────────────────────── + +pub async fn list_members( + state: &Arc, + spec_name: &str, +) -> Result<(Vec, String), TuiError> { + spec_forest::api::sync_list_members(state, spec_name) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn grant_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_grant_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + +pub async fn revoke_access( + state: &Arc, + spec_name: &str, + username: &str, +) -> Result<(), TuiError> { + spec_forest::api::sync_revoke_access(state, spec_name, username) + .await + .map_err(|e| TuiError::Api(e.to_string())) +} + // ── Shadow answers ──────────────────────────────────────── pub fn start_shadow( diff --git a/crates/spec-forest-tui/src/input.rs b/crates/spec-forest-tui/src/input.rs index 9b5b3c2..c316e22 100644 --- a/crates/spec-forest-tui/src/input.rs +++ b/crates/spec-forest-tui/src/input.rs @@ -31,6 +31,7 @@ pub fn map_key( Screen::UsernameInput => map_input_key(key), Screen::SimChannelPicker { .. } => map_sim_channel_picker_key(key), Screen::ExploreDepthPicker { .. } => map_depth_picker_key(key), + Screen::SpecMembers { .. } => Action::Noop, // handled by map_spec_members_key Screen::SimScenario { .. } => Action::Noop, // handled by map_sim_scenario_key Screen::Simulation { .. } => Action::Noop, // handled by map_sim_key } @@ -261,6 +262,7 @@ fn map_spec_view_key(key: KeyCode, modifiers: KeyModifiers, tree_visible: bool, KeyCode::Char('t') => Action::ToggleTree, KeyCode::Char('l') => Action::ToggleLog, KeyCode::Char('g') => Action::OpenSpecSettings, + KeyCode::Char('M') => Action::OpenMembers, KeyCode::Char('s') if modifiers.contains(KeyModifiers::ALT) => Action::RegenerateShadow, KeyCode::Up if log_focused && log_visible => Action::LogScrollLineUp, KeyCode::Down if log_focused && log_visible => Action::LogScrollLineDown, @@ -338,10 +340,32 @@ fn map_spec_settings_key(key: KeyCode) -> Action { KeyCode::Down => Action::NavigateDown, KeyCode::Enter => Action::SetSpecDirectory, KeyCode::Char('d') => Action::ClearSpecDirectory, + KeyCode::Char('m') => Action::OpenMembers, _ => Action::Noop, } } +pub fn map_spec_members_key(key: KeyCode, input_active: bool, is_creator: bool) -> Action { + if input_active { + match key { + KeyCode::Esc => Action::MembersDeactivateInput, + KeyCode::Enter => Action::MembersInputSubmit, + KeyCode::Backspace => Action::MembersInputBackspace, + KeyCode::Char(c) => Action::MembersInputChar(c), + _ => Action::Noop, + } + } else { + match key { + KeyCode::Esc => Action::Cancel, + KeyCode::Up => Action::MembersUp, + KeyCode::Down => Action::MembersDown, + KeyCode::Char('a') if is_creator => Action::MembersActivateInput, + KeyCode::Char('d') if is_creator => Action::MembersRemoveMember, + _ => Action::Noop, + } + } +} + fn map_model_config_key(key: KeyCode) -> Action { match key { KeyCode::Esc => Action::Cancel, diff --git a/crates/spec-forest-tui/src/ui.rs b/crates/spec-forest-tui/src/ui.rs index 8257a88..74b74d0 100644 --- a/crates/spec-forest-tui/src/ui.rs +++ b/crates/spec-forest-tui/src/ui.rs @@ -12,6 +12,7 @@ mod sim_channel_picker; mod sim_scenario; mod simulation; mod spec_list; +mod spec_members; mod spec_options_picker; mod spec_settings; mod spec_view; @@ -30,6 +31,7 @@ pub fn render(app: &App, frame: &mut Frame) { Screen::SpecOptionsPicker => spec_options_picker::render(app, frame), Screen::SpecView { .. } => spec_view::render(app, frame), Screen::SpecSettings { .. } => spec_settings::render(app, frame), + Screen::SpecMembers { .. } => spec_members::render(app, frame), Screen::SyncConfig => sync_config::render(app, frame), Screen::SyncPasswordInput => input_screen::render_password(app, frame), Screen::ModelConfig => model_config::render(app, frame), diff --git a/crates/spec-forest-tui/src/ui/help_popup.rs b/crates/spec-forest-tui/src/ui/help_popup.rs index ae2ee44..1ecf603 100644 --- a/crates/spec-forest-tui/src/ui/help_popup.rs +++ b/crates/spec-forest-tui/src/ui/help_popup.rs @@ -78,6 +78,7 @@ fn help_sections(app: &App) -> Vec { ("t", "Toggle tree"), ("l", "Toggle log"), ("g", "Spec settings"), + ("M", "Members"), ], }); @@ -231,9 +232,35 @@ fn help_sections(app: &App) -> Vec { bindings: vec![ ("Enter", "Change directory"), ("d", "Clear directory"), + ("m", "Members"), ("Esc", "Back"), ], }], + Screen::SpecMembers { .. } => { + if app.members_input_active { + vec![HelpSection { + title: "Add Member", + bindings: vec![ + ("Enter", "Submit"), + ("Esc", "Cancel"), + ], + }] + } else { + let is_creator = app.state.user_name() == app.members_creator; + let mut bindings = vec![ + ("Up/Down", "Navigate members"), + ]; + if is_creator { + bindings.push(("a", "Add member")); + bindings.push(("d", "Remove member")); + } + bindings.push(("Esc", "Back")); + vec![HelpSection { + title: "Members", + bindings, + }] + } + } Screen::SyncConfig => { let has_url = app.state.sync_url().is_some(); if has_url { diff --git a/crates/spec-forest-tui/src/ui/spec_members.rs b/crates/spec-forest-tui/src/ui/spec_members.rs new file mode 100644 index 0000000..b4e2282 --- /dev/null +++ b/crates/spec-forest-tui/src/ui/spec_members.rs @@ -0,0 +1,113 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +use crate::app::{App, Screen}; + +pub fn render(app: &App, frame: &mut Frame) { + let spec_id = match &app.screen { + Screen::SpecMembers { spec_id } => spec_id, + _ => return, + }; + + let spec_name = app + .specs + .iter() + .find(|s| s.id == *spec_id) + .map(|s| s.name.as_str()) + .unwrap_or("?"); + + let is_creator = app.state.user_name() == app.members_creator; + + let constraints = if app.members_input_active { + vec![ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(3), + ] + } else { + vec![Constraint::Min(3), Constraint::Length(3)] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.area()); + + // Member list + let items: Vec = app + .members + .iter() + .map(|m| { + let mut spans = vec![Span::raw(format!(" {m}"))]; + if *m == app.members_creator { + spans.push(Span::styled(" (creator)", Style::default().fg(Color::Cyan))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let empty_msg = if items.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No members", + Style::default().fg(Color::DarkGray), + )))] + } else { + items + }; + + let list = List::new(empty_msg) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Members — {spec_name} ")), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if !app.members.is_empty() { + state.select(Some(app.members_selected)); + } + frame.render_stateful_widget(list, chunks[0], &mut state); + + // Input area (when active) + if app.members_input_active { + let input_display = format!(" Add member: {}█", app.members_input); + let input_widget = Paragraph::new(input_display) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)); + frame.render_widget(input_widget, chunks[1]); + } + + // Footer + let footer_idx = if app.members_input_active { 2 } else { 1 }; + let footer_line = if let Some(ref msg) = app.message { + Line::from(msg.clone()) + } else if app.members_input_active { + super::common::render_footer_line( + &[("Enter", "Add"), ("Esc", "Cancel")], + None, + ) + } else if is_creator { + super::common::render_footer_line( + &[("a", "Add member"), ("d", "Remove"), ("Esc", "Back"), ("?", "Help")], + None, + ) + } else { + super::common::render_footer_line( + &[("Esc", "Back"), ("?", "Help")], + None, + ) + }; + let footer = Paragraph::new(footer_line).block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, chunks[footer_idx]); +}