Skip to main content
Glama
by 8b-is
spicy_tui.rs26.8 kB
// Spicy TUI Mode - "Making directory browsing cyberpunk cool!" 🌶️ // Inspired by spicy-fzf's beautiful terminal aesthetic use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use std::path::Path; use std::{ fs, io::{self, BufRead, BufReader}, path::PathBuf, time::{Duration, SystemTime}, }; use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; use crate::scanner::{FileNode, Scanner, ScannerConfig}; // Color scheme inspired by spicy-fzf const SPICY_GREEN: Color = Color::Rgb(0, 255, 0); const SPICY_DARK_GREEN: Color = Color::Rgb(0, 128, 0); const SPICY_YELLOW: Color = Color::Rgb(255, 255, 0); const SPICY_CYAN: Color = Color::Rgb(0, 255, 255); const SPICY_MAGENTA: Color = Color::Rgb(255, 0, 255); const SPICY_ORANGE: Color = Color::Rgb(255, 165, 0); const SPICY_BG: Color = Color::Rgb(10, 10, 10); const SPICY_BORDER: Color = Color::Rgb(0, 100, 0); pub struct SpicyTui { terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>, current_path: PathBuf, file_nodes: Vec<FileNode>, selected_index: usize, list_state: ListState, preview_content: Option<String>, search_query: String, search_mode: bool, filtered_indices: Vec<usize>, scroll_offset: u16, preview_scroll: u16, show_hidden: bool, show_help: bool, syntax_set: SyntaxSet, theme_set: ThemeSet, status_message: Option<(String, SystemTime)>, } impl SpicyTui { pub fn new(path: PathBuf) -> Result<Self> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; // Load syntax highlighting let syntax_set = SyntaxSet::load_defaults_newlines(); let theme_set = ThemeSet::load_defaults(); let mut app = Self { terminal: Some(terminal), current_path: path.clone(), file_nodes: Vec::new(), selected_index: 0, list_state: ListState::default(), preview_content: None, search_query: String::new(), search_mode: false, filtered_indices: Vec::new(), scroll_offset: 0, preview_scroll: 0, show_hidden: false, show_help: false, syntax_set, theme_set, status_message: None, }; app.refresh_directory()?; Ok(app) } fn refresh_directory(&mut self) -> Result<()> { let config = ScannerConfig { max_depth: 1, show_hidden: self.show_hidden, respect_gitignore: true, use_default_ignores: true, ..Default::default() }; let scanner = Scanner::new(&self.current_path, config)?; let (nodes, _) = scanner.scan()?; self.file_nodes = nodes; self.filtered_indices = (0..self.file_nodes.len()).collect(); self.selected_index = 0; self.list_state.select(Some(0)); self.update_preview()?; Ok(()) } fn update_preview(&mut self) -> Result<()> { if self.filtered_indices.is_empty() { self.preview_content = None; return Ok(()); } let actual_index = self.filtered_indices[self.selected_index]; let node = &self.file_nodes[actual_index]; let path = &node.path; if path.is_file() { // Try to read file preview match fs::metadata(path) { Ok(meta) if meta.len() > 1_000_000 => { self.preview_content = Some(format!( "📁 File too large for preview\nSize: {:.2} MB", meta.len() as f64 / 1_048_576.0 )); } Ok(_) => { // Read first 100 lines if let Ok(file) = fs::File::open(path) { let reader = BufReader::new(file); let lines: Vec<String> = reader.lines().take(100).filter_map(|l| l.ok()).collect(); self.preview_content = Some(lines.join("\n")); } else { self.preview_content = Some("⚠️ Cannot read file".to_string()); } } Err(_) => { self.preview_content = Some("❌ Permission denied".to_string()); } } } else if path.is_dir() { // Show directory info let config = ScannerConfig { max_depth: 1, show_hidden: self.show_hidden, respect_gitignore: true, use_default_ignores: true, ..Default::default() }; if let Ok(scanner) = Scanner::new(path, config) { if let Ok((children, stats)) = scanner.scan() { let info = format!( "📂 Directory\n\nFiles: {}\nDirectories: {}\nTotal Size: {:.2} MB\n\n--- Contents ---\n{}", stats.total_files, stats.total_dirs, stats.total_size as f64 / 1_048_576.0, children.iter() .take(20) .map(|n| format!(" {} {}", if n.path.is_dir() { "📁" } else { "📄" }, n.path.file_name().unwrap_or_default().to_string_lossy() )) .collect::<Vec<_>>() .join("\n") ); self.preview_content = Some(info); } } } Ok(()) } fn apply_search_filter(&mut self) { if self.search_query.is_empty() { self.filtered_indices = (0..self.file_nodes.len()).collect(); } else { let query = self.search_query.to_lowercase(); self.filtered_indices = self .file_nodes .iter() .enumerate() .filter(|(_, node)| { node.path .file_name() .unwrap_or_default() .to_string_lossy() .to_lowercase() .contains(&query) }) .map(|(i, _)| i) .collect(); } // Reset selection self.selected_index = 0; if !self.filtered_indices.is_empty() { self.list_state.select(Some(0)); self.update_preview().ok(); } else { self.list_state.select(None); self.preview_content = None; } } pub async fn run(&mut self) -> Result<()> { loop { // Temporarily take ownership of terminal to avoid borrow conflicts if let Some(mut terminal) = self.terminal.take() { terminal.draw(|f| self.draw(f))?; self.terminal = Some(terminal); } if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if self.search_mode { match key.code { KeyCode::Esc => { self.search_mode = false; self.set_status("Search cancelled"); } KeyCode::Enter => { self.search_mode = false; self.set_status(&format!( "Filtered: {} results", self.filtered_indices.len() )); } KeyCode::Backspace => { self.search_query.pop(); self.apply_search_filter(); } KeyCode::Char(c) => { self.search_query.push(c); self.apply_search_filter(); } _ => {} } } else { match key.code { KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Char('?') | KeyCode::F(1) => { self.show_help = !self.show_help; } KeyCode::Char('/') => { self.search_mode = true; self.search_query.clear(); self.set_status("Search mode: Type to filter files"); } KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.show_hidden = !self.show_hidden; self.refresh_directory()?; self.set_status(&format!( "Hidden files: {}", if self.show_hidden { "shown" } else { "hidden" } )); } KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::PageUp => self.move_selection(-10), KeyCode::PageDown => self.move_selection(10), KeyCode::Home | KeyCode::Char('g') => { if !self.filtered_indices.is_empty() { self.selected_index = 0; self.list_state.select(Some(0)); self.update_preview()?; } } KeyCode::End | KeyCode::Char('G') => { if !self.filtered_indices.is_empty() { self.selected_index = self.filtered_indices.len() - 1; self.list_state.select(Some(self.selected_index)); self.update_preview()?; } } KeyCode::Enter | KeyCode::Char(' ') => { self.enter_selected()?; } KeyCode::Backspace | KeyCode::Char('-') => { if let Some(parent) = self.current_path.parent() { self.current_path = parent.to_path_buf(); self.refresh_directory()?; self.set_status(&format!( "Navigated to: {}", self.current_path.display() )); } } KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.refresh_directory()?; self.set_status("Directory refreshed"); } _ => {} } } } } // Clear old status messages if let Some((_, time)) = &self.status_message { if time.elapsed().unwrap_or_default() > Duration::from_secs(3) { self.status_message = None; } } } Ok(()) } fn move_selection(&mut self, delta: i32) { if self.filtered_indices.is_empty() { return; } let len = self.filtered_indices.len() as i32; let new_index = (self.selected_index as i32 + delta).clamp(0, len - 1) as usize; if new_index != self.selected_index { self.selected_index = new_index; self.list_state.select(Some(new_index)); self.update_preview().ok(); } } fn enter_selected(&mut self) -> Result<()> { if self.filtered_indices.is_empty() { return Ok(()); } let actual_index = self.filtered_indices[self.selected_index]; let node = &self.file_nodes[actual_index]; let path = node.path.clone(); // Clone the path to avoid borrow conflict if path.is_dir() { self.current_path = path.clone(); self.refresh_directory()?; self.search_query.clear(); self.set_status(&format!("Entered: {}", path.display())); } else { self.set_status(&format!("Selected: {}", path.display())); } Ok(()) } fn set_status(&mut self, msg: &str) { self.status_message = Some((msg.to_string(), SystemTime::now())); } fn draw(&mut self, f: &mut Frame) { let size = f.size(); // Create main layout let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(10), // Main content Constraint::Length(3), // Status bar ]) .split(size); // Draw header with spicy styling self.draw_header(f, chunks[0]); // Split main content area let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(35), // File list Constraint::Percentage(45), // Preview Constraint::Percentage(20), // Info panel ]) .split(chunks[1]); // Draw components self.draw_file_list(f, main_chunks[0]); self.draw_preview(f, main_chunks[1]); self.draw_info_panel(f, main_chunks[2]); // Draw status bar self.draw_status_bar(f, chunks[2]); // Draw help overlay if active if self.show_help { self.draw_help_overlay(f, size); } } fn draw_header(&self, f: &mut Frame, area: Rect) { let header_text = vec![ Span::styled(" 🌶️ SPICY ", Style::default().fg(SPICY_ORANGE).bold()), Span::styled("TREE ", Style::default().fg(SPICY_GREEN).bold()), Span::styled("│ ", Style::default().fg(SPICY_BORDER)), Span::styled( self.current_path.display().to_string(), Style::default().fg(SPICY_CYAN), ), ]; let header = Paragraph::new(Line::from(header_text)) .style(Style::default().bg(SPICY_BG)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)) .border_type(ratatui::widgets::BorderType::Double), ) .alignment(Alignment::Left); f.render_widget(header, area); } fn draw_file_list(&mut self, f: &mut Frame, area: Rect) { let items: Vec<ListItem> = self .filtered_indices .iter() .enumerate() .map(|(display_idx, &actual_idx)| { let node = &self.file_nodes[actual_idx]; let name = node.path.file_name().unwrap_or_default().to_string_lossy(); let icon = if node.path.is_dir() { "📁" } else { icon_for(&node.path) }; let style = if display_idx == self.selected_index { Style::default() .fg(Color::Black) .bg(SPICY_GREEN) .add_modifier(Modifier::BOLD) } else if node.path.is_dir() { Style::default().fg(SPICY_CYAN) } else { Style::default().fg(SPICY_GREEN) }; ListItem::new(format!(" {} {}", icon, name)).style(style) }) .collect(); let title = if self.search_mode { format!(" 🔍 Search: {} ", self.search_query) } else if !self.search_query.is_empty() { format!(" 📂 Files (filtered: {}) ", self.filtered_indices.len()) } else { " 📂 Files ".to_string() }; let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)) .title(title) .title_style(Style::default().fg(SPICY_YELLOW).bold()), ) .highlight_style(Style::default()); f.render_stateful_widget(list, area, &mut self.list_state); } fn draw_preview(&self, f: &mut Frame, area: Rect) { let content = self .preview_content .as_deref() .unwrap_or("No preview available"); let preview = Paragraph::new(content) .style(Style::default().fg(SPICY_GREEN)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)) .title(" 👁️ Preview ") .title_style(Style::default().fg(SPICY_YELLOW).bold()), ) .wrap(Wrap { trim: false }) .scroll((self.preview_scroll, 0)); f.render_widget(preview, area); } fn draw_info_panel(&self, f: &mut Frame, area: Rect) { let mut info_lines = vec![]; if !self.filtered_indices.is_empty() { let actual_index = self.filtered_indices[self.selected_index]; let node = &self.file_nodes[actual_index]; info_lines.push(Line::from(vec![ Span::styled("Type: ", Style::default().fg(SPICY_DARK_GREEN)), Span::styled( if node.path.is_dir() { "Directory" } else { "File" }, Style::default().fg(SPICY_CYAN), ), ])); if let Ok(meta) = fs::metadata(&node.path) { // Size let size = if meta.is_file() { format!("{:.2} KB", meta.len() as f64 / 1024.0) } else { "N/A".to_string() }; info_lines.push(Line::from(vec![ Span::styled("Size: ", Style::default().fg(SPICY_DARK_GREEN)), Span::styled(size, Style::default().fg(SPICY_CYAN)), ])); // Permissions #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = meta.permissions().mode(); let perms = format!("{:o}", mode & 0o777); info_lines.push(Line::from(vec![ Span::styled("Perms: ", Style::default().fg(SPICY_DARK_GREEN)), Span::styled(perms, Style::default().fg(SPICY_CYAN)), ])); } } // Add some stats info_lines.push(Line::from("")); info_lines.push(Line::from(vec![Span::styled( "─────────", Style::default().fg(SPICY_BORDER), )])); info_lines.push(Line::from(vec![ Span::styled("Total: ", Style::default().fg(SPICY_DARK_GREEN)), Span::styled( format!("{}", self.file_nodes.len()), Style::default().fg(SPICY_CYAN), ), ])); info_lines.push(Line::from(vec![ Span::styled("Shown: ", Style::default().fg(SPICY_DARK_GREEN)), Span::styled( format!("{}", self.filtered_indices.len()), Style::default().fg(SPICY_CYAN), ), ])); } let info = Paragraph::new(info_lines).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)) .title(" 📊 Info ") .title_style(Style::default().fg(SPICY_YELLOW).bold()), ); f.render_widget(info, area); } fn draw_status_bar(&self, f: &mut Frame, area: Rect) { let mut status_spans = vec![]; // Left side - shortcuts status_spans.push(Span::styled( " q:Quit │ /:Search │ Enter:Open │ ?:Help ", Style::default().fg(SPICY_DARK_GREEN), )); // Right side - status message or default if let Some((msg, _)) = &self.status_message { status_spans.push(Span::styled(" │ ", Style::default().fg(SPICY_BORDER))); status_spans.push(Span::styled(msg, Style::default().fg(SPICY_YELLOW))); } let status = Paragraph::new(Line::from(status_spans)) .style(Style::default().bg(SPICY_BG)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)), ); f.render_widget(status, area); } fn draw_help_overlay(&self, f: &mut Frame, area: Rect) { let help_text = vec![ Line::from(vec![Span::styled( "🌶️ SPICY TREE HELP", Style::default().fg(SPICY_ORANGE).bold(), )]), Line::from(""), Line::from(vec![Span::styled( "Navigation:", Style::default().fg(SPICY_YELLOW).bold(), )]), Line::from(" ↑/k - Move up"), Line::from(" ↓/j - Move down"), Line::from(" Enter - Open directory / Select file"), Line::from(" Backspace - Go to parent directory"), Line::from(" g/Home - Go to first item"), Line::from(" G/End - Go to last item"), Line::from(""), Line::from(vec![Span::styled( "Search:", Style::default().fg(SPICY_YELLOW).bold(), )]), Line::from(" / - Start search mode"), Line::from(" Esc - Cancel search"), Line::from(" Enter - Apply search filter"), Line::from(""), Line::from(vec![Span::styled( "Display:", Style::default().fg(SPICY_YELLOW).bold(), )]), Line::from(" Ctrl+H - Toggle hidden files"), Line::from(" Ctrl+R - Refresh directory"), Line::from(" ?/F1 - Toggle this help"), Line::from(""), Line::from(vec![Span::styled( "Press any key to close help", Style::default().fg(SPICY_DARK_GREEN).italic(), )]), ]; let help_width = 50; let help_height = 24; let help_area = centered_rect(help_width, help_height, area); let help = Paragraph::new(help_text) .style(Style::default().fg(SPICY_GREEN)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(SPICY_BORDER)) .border_type(ratatui::widgets::BorderType::Double) .title(" ❓ Help ") .title_style(Style::default().fg(SPICY_YELLOW).bold()) .style(Style::default().bg(SPICY_BG)), ) .alignment(Alignment::Left); // Draw background overlay let overlay = Block::default().style(Style::default().bg(Color::Rgb(0, 0, 0))); f.render_widget(overlay, area); // Draw help dialog f.render_widget(help, help_area); } } impl Drop for SpicyTui { fn drop(&mut self) { // Restore terminal disable_raw_mode().ok(); if let Some(mut term) = self.terminal.take() { execute!( term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture ) .ok(); term.show_cursor().ok(); } } } // Helper function to center a rect fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length((area.height - height) / 2), Constraint::Length(height), Constraint::Length((area.height - height) / 2), ]) .split(area); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length((area.width - width) / 2), Constraint::Length(width), Constraint::Length((area.width - width) / 2), ]) .split(popup_layout[1])[1] } // Simple icon heuristic without heavy content detection fn icon_for(path: &Path) -> &'static str { if let Some(ext) = path .extension() .and_then(|e| e.to_str()) .map(|s| s.to_ascii_lowercase()) { match ext.as_str() { "rs" => "🦀", "py" => "🐍", "js" | "ts" | "tsx" | "jsx" => "📜", "md" | "markdown" => "📝", "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "🖼️", "exe" | "bin" | "dll" | "so" | "dylib" => "⚙️", _ => "📄", } } else { "📄" } } // Public entry point pub async fn run_spicy_tui(path: PathBuf) -> Result<()> { let mut app = SpicyTui::new(path)?; app.run().await }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/8b-is/smart-tree'

If you have feedback or need assistance with the MCP directory API, please join our Discord server