diff --git a/crates/owlen-core/src/state/mod.rs b/crates/owlen-core/src/state/mod.rs index c215d53..8a7e56d 100644 --- a/crates/owlen-core/src/state/mod.rs +++ b/crates/owlen-core/src/state/mod.rs @@ -21,6 +21,8 @@ pub enum InputMode { Command, SessionBrowser, ThemeBrowser, + RepoSearch, + SymbolSearch, } impl fmt::Display for InputMode { @@ -35,6 +37,8 @@ impl fmt::Display for InputMode { InputMode::Command => "Command", InputMode::SessionBrowser => "Sessions", InputMode::ThemeBrowser => "Themes", + InputMode::RepoSearch => "Search", + InputMode::SymbolSearch => "Symbols", }; f.write_str(label) } @@ -43,6 +47,7 @@ impl fmt::Display for InputMode { /// Represents which panel is currently focused in the TUI layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusedPanel { + Files, Chat, Thinking, Input, diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 0480980..d193667 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -20,6 +20,13 @@ textwrap = { workspace = true } unicode-width = "0.1" unicode-segmentation = "1.11" async-trait = "0.1" +globset = "0.4" +ignore = "0.4" +pathdiff = "0.2" +tree-sitter = "0.20" +tree-sitter-rust = "0.20" +dirs = { workspace = true } +toml = { workspace = true } # Async runtime tokio = { workspace = true } @@ -30,6 +37,7 @@ futures-util = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } serde_json.workspace = true +serde.workspace = true [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 058cbab..0ecb2f4 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1,4 +1,5 @@ -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::{ Provider, ProviderConfig, @@ -9,10 +10,14 @@ use owlen_core::{ types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay}, }; +use pathdiff::diff_paths; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use textwrap::{Options, WordSeparator, wrap}; -use tokio::{sync::mpsc, task::JoinHandle}; +use tokio::{ + sync::mpsc, + task::{self, JoinHandle}, +}; use tui_textarea::{Input, TextArea}; use unicode_width::UnicodeWidthStr; use uuid::Uuid; @@ -21,14 +26,27 @@ use crate::commands; use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; -use crate::state::{CommandPalette, ModelPaletteEntry}; +use crate::state::{ + CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry, + PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, + SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, + spawn_symbol_search_task, +}; use crate::ui::format_tool_output; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. use std::collections::hash_map::DefaultHasher; use std::collections::{BTreeSet, HashMap, HashSet}; +use std::env; +use std::fs; +use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dirs::{config_dir, data_local_dir}; const ONBOARDING_STATUS_LINE: &str = "Welcome to Owlen! Press F1 for help or type :tutorial for keybinding tips."; @@ -37,6 +55,11 @@ const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer. const TUTORIAL_SYSTEM_STATUS: &str = "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Send ▸ Enter"; +const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200); +const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); +const RESIZE_STEP: f32 = 0.05; +const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; + #[derive(Clone, Debug)] pub(crate) struct ModelSelectorItem { kind: ModelSelectorItemKind, @@ -175,27 +198,39 @@ pub struct ChatApp { current_thinking: Option, // Current thinking content from last assistant message // Holds the latest formatted Agentic ReAct actions (thought/action/observation) agent_actions: Option, - pending_key: Option, // For multi-key sequences like gg, dd - clipboard: String, // Vim-style clipboard for yank/paste + pending_key: Option, // For multi-key sequences like gg, dd + clipboard: String, // Vim-style clipboard for yank/paste + pending_file_action: Option, // Active file action prompt command_palette: CommandPalette, // Command mode state (buffer + suggestions) + repo_search: RepoSearchState, // Repository search overlay state + repo_search_task: Option>, + repo_search_rx: Option>, + repo_search_file_map: HashMap, + symbol_search: SymbolSearchState, // Symbol search overlay state + symbol_search_task: Option>, + symbol_search_rx: Option>, visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels focused_panel: FocusedPanel, // Currently focused panel for scrolling chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col) chat_line_offset: usize, // Number of leading lines trimmed for scrollback thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col) - code_view_path: Option, // Active code view file path - code_view_lines: Vec, // Cached lines for code view rendering - code_view_scroll: AutoScroll, // Scroll state for code view - code_view_viewport_height: usize, // Viewport height for code view panel - saved_sessions: Vec, // Cached list of saved sessions - selected_session_index: usize, // Index of selected session in browser - help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) - theme: Theme, // Current theme - available_themes: Vec, // Cached list of theme names - selected_theme_index: usize, // Index of selected theme in browser + code_workspace: CodeWorkspace, // Code views with tabs/splits + pending_focus_chord: Option, // Tracks Ctrl+K focus chord timeout + last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection + resize_snap_index: usize, // Cycles through 25/50/75 snaps + last_snap_direction: Option, + file_tree: FileTreeState, // Workspace file tree state + file_panel_collapsed: bool, // Whether the file panel is collapsed + file_panel_width: u16, // Cached file panel width + saved_sessions: Vec, // Cached list of saved sessions + selected_session_index: usize, // Index of selected session in browser + help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) + theme: Theme, // Current theme + available_themes: Vec, // Cached list of theme names + selected_theme_index: usize, // Index of selected theme in browser pending_consent: Option, // Pending consent request - system_status: String, // System/status messages (tool execution, status, etc) + system_status: String, // System/status messages (tool execution, status, etc) /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, /// Agent mode enabled @@ -273,6 +308,54 @@ enum MessageSegment { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileOpenDisposition { + Primary, + SplitHorizontal, + SplitVertical, + Tab, +} + +#[derive(Debug, Clone)] +struct FileActionPrompt { + kind: FileActionKind, + buffer: String, +} + +#[derive(Debug, Clone)] +enum FileActionKind { + CreateFile { base: PathBuf }, + CreateFolder { base: PathBuf }, + Rename { original: PathBuf }, + Move { original: PathBuf }, + Delete { target: PathBuf, confirm: String }, +} + +impl FileActionPrompt { + fn new(kind: FileActionKind, initial: impl Into) -> Self { + Self { + kind, + buffer: initial.into(), + } + } + + fn push_char(&mut self, ch: char) { + self.buffer.push(ch); + } + + fn pop_char(&mut self) { + self.buffer.pop(); + } + + fn set_buffer(&mut self, buffer: impl Into) { + self.buffer = buffer.into(); + } + + fn is_destructive(&self) -> bool { + matches!(self.kind, FileActionKind::Delete { .. }) + } +} + impl ChatApp { pub async fn new( controller: SessionController, @@ -294,6 +377,9 @@ impl ChatApp { Theme::default() }); + let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let file_tree = FileTreeState::new(workspace_root); + let mut app = Self { controller, mode: InputMode::Normal, @@ -333,17 +419,29 @@ impl ChatApp { agent_actions: None, pending_key: None, clipboard: String::new(), + pending_file_action: None, command_palette: CommandPalette::new(), + repo_search: RepoSearchState::new(), + repo_search_task: None, + repo_search_rx: None, + repo_search_file_map: HashMap::new(), + symbol_search: SymbolSearchState::new(), + symbol_search_task: None, + symbol_search_rx: None, visual_start: None, visual_end: None, focused_panel: FocusedPanel::Input, chat_cursor: (0, 0), chat_line_offset: 0, thinking_cursor: (0, 0), - code_view_path: None, - code_view_lines: Vec::new(), - code_view_scroll: AutoScroll::default(), - code_view_viewport_height: 0, + code_workspace: CodeWorkspace::new(), + pending_focus_chord: None, + last_resize_tap: None, + resize_snap_index: 0, + last_snap_direction: None, + file_tree, + file_panel_collapsed: true, + file_panel_width: 32, saved_sessions: Vec::new(), selected_session_index: 0, help_tab_index: 0, @@ -368,6 +466,10 @@ impl ChatApp { app.update_command_palette_catalog(); + if let Err(err) = app.restore_workspace_layout().await { + eprintln!("Warning: failed to restore workspace layout: {err}"); + } + if show_onboarding { let mut cfg = app.controller.config_mut(); if cfg.ui.show_onboarding { @@ -416,27 +518,496 @@ impl ChatApp { } pub fn should_show_code_view(&self) -> bool { - matches!(self.operating_mode, owlen_core::mode::Mode::Code) && self.code_view_path.is_some() + if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { + return false; + } + if let Some(pane) = self.code_workspace.active_pane() { + return pane.display_path().is_some() || !pane.lines.is_empty(); + } + false } pub fn code_view_path(&self) -> Option<&str> { - self.code_view_path.as_deref() + self.code_workspace + .active_pane() + .and_then(|pane| pane.display_path()) } pub fn code_view_lines(&self) -> &[String] { - &self.code_view_lines + self.code_workspace + .active_pane() + .map(|pane| pane.lines.as_slice()) + .unwrap_or(&[]) } - pub fn code_view_scroll(&self) -> &AutoScroll { - &self.code_view_scroll + pub fn code_view_scroll(&self) -> Option<&AutoScroll> { + self.code_workspace.active_pane().map(|pane| &pane.scroll) } - pub fn code_view_scroll_mut(&mut self) -> &mut AutoScroll { - &mut self.code_view_scroll + pub fn code_view_scroll_mut(&mut self) -> Option<&mut AutoScroll> { + self.code_workspace + .active_tab_mut() + .and_then(|tab| tab.active_pane_mut()) + .map(|pane| &mut pane.scroll) } pub fn set_code_view_viewport_height(&mut self, height: usize) { - self.code_view_viewport_height = height; + self.code_workspace.set_active_viewport_height(height); + } + + pub fn repo_search(&self) -> &RepoSearchState { + &self.repo_search + } + + pub fn repo_search_mut(&mut self) -> &mut RepoSearchState { + &mut self.repo_search + } + + pub fn symbol_search(&self) -> &SymbolSearchState { + &self.symbol_search + } + + pub fn symbol_search_mut(&mut self) -> &mut SymbolSearchState { + &mut self.symbol_search + } + + fn repo_search_display_path(&self, absolute: &Path) -> String { + if let Some(relative) = diff_paths(absolute, self.file_tree().root()) { + if relative.as_os_str().is_empty() { + ".".to_string() + } else { + relative.to_string_lossy().into_owned() + } + } else { + absolute.to_string_lossy().into_owned() + } + } + + fn ensure_repo_search_file_index(&mut self, path: &Path) -> usize { + if let Some(index) = self.repo_search_file_map.get(path).copied() { + return index; + } + let display = self.repo_search_display_path(path); + let idx = self + .repo_search + .ensure_file_entry(path.to_path_buf(), display); + self.repo_search_file_map.insert(path.to_path_buf(), idx); + idx + } + + fn cancel_repo_search_process(&mut self) { + if let Some(handle) = self.repo_search_task.take() { + handle.abort(); + } + self.repo_search_rx = None; + } + + fn poll_repo_search(&mut self) { + if let Some(mut rx) = self.repo_search_rx.take() { + use tokio::sync::mpsc::error::TryRecvError; + let mut keep_receiver = true; + loop { + match rx.try_recv() { + Ok(message) => { + if !self.handle_repo_search_message(message) { + keep_receiver = false; + break; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.repo_search_task = None; + keep_receiver = false; + break; + } + } + } + if keep_receiver { + self.repo_search_rx = Some(rx); + } + } + } + + fn handle_repo_search_message(&mut self, message: RepoSearchMessage) -> bool { + match message { + RepoSearchMessage::File { path } => { + self.ensure_repo_search_file_index(&path); + true + } + RepoSearchMessage::Match { + path, + line_number, + column, + preview, + matched, + } => { + let idx = self.ensure_repo_search_file_index(&path); + self.repo_search + .add_match(idx, line_number, column, preview, matched); + true + } + RepoSearchMessage::Done { matches } => { + self.repo_search.finish(matches); + self.repo_search_task = None; + self.repo_search_rx = None; + self.status = if matches == 0 { + "Repo search: no matches".to_string() + } else { + format!("Repo search: {matches} match(es)") + }; + false + } + RepoSearchMessage::Error(err) => { + self.repo_search.mark_error(err.clone()); + self.repo_search_task = None; + self.repo_search_rx = None; + self.error = Some(err.clone()); + self.status = format!("Repo search failed: {err}"); + false + } + } + } + + async fn start_repo_search(&mut self) -> Result<()> { + let Some(query) = self.repo_search.prepare_run() else { + if self.repo_search.query_input().is_empty() { + self.status = "Enter a search query".to_string(); + } + return Ok(()); + }; + + self.cancel_repo_search_process(); + self.repo_search_file_map.clear(); + let root = self.file_tree().root().to_path_buf(); + + match spawn_repo_search_task(root, query.clone()) { + Ok((handle, rx)) => { + self.repo_search_task = Some(handle); + self.repo_search_rx = Some(rx); + self.status = format!("Searching for \"{query}\"…"); + self.error = None; + } + Err(err) => { + let message = err.to_string(); + self.repo_search.mark_error(message.clone()); + self.error = Some(message.clone()); + self.status = format!("Failed to start search: {message}"); + } + } + + Ok(()) + } + + async fn open_repo_search_match(&mut self) -> Result<()> { + let Some((file_index, match_index)) = self.repo_search.selected_indices() else { + self.status = "Select a match to open".to_string(); + return Ok(()); + }; + + let (absolute, display, line_number, column) = { + let file = &self.repo_search.files()[file_index]; + let m = &file.matches[match_index]; + ( + file.absolute.clone(), + file.display.clone(), + m.line_number, + m.column, + ) + }; + + let root = self.file_tree().root().to_path_buf(); + let request_path = if absolute.starts_with(&root) { + diff_paths(&absolute, &root) + .filter(|rel| !rel.as_os_str().is_empty()) + .map(|rel| rel.to_string_lossy().into_owned()) + .unwrap_or_else(|| absolute.to_string_lossy().into_owned()) + } else { + absolute.to_string_lossy().into_owned() + }; + + if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { + self.set_mode(owlen_core::mode::Mode::Code).await; + } + + match self.controller.read_file_with_tools(&request_path).await { + Ok(content) => { + self.prepare_code_view_target(FileOpenDisposition::Primary); + self.set_code_view_content(display.clone(), Some(absolute.clone()), content); + if let Some(pane) = self.code_workspace.active_pane_mut() { + pane.scroll.stick_to_bottom = false; + let target_line = line_number.saturating_sub(1) as usize; + let viewport = pane.viewport_height.max(1); + let scroll = target_line.saturating_sub(viewport / 2); + pane.scroll.scroll = scroll; + } + self.file_tree_mut().reveal(&absolute); + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.mode = InputMode::Normal; + self.status = format!("Opened {}:{}:{column}", display, line_number); + self.error = None; + } + Err(err) => { + let message = format!("Failed to open {}: {}", display, err); + self.error = Some(message.clone()); + self.status = message; + } + } + + Ok(()) + } + + async fn open_repo_search_scratch(&mut self) -> Result<()> { + if !self.repo_search.has_results() { + self.status = "No matches to open".to_string(); + return Ok(()); + } + + let mut buffer = String::new(); + for file in self.repo_search.files() { + if file.matches.is_empty() { + continue; + } + buffer.push_str(&format!("{}\n", file.display)); + for m in &file.matches { + buffer.push_str(&format!( + " {:>6}:{:<3} {}\n", + m.line_number, m.column, m.preview + )); + } + buffer.push('\n'); + } + + let title = if let Some(query) = self.repo_search.last_query() { + format!("Search results: {query}") + } else { + "Search results".to_string() + }; + + if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { + self.set_mode(owlen_core::mode::Mode::Code).await; + } + + self.code_workspace.open_new_tab(); + self.set_code_view_content(title.clone(), None::, buffer); + if let Some(pane) = self.code_workspace.active_pane_mut() { + pane.is_dirty = false; + pane.is_staged = false; + } + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.mode = InputMode::Normal; + self.status = format!("Opened scratch buffer for {title}"); + Ok(()) + } + + fn cancel_symbol_search_process(&mut self) { + if let Some(handle) = self.symbol_search_task.take() { + handle.abort(); + } + self.symbol_search_rx = None; + } + + fn poll_symbol_search(&mut self) { + if let Some(mut rx) = self.symbol_search_rx.take() { + use tokio::sync::mpsc::error::TryRecvError; + let mut keep_receiver = true; + loop { + match rx.try_recv() { + Ok(message) => { + if !self.handle_symbol_search_message(message) { + keep_receiver = false; + break; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.symbol_search_task = None; + keep_receiver = false; + break; + } + } + } + if keep_receiver { + self.symbol_search_rx = Some(rx); + } + } + } + + fn handle_symbol_search_message(&mut self, message: SymbolSearchMessage) -> bool { + match message { + SymbolSearchMessage::Symbols(batch) => { + self.symbol_search.add_symbols(batch); + true + } + SymbolSearchMessage::Done => { + self.symbol_search.finish(); + self.symbol_search_task = None; + self.symbol_search_rx = None; + self.status = "Symbol index ready".to_string(); + false + } + SymbolSearchMessage::Error(err) => { + self.symbol_search.mark_error(err.clone()); + self.symbol_search_task = None; + self.symbol_search_rx = None; + self.error = Some(err.clone()); + self.status = format!("Symbol search failed: {err}"); + false + } + } + } + + async fn start_symbol_search(&mut self) -> Result<()> { + self.cancel_symbol_search_process(); + self.symbol_search.begin_index(); + let root = self.file_tree().root().to_path_buf(); + match spawn_symbol_search_task(root) { + Ok((handle, rx)) => { + self.symbol_search_task = Some(handle); + self.symbol_search_rx = Some(rx); + self.status = "Indexing symbols…".to_string(); + self.error = None; + } + Err(err) => { + let message = err.to_string(); + self.symbol_search.mark_error(message.clone()); + self.error = Some(message.clone()); + self.status = format!("Unable to start symbol search: {message}"); + } + } + Ok(()) + } + + async fn open_symbol_search_entry(&mut self) -> Result<()> { + let Some(entry) = self.symbol_search.selected_entry().cloned() else { + self.status = "Select a symbol".to_string(); + return Ok(()); + }; + + let root = self.file_tree().root().to_path_buf(); + let request_path = if entry.file.starts_with(&root) { + diff_paths(&entry.file, &root) + .filter(|rel| !rel.as_os_str().is_empty()) + .map(|rel| rel.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.file.to_string_lossy().into_owned()) + } else { + entry.file.to_string_lossy().into_owned() + }; + + if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { + self.set_mode(owlen_core::mode::Mode::Code).await; + } + + match self.controller.read_file_with_tools(&request_path).await { + Ok(content) => { + self.prepare_code_view_target(FileOpenDisposition::Primary); + self.set_code_view_content( + entry.display_path.clone(), + Some(entry.file.clone()), + content, + ); + if let Some(pane) = self.code_workspace.active_pane_mut() { + pane.scroll.stick_to_bottom = false; + let target_line = entry.line.saturating_sub(1) as usize; + let viewport = pane.viewport_height.max(1); + let scroll = target_line.saturating_sub(viewport / 2); + pane.scroll.scroll = scroll; + } + self.file_tree_mut().reveal(&entry.file); + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.mode = InputMode::Normal; + self.status = format!( + "Jumped to {} {}:{}", + entry.kind.label(), + entry.display_path, + entry.line + ); + self.error = None; + } + Err(err) => { + let message = format!("Failed to open {}: {}", entry.display_path, err); + self.error = Some(message.clone()); + self.status = message; + } + } + + Ok(()) + } + + pub fn code_view_viewport_height(&self) -> usize { + self.code_workspace + .active_pane() + .map(|pane| pane.viewport_height) + .unwrap_or(0) + } + + pub fn has_loaded_code_view(&self) -> bool { + self.code_workspace + .active_pane() + .map(|pane| pane.display_path().is_some() || !pane.lines.is_empty()) + .unwrap_or(false) + } + + pub fn file_tree(&self) -> &FileTreeState { + &self.file_tree + } + + pub fn file_tree_mut(&mut self) -> &mut FileTreeState { + &mut self.file_tree + } + + pub fn workspace(&self) -> &CodeWorkspace { + &self.code_workspace + } + + pub fn workspace_mut(&mut self) -> &mut CodeWorkspace { + &mut self.code_workspace + } + + pub fn is_file_panel_collapsed(&self) -> bool { + self.file_panel_collapsed + } + + pub fn set_file_panel_collapsed(&mut self, collapsed: bool) { + self.file_panel_collapsed = collapsed; + } + + pub fn file_panel_width(&self) -> u16 { + self.file_panel_width + } + + pub fn set_file_panel_width(&mut self, width: u16) { + const MIN_WIDTH: u16 = 24; + const MAX_WIDTH: u16 = 80; + self.file_panel_width = width.clamp(MIN_WIDTH, MAX_WIDTH); + } + + pub fn expand_file_panel(&mut self) { + if self.file_panel_collapsed { + self.file_panel_collapsed = false; + self.focused_panel = FocusedPanel::Files; + self.ensure_focus_valid(); + } + } + + pub fn collapse_file_panel(&mut self) { + if !self.file_panel_collapsed { + self.file_panel_collapsed = true; + if matches!(self.focused_panel, FocusedPanel::Files) { + self.focused_panel = FocusedPanel::Chat; + } + self.ensure_focus_valid(); + } + } + + pub fn toggle_file_panel(&mut self) { + if self.file_panel_collapsed { + self.expand_file_panel(); + } else { + self.collapse_file_panel(); + } } // Synchronous access for UI rendering and other callers that expect an immediate Config. @@ -776,6 +1347,14 @@ impl ChatApp { } } + pub fn is_loading(&self) -> bool { + self.is_loading + } + + pub fn is_streaming(&self) -> bool { + !self.streaming.is_empty() + } + pub fn scrollback_limit(&self) -> usize { let limit = { let config = self.controller.config(); @@ -1231,7 +1810,11 @@ impl ChatApp { } fn focus_sequence(&self) -> Vec { - let mut order = vec![FocusedPanel::Chat]; + let mut order = Vec::new(); + if !self.file_panel_collapsed { + order.push(FocusedPanel::Files); + } + order.push(FocusedPanel::Chat); if self.should_show_code_view() { order.push(FocusedPanel::Code); } @@ -1303,29 +1886,1115 @@ impl ChatApp { configure_textarea_defaults(&mut self.textarea); } - fn set_code_view_content(&mut self, path: impl Into, content: String) { + fn set_code_view_content( + &mut self, + display_path: impl Into, + absolute: Option, + content: String, + ) { let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); if content.ends_with('\n') { lines.push(String::new()); } - self.code_view_path = Some(path.into()); - self.code_view_lines = lines; - self.code_view_scroll = AutoScroll::default(); - self.code_view_scroll.content_len = self.code_view_lines.len(); - self.code_view_scroll.stick_to_bottom = false; - self.code_view_scroll.scroll = 0; + let display = display_path.into(); + self.code_workspace + .set_active_contents(absolute, Some(display), lines); self.ensure_focus_valid(); } + fn repo_layout_slug(&self) -> String { + self.file_tree() + .repo_name() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() + } + + fn workspace_layout_path(&self) -> Result { + let base = data_local_dir().or_else(config_dir).ok_or_else(|| { + anyhow!("Unable to determine configuration directory for layout persistence") + })?; + + let mut dir = base.join("owlen").join("layouts"); + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create layout directory at {}", dir.display()))?; + + let mut hasher = DefaultHasher::new(); + self.file_tree().root().to_string_lossy().hash(&mut hasher); + let slug = self.repo_layout_slug(); + dir.push(format!("{}-{}.toml", slug, hasher.finish())); + Ok(dir) + } + + fn persist_workspace_layout(&mut self) { + if self.code_workspace.tabs().is_empty() { + return; + } + + let snapshot = self.code_workspace.snapshot(); + match (self.workspace_layout_path(), toml::to_string(&snapshot)) { + (Ok(path), Ok(serialized)) => { + if let Err(err) = fs::write(&path, serialized) { + eprintln!( + "Warning: failed to write workspace layout {}: {}", + path.display(), + err + ); + } + } + (Err(err), _) => { + eprintln!("Warning: unable to determine layout path: {err}"); + } + (_, Err(err)) => { + eprintln!("Warning: failed to serialize workspace layout: {err}"); + } + } + } + + fn restore_pane_from_request(&mut self, request: PaneRestoreRequest) -> Result<()> { + let Some(absolute) = request.absolute_path.as_ref() else { + return Ok(()); + }; + + let content = fs::read_to_string(absolute) + .with_context(|| format!("Failed to read restored file {}", absolute.display()))?; + let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); + if content.ends_with('\n') { + lines.push(String::new()); + } + + let display = request.display_path.clone().or_else(|| { + diff_paths(absolute, self.file_tree().root()).map(|path| { + if path.as_os_str().is_empty() { + ".".to_string() + } else { + path.to_string_lossy().into_owned() + } + }) + }); + + if self.code_workspace.set_pane_contents( + request.pane_id, + Some(absolute.clone()), + display, + lines, + ) { + self.code_workspace + .restore_scroll(request.pane_id, &request.scroll); + } + + Ok(()) + } + + async fn restore_workspace_layout(&mut self) -> Result<()> { + let path = match self.workspace_layout_path() { + Ok(path) => path, + Err(_) => return Ok(()), + }; + + if !path.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&path) + .with_context(|| format!("Failed to read workspace layout {}", path.display()))?; + let snapshot: WorkspaceSnapshot = toml::from_str(&contents) + .with_context(|| format!("Failed to parse workspace layout {}", path.display()))?; + + let requests = self.code_workspace.apply_snapshot(snapshot); + let mut restored_any = false; + for request in requests { + if let Err(err) = self.restore_pane_from_request(request) { + eprintln!("Warning: failed to restore pane from layout: {err}"); + } else { + restored_any = true; + } + } + + if restored_any { + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.status = "Workspace layout restored".to_string(); + } + + Ok(()) + } + + fn direction_label(direction: PaneDirection) -> &'static str { + match direction { + PaneDirection::Left => "←", + PaneDirection::Right => "→", + PaneDirection::Up => "↑", + PaneDirection::Down => "↓", + } + } + + fn handle_workspace_focus_move(&mut self, direction: PaneDirection) { + self.pending_focus_chord = None; + if self.code_workspace.move_focus(direction) { + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + if let Some(share) = self.code_workspace.active_share() { + self.status = format!( + "Focused pane {} · {:.0}% share", + Self::direction_label(direction), + (share * 100.0).round() + ); + } else { + self.status = format!("Focused pane {}", Self::direction_label(direction)); + } + self.error = None; + self.persist_workspace_layout(); + } else { + self.status = "No pane in that direction".to_string(); + } + } + + fn handle_workspace_resize(&mut self, direction: PaneDirection) { + self.pending_focus_chord = None; + let now = Instant::now(); + let is_double = self + .last_resize_tap + .map(|(prev_dir, instant)| { + prev_dir == direction && now.duration_since(instant) <= RESIZE_DOUBLE_TAP_WINDOW + }) + .unwrap_or(false); + + let share_opt = if is_double { + if self.last_snap_direction != Some(direction) { + self.resize_snap_index = 0; + } + let snap = RESIZE_SNAP_VALUES[self.resize_snap_index % RESIZE_SNAP_VALUES.len()]; + let result = self.code_workspace.snap_active_share(direction, snap); + if result.is_some() { + self.last_snap_direction = Some(direction); + self.resize_snap_index = (self.resize_snap_index + 1) % RESIZE_SNAP_VALUES.len(); + } + result + } else { + self.last_snap_direction = None; + self.resize_snap_index = 0; + self.code_workspace + .resize_active_step(direction, RESIZE_STEP) + }; + + match share_opt { + Some(share) => { + if is_double { + self.status = format!( + "Pane snapped {} · {:.0}% share", + Self::direction_label(direction), + (share * 100.0).round() + ); + } else { + self.status = format!( + "Pane resized {} · {:.0}% share", + Self::direction_label(direction), + (share * 100.0).round() + ); + } + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.error = None; + self.persist_workspace_layout(); + if is_double { + self.last_resize_tap = None; + } else { + self.last_resize_tap = Some((direction, now)); + } + } + None => { + self.status = "No adjacent split to resize".to_string(); + self.last_resize_tap = Some((direction, now)); + self.last_snap_direction = None; + } + } + } + + fn prepare_code_view_target(&mut self, disposition: FileOpenDisposition) -> bool { + match disposition { + FileOpenDisposition::Primary => true, + FileOpenDisposition::SplitHorizontal => self + .code_workspace + .split_active(SplitAxis::Horizontal) + .is_some(), + FileOpenDisposition::SplitVertical => self + .code_workspace + .split_active(SplitAxis::Vertical) + .is_some(), + FileOpenDisposition::Tab => { + self.code_workspace.open_new_tab(); + true + } + } + } + + fn split_active_pane(&mut self, axis: SplitAxis) { + let Some(snapshot) = self.code_workspace.active_pane().cloned() else { + self.status = "No pane to split".to_string(); + return; + }; + + if self.code_workspace.split_active(axis).is_some() { + let lines = snapshot.lines.clone(); + let absolute = snapshot.absolute_path.clone(); + let display = snapshot.display_path.clone(); + self.code_workspace + .set_active_contents(absolute, display, lines); + if let Some(pane) = self.code_workspace.active_pane_mut() { + pane.is_dirty = snapshot.is_dirty; + pane.is_staged = snapshot.is_staged; + pane.viewport_height = snapshot.viewport_height; + pane.scroll = snapshot.scroll.clone(); + } + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.status = match axis { + SplitAxis::Horizontal => "Split pane horizontally".to_string(), + SplitAxis::Vertical => "Split pane vertically".to_string(), + }; + self.error = None; + self.persist_workspace_layout(); + } else { + self.status = "Unable to split pane".to_string(); + self.error = Some("Unable to split pane".to_string()); + } + } + fn close_code_view(&mut self) { - self.code_view_path = None; - self.code_view_lines.clear(); - self.code_view_scroll = AutoScroll::default(); - self.code_view_viewport_height = 0; + self.code_workspace.clear_active_pane(); if matches!(self.focused_panel, FocusedPanel::Code) { self.focused_panel = FocusedPanel::Chat; } self.ensure_focus_valid(); + self.persist_workspace_layout(); + } + + fn absolute_tree_path(&self, path: &Path) -> PathBuf { + if path.as_os_str().is_empty() { + self.file_tree().root().to_path_buf() + } else if path.is_absolute() { + path.to_path_buf() + } else { + self.file_tree().root().join(path) + } + } + + fn relative_tree_display(&self, path: &Path) -> String { + if path.as_os_str().is_empty() { + ".".to_string() + } else { + path.to_string_lossy().into_owned() + } + } + + async fn open_selected_file_from_tree( + &mut self, + disposition: FileOpenDisposition, + ) -> Result<()> { + let selected_opt = { + let tree = self.file_tree(); + tree.selected_node().cloned() + }; + + let Some(selected) = selected_opt else { + self.status = "No file selected".to_string(); + return Ok(()); + }; + + if selected.is_dir { + let was_expanded = selected.is_expanded; + self.file_tree_mut().toggle_expand(); + let label = self.relative_tree_display(&selected.path); + self.status = if was_expanded { + format!("Collapsed {}", label) + } else { + format!("Expanded {}", label) + }; + return Ok(()); + } + + if selected.path.as_os_str().is_empty() { + return Ok(()); + } + + if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) { + self.set_mode(owlen_core::mode::Mode::Code).await; + } + + let relative_display = self.relative_tree_display(&selected.path); + let absolute_path = self.absolute_tree_path(&selected.path); + let request_path = if selected.path.is_absolute() { + selected.path.to_string_lossy().into_owned() + } else { + relative_display.clone() + }; + + match self.controller.read_file_with_tools(&request_path).await { + Ok(content) => { + let prepared = self.prepare_code_view_target(disposition); + self.set_code_view_content( + relative_display.clone(), + Some(absolute_path.clone()), + content, + ); + self.focused_panel = FocusedPanel::Code; + self.ensure_focus_valid(); + self.file_tree_mut().reveal(&absolute_path); + if !prepared { + self.error = + Some("Unable to create requested split; opened in active pane".to_string()); + } else { + self.error = None; + } + self.status = match (disposition, prepared) { + (FileOpenDisposition::Primary, _) => format!("Opened {}", relative_display), + (FileOpenDisposition::SplitHorizontal, true) => { + format!("Opened {} in horizontal split", relative_display) + } + (FileOpenDisposition::SplitVertical, true) => { + format!("Opened {} in vertical split", relative_display) + } + (FileOpenDisposition::Tab, true) => { + format!("Opened {} in new tab", relative_display) + } + (FileOpenDisposition::SplitHorizontal, false) + | (FileOpenDisposition::SplitVertical, false) => { + format!("Opened {} (split unavailable)", relative_display) + } + (FileOpenDisposition::Tab, false) => { + format!("Opened {} (tab unavailable)", relative_display) + } + }; + self.set_system_status(format!("Viewing {}", relative_display)); + self.persist_workspace_layout(); + } + Err(err) => { + self.error = Some(format!("Failed to open {}: {}", relative_display, err)); + } + } + + Ok(()) + } + + fn copy_selected_path(&mut self, relative: bool) { + let selected_opt = { + let tree = self.file_tree(); + tree.selected_node().cloned() + }; + + let Some(selected) = selected_opt else { + self.status = "No file selected".to_string(); + return; + }; + + let path_string = if relative { + self.relative_tree_display(&selected.path) + } else { + let abs = self.absolute_tree_path(&selected.path); + abs.to_string_lossy().into_owned() + }; + + self.clipboard = path_string.clone(); + self.status = if relative { + format!("Copied relative path: {}", path_string) + } else { + format!("Copied path: {}", path_string) + }; + self.error = None; + } + + fn selected_file_node(&self) -> Option { + let tree = self.file_tree(); + tree.selected_node().cloned() + } + + fn mutate_file_filter(&mut self, mutate: F) + where + F: FnOnce(&mut String), + { + let mut query = { + let tree = self.file_tree(); + tree.filter_query().to_string() + }; + + mutate(&mut query); + + { + let tree = self.file_tree_mut(); + tree.set_filter_query(query.clone()); + } + + if query.is_empty() { + self.status = "Filter cleared".to_string(); + } else { + self.status = format!("Filter: {}", query); + } + self.error = None; + } + + fn backspace_file_filter(&mut self) { + self.mutate_file_filter(|query| { + query.pop(); + }); + } + + fn clear_file_filter(&mut self) { + self.mutate_file_filter(|query| { + query.clear(); + }); + } + + fn append_file_filter_char(&mut self, ch: char) { + self.mutate_file_filter(|query| { + query.push(ch); + }); + } + + fn toggle_hidden_files(&mut self) { + match self.file_tree_mut().toggle_hidden() { + Ok(()) => { + let show_hidden = self.file_tree().show_hidden(); + self.status = if show_hidden { + "Hidden files visible".to_string() + } else { + "Hidden files hidden".to_string() + }; + self.error = None; + } + Err(err) => { + self.error = Some(format!("Failed to toggle hidden files: {}", err)); + } + } + } + + pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> { + self.pending_file_action.as_ref().map(|prompt| { + ( + self.describe_file_action_prompt(prompt), + prompt.is_destructive(), + ) + }) + } + + fn describe_file_action_prompt(&self, prompt: &FileActionPrompt) -> String { + let buffer_display = if prompt.buffer.trim().is_empty() { + "".to_string() + } else { + prompt.buffer.clone() + }; + + let base_message = match &prompt.kind { + FileActionKind::CreateFile { base } => { + let base_display = self.relative_tree_display(base); + format!("Create file in {} ▸ {}", base_display, buffer_display) + } + FileActionKind::CreateFolder { base } => { + let base_display = self.relative_tree_display(base); + format!("Create folder in {} ▸ {}", base_display, buffer_display) + } + FileActionKind::Rename { original } => { + let current_display = self.relative_tree_display(original); + format!("Rename {} → {}", current_display, buffer_display) + } + FileActionKind::Move { original } => { + let current_display = self.relative_tree_display(original); + format!("Move {} → {}", current_display, buffer_display) + } + FileActionKind::Delete { target, .. } => { + let target_display = self.relative_tree_display(target); + format!( + "Delete {} — type filename to confirm ▸ {}", + target_display, buffer_display + ) + } + }; + + format!("{base_message} (Enter to apply · Esc to cancel)") + } + + fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into) { + let prompt = FileActionPrompt::new(kind, initial); + self.status = self.describe_file_action_prompt(&prompt); + self.error = None; + self.pending_file_action = Some(prompt); + } + + fn refresh_file_action_status(&mut self) { + if let Some(prompt) = self.pending_file_action.as_ref() { + self.status = self.describe_file_action_prompt(prompt); + } + } + + fn cancel_file_action(&mut self) { + self.pending_file_action = None; + self.status = "File action cancelled".to_string(); + self.error = None; + } + + fn handle_file_action_prompt(&mut self, key: &crossterm::event::KeyEvent) -> Result { + use crossterm::event::{KeyCode, KeyModifiers}; + + if self.pending_file_action.is_none() { + return Ok(false); + } + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + match key.code { + KeyCode::Enter if !ctrl && !alt => { + self.apply_pending_file_action()?; + return Ok(true); + } + KeyCode::Esc if !ctrl && !alt => { + self.cancel_file_action(); + return Ok(true); + } + KeyCode::Backspace if !ctrl && !alt => { + if let Some(prompt) = self.pending_file_action.as_mut() { + prompt.pop_char(); + } + self.refresh_file_action_status(); + self.error = None; + return Ok(true); + } + KeyCode::Char(c) if !ctrl && !alt => { + if let Some(prompt) = self.pending_file_action.as_mut() { + prompt.push_char(c); + } + self.refresh_file_action_status(); + self.error = None; + return Ok(true); + } + KeyCode::Tab if !ctrl && !alt => { + if let Some(prompt) = self.pending_file_action.as_mut() { + prompt.push_char('\t'); + } + self.refresh_file_action_status(); + self.error = None; + return Ok(true); + } + KeyCode::Delete if !ctrl && !alt => { + if let Some(prompt) = self.pending_file_action.as_mut() { + prompt.set_buffer(String::new()); + } + self.refresh_file_action_status(); + self.error = None; + return Ok(true); + } + _ => {} + } + + Ok(false) + } + + fn apply_pending_file_action(&mut self) -> Result<()> { + let Some(prompt) = self.pending_file_action.take() else { + return Ok(()); + }; + let cloned_prompt = prompt.clone(); + match self.perform_file_action(prompt) { + Ok(message) => { + self.status = message; + self.error = None; + Ok(()) + } + Err(err) => { + self.pending_file_action = Some(cloned_prompt); + self.error = Some(err.to_string()); + Err(err) + } + } + } + + async fn launch_external_editor(&mut self) -> Result<()> { + let Some(selected) = self.selected_file_node() else { + self.status = "No file selected".to_string(); + return Ok(()); + }; + let relative = selected.path.clone(); + let absolute = self.absolute_tree_path(&relative); + let editor = env::var("EDITOR") + .or_else(|_| env::var("VISUAL")) + .unwrap_or_else(|_| "vi".to_string()); + + self.status = format!("Launching {} {}", editor, absolute.display()); + self.error = None; + + let editor_cmd = editor.clone(); + let path_arg = absolute.clone(); + + let raw_mode_disabled = disable_raw_mode().is_ok(); + let join_result = + task::spawn_blocking(move || Command::new(&editor_cmd).arg(&path_arg).status()).await; + if raw_mode_disabled { + let _ = enable_raw_mode(); + } + let join_result = join_result.context("Editor task failed to join")?; + + match join_result { + Ok(status) => { + if status.success() { + self.status = format!( + "Closed {} for {}", + editor, + self.relative_tree_display(&relative) + ); + self.error = None; + } else { + let code = status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()); + self.error = Some(format!("{} exited with status {}", editor, code)); + } + } + Err(err) => { + self.error = Some(format!("Failed to launch {}: {}", editor, err)); + } + } + + match self.file_tree_mut().refresh() { + Ok(()) => { + self.file_tree_mut().reveal(&absolute); + self.ensure_focus_valid(); + } + Err(err) => { + self.error = Some(format!("Failed to refresh file tree: {}", err)); + } + } + + Ok(()) + } + + fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result { + match prompt.kind { + FileActionKind::CreateFile { base } => { + let name = prompt.buffer.trim(); + if name.is_empty() { + return Err(anyhow!("File name cannot be empty")); + } + let name_path = PathBuf::from(name); + validate_relative_path(&name_path, true)?; + let relative = if base.as_os_str().is_empty() { + name_path + } else { + base.join(name_path) + }; + let absolute = self.absolute_tree_path(&relative); + if absolute.exists() { + return Err(anyhow!("{} already exists", absolute.display())); + } + if let Some(parent) = absolute.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create parent directories for {}", + absolute.display() + ) + })?; + } + OpenOptions::new() + .create_new(true) + .write(true) + .open(&absolute) + .with_context(|| format!("Failed to create {}", absolute.display()))?; + self.file_tree_mut() + .refresh() + .context("Failed to refresh file tree")?; + self.file_tree_mut().reveal(&absolute); + self.ensure_focus_valid(); + Ok(format!( + "Created file {}", + self.relative_tree_display(&relative) + )) + } + FileActionKind::CreateFolder { base } => { + let name = prompt.buffer.trim(); + if name.is_empty() { + return Err(anyhow!("Folder name cannot be empty")); + } + let name_path = PathBuf::from(name); + validate_relative_path(&name_path, true)?; + let relative = if base.as_os_str().is_empty() { + name_path + } else { + base.join(name_path) + }; + let absolute = self.absolute_tree_path(&relative); + if absolute.exists() { + return Err(anyhow!("{} already exists", absolute.display())); + } + fs::create_dir_all(&absolute) + .with_context(|| format!("Failed to create {}", absolute.display()))?; + self.file_tree_mut() + .refresh() + .context("Failed to refresh file tree")?; + self.file_tree_mut().reveal(&absolute); + self.ensure_focus_valid(); + Ok(format!( + "Created folder {}", + self.relative_tree_display(&relative) + )) + } + FileActionKind::Rename { original } => { + if original.as_os_str().is_empty() { + return Err(anyhow!("Cannot rename workspace root")); + } + let name = prompt.buffer.trim(); + if name.is_empty() { + return Err(anyhow!("New name cannot be empty")); + } + validate_relative_path(Path::new(name), false)?; + let new_relative = original + .parent() + .map(|parent| { + if parent.as_os_str().is_empty() { + PathBuf::from(name) + } else { + parent.join(name) + } + }) + .unwrap_or_else(|| PathBuf::from(name)); + let source_abs = self.absolute_tree_path(&original); + let target_abs = self.absolute_tree_path(&new_relative); + if target_abs.exists() { + return Err(anyhow!("{} already exists", target_abs.display())); + } + fs::rename(&source_abs, &target_abs).with_context(|| { + format!( + "Failed to rename {} to {}", + source_abs.display(), + target_abs.display() + ) + })?; + self.file_tree_mut() + .refresh() + .context("Failed to refresh file tree")?; + self.file_tree_mut().reveal(&target_abs); + self.ensure_focus_valid(); + Ok(format!( + "Renamed {} to {}", + self.relative_tree_display(&original), + self.relative_tree_display(&new_relative) + )) + } + FileActionKind::Move { original } => { + if original.as_os_str().is_empty() { + return Err(anyhow!("Cannot move workspace root")); + } + let target = prompt.buffer.trim(); + if target.is_empty() { + return Err(anyhow!("Target path cannot be empty")); + } + let target_relative = PathBuf::from(target); + validate_relative_path(&target_relative, true)?; + let source_abs = self.absolute_tree_path(&original); + let target_abs = self.absolute_tree_path(&target_relative); + if target_abs.exists() { + return Err(anyhow!("{} already exists", target_abs.display())); + } + if let Some(parent) = target_abs.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create parent directories for {}", + target_abs.display() + ) + })?; + } + fs::rename(&source_abs, &target_abs).with_context(|| { + format!( + "Failed to move {} to {}", + source_abs.display(), + target_abs.display() + ) + })?; + self.file_tree_mut() + .refresh() + .context("Failed to refresh file tree")?; + self.file_tree_mut().reveal(&target_abs); + self.ensure_focus_valid(); + Ok(format!( + "Moved {} to {}", + self.relative_tree_display(&original), + self.relative_tree_display(&target_relative) + )) + } + FileActionKind::Delete { target, confirm } => { + if target.as_os_str().is_empty() { + return Err(anyhow!("Cannot delete workspace root")); + } + let typed = prompt.buffer.trim(); + if typed != confirm { + return Err(anyhow!("Type '{}' to confirm deletion", confirm)); + } + let absolute = self.absolute_tree_path(&target); + if absolute.is_dir() { + fs::remove_dir_all(&absolute).with_context(|| { + format!("Failed to delete directory {}", absolute.display()) + })?; + } else if absolute.exists() { + fs::remove_file(&absolute) + .with_context(|| format!("Failed to delete file {}", absolute.display()))?; + } else { + return Err(anyhow!("{} does not exist", absolute.display())); + } + self.file_tree_mut() + .refresh() + .context("Failed to refresh file tree")?; + if let Some(parent) = target.parent() { + let parent_abs = if parent.as_os_str().is_empty() { + self.file_tree().root().to_path_buf() + } else { + self.absolute_tree_path(parent) + }; + self.file_tree_mut().reveal(&parent_abs); + } + self.ensure_focus_valid(); + Ok(format!("Deleted {}", self.relative_tree_display(&target))) + } + } + } + + fn reveal_path_in_file_tree(&mut self, path: &Path) { + let absolute = self.absolute_tree_path(path); + self.expand_file_panel(); + self.file_tree_mut().reveal(&absolute); + self.focused_panel = FocusedPanel::Files; + self.ensure_focus_valid(); + let display = absolute + .strip_prefix(self.file_tree().root()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| absolute.to_string_lossy().into_owned()); + self.status = format!("Revealed {}", display); + } + + fn reveal_active_file(&mut self) { + let path_opt = self.code_workspace.active_pane().and_then(|pane| { + pane.absolute_path().map(Path::to_path_buf).or_else(|| { + pane.display_path() + .map(|display| PathBuf::from(display.to_string())) + }) + }); + + match path_opt { + Some(path) => self.reveal_path_in_file_tree(&path), + None => { + self.status = "No active file to reveal".to_string(); + } + } + } + + async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result { + use crossterm::event::{KeyCode, KeyModifiers}; + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let no_modifiers = key.modifiers.is_empty(); + + if self.pending_file_action.is_some() && self.handle_file_action_prompt(key)? { + return Ok(true); + } + + match key.code { + KeyCode::Enter => { + self.open_selected_file_from_tree(FileOpenDisposition::Primary) + .await?; + return Ok(true); + } + KeyCode::Char('o') if no_modifiers => { + self.open_selected_file_from_tree(FileOpenDisposition::SplitHorizontal) + .await?; + return Ok(true); + } + KeyCode::Char('O') if shift && !ctrl && !alt => { + self.open_selected_file_from_tree(FileOpenDisposition::SplitVertical) + .await?; + return Ok(true); + } + KeyCode::Char('t') if no_modifiers => { + self.open_selected_file_from_tree(FileOpenDisposition::Tab) + .await?; + return Ok(true); + } + KeyCode::Char('y') if no_modifiers => { + self.copy_selected_path(false); + return Ok(true); + } + KeyCode::Char('Y') if shift && !ctrl && !alt => { + self.copy_selected_path(true); + return Ok(true); + } + KeyCode::Char('a') if no_modifiers => { + if let Some(selected) = self.selected_file_node() { + let base = if selected.is_dir { + selected.path.clone() + } else { + selected + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(PathBuf::new) + }; + self.begin_file_action(FileActionKind::CreateFile { base }, String::new()); + } else { + self.status = "No file selected".to_string(); + } + return Ok(true); + } + KeyCode::Char('A') if shift && !ctrl && !alt => { + if let Some(selected) = self.selected_file_node() { + let base = if selected.is_dir { + selected.path.clone() + } else { + selected + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(PathBuf::new) + }; + self.begin_file_action(FileActionKind::CreateFolder { base }, String::new()); + } else { + self.status = "No file selected".to_string(); + } + return Ok(true); + } + KeyCode::Char('r') if no_modifiers => { + if let Some(selected) = self.selected_file_node() { + if selected.path.as_os_str().is_empty() { + self.error = Some("Cannot rename workspace root".to_string()); + } else { + let initial = selected + .path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + self.begin_file_action( + FileActionKind::Rename { + original: selected.path.clone(), + }, + initial, + ); + } + } else { + self.status = "No file selected".to_string(); + } + return Ok(true); + } + KeyCode::Char('m') if no_modifiers => { + if let Some(selected) = self.selected_file_node() { + if selected.path.as_os_str().is_empty() { + self.error = Some("Cannot move workspace root".to_string()); + } else { + let initial = self.relative_tree_display(&selected.path); + self.begin_file_action( + FileActionKind::Move { + original: selected.path.clone(), + }, + initial, + ); + } + } else { + self.status = "No file selected".to_string(); + } + return Ok(true); + } + KeyCode::Char('d') if no_modifiers => { + if let Some(selected) = self.selected_file_node() { + if selected.path.as_os_str().is_empty() { + self.error = Some("Cannot delete workspace root".to_string()); + } else { + let confirm = selected + .path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if confirm.is_empty() { + self.error = + Some("Unable to determine file name for confirmation".to_string()); + } else { + self.begin_file_action( + FileActionKind::Delete { + target: selected.path.clone(), + confirm, + }, + String::new(), + ); + } + } + } else { + self.status = "No file selected".to_string(); + } + return Ok(true); + } + KeyCode::Char('.') if no_modifiers => { + self.launch_external_editor().await?; + return Ok(true); + } + KeyCode::Char('/') if !ctrl && !alt => { + { + let tree = self.file_tree_mut(); + tree.toggle_filter_mode(); + } + let mode = match self.file_tree().filter_mode() { + FileFilterMode::Glob => "glob", + FileFilterMode::Fuzzy => "fuzzy", + }; + let query = self.file_tree().filter_query(); + if query.is_empty() { + self.status = format!("Filter mode: {}", mode); + } else { + self.status = format!("Filter mode: {} · {}", mode, query); + } + return Ok(true); + } + KeyCode::Backspace if !ctrl && !alt => { + self.backspace_file_filter(); + return Ok(true); + } + KeyCode::Esc if !ctrl && !alt => { + if !self.file_tree().filter_query().is_empty() { + self.clear_file_filter(); + return Ok(true); + } + } + KeyCode::Char(' ') if !ctrl && !alt => { + self.file_tree_mut().toggle_expand(); + return Ok(true); + } + KeyCode::Char(c) if !ctrl && !alt => { + let reserved = matches!( + (c, shift), + ('o', false) + | ('O', true) + | ('t', false) + | ('y', false) + | ('Y', true) + | ('g', _) + | ('d', _) + | ('m', _) + | ('a', _) + | ('A', true) + | ('r', _) + | ('/', _) + | ('.', _) + ); + if !reserved && !c.is_control() { + self.append_file_filter_char(c); + return Ok(true); + } + } + _ => {} + } + + Ok(false) } fn handle_resize(&mut self, width: u16, _height: u16) { @@ -1333,7 +3002,9 @@ impl ChatApp { self.content_width = approx_content_width.max(20); self.auto_scroll.stick_to_bottom = true; self.thinking_scroll.stick_to_bottom = true; - self.code_view_scroll.stick_to_bottom = false; + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.stick_to_bottom = false; + } } pub async fn initialize_models(&mut self) -> Result<()> { @@ -1392,6 +3063,8 @@ impl ChatApp { match event { Event::Tick => { + self.poll_repo_search(); + self.poll_symbol_search(); // Future: update streaming timers } Event::Resize(width, height) => { @@ -1488,18 +3161,57 @@ impl ChatApp { } } - let is_help_key = matches!(key.code, KeyCode::F(1)); + let is_file_focus_key = matches!(key.code, KeyCode::F(1)); let is_question_mark = matches!( (key.code, key.modifiers), (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) ); + let is_reveal_active = key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + && matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R')); + let is_repo_search = key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + && matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F')); + let is_symbol_search_key = key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')); - if is_help_key || (is_question_mark && matches!(self.mode, InputMode::Normal)) { + if is_file_focus_key && matches!(self.mode, InputMode::Normal) { + self.expand_file_panel(); + self.focused_panel = FocusedPanel::Files; + self.status = "Files panel focused".to_string(); + return Ok(AppState::Running); + } + + if is_reveal_active && matches!(self.mode, InputMode::Normal) { + self.reveal_active_file(); + return Ok(AppState::Running); + } + + if is_question_mark && matches!(self.mode, InputMode::Normal) { self.mode = InputMode::Help; self.status = "Help".to_string(); return Ok(AppState::Running); } + if is_repo_search && matches!(self.mode, InputMode::Normal) { + self.mode = InputMode::RepoSearch; + if self.repo_search.query_input().is_empty() { + *self.repo_search.status_mut() = + Some("Type a pattern · Enter runs ripgrep".to_string()); + } + self.status = "Repo search active".to_string(); + return Ok(AppState::Running); + } + + if is_symbol_search_key && matches!(self.mode, InputMode::Normal) { + self.mode = InputMode::SymbolSearch; + self.symbol_search.clear_query(); + self.status = "Symbol search active".to_string(); + self.start_symbol_search().await?; + return Ok(AppState::Running); + } + match self.mode { InputMode::Normal => { // Handle multi-key sequences first @@ -1514,12 +3226,53 @@ impl ChatApp { return Ok(AppState::Running); } + if let Some(started) = self.pending_focus_chord { + if started.elapsed() > FOCUS_CHORD_TIMEOUT { + self.pending_focus_chord = None; + } else if key.modifiers.is_empty() { + let direction = match key.code { + KeyCode::Left => Some(PaneDirection::Left), + KeyCode::Right => Some(PaneDirection::Right), + KeyCode::Up => Some(PaneDirection::Up), + KeyCode::Down => Some(PaneDirection::Down), + _ => None, + }; + if let Some(direction) = direction { + self.handle_workspace_focus_move(direction); + return Ok(AppState::Running); + } else { + self.pending_focus_chord = None; + } + } else { + self.pending_focus_chord = None; + } + } + if let Some(pending) = self.pending_key { self.pending_key = None; match (pending, key.code) { ('g', KeyCode::Char('g')) => { self.jump_to_top(); } + ('g', KeyCode::Char('T')) | ('g', KeyCode::Char('t')) => { + self.expand_file_panel(); + self.focused_panel = FocusedPanel::Files; + self.status = "Files panel focused".to_string(); + } + ('g', KeyCode::Char('h')) | ('g', KeyCode::Char('H')) => { + if matches!(self.focused_panel, FocusedPanel::Files) { + self.toggle_hidden_files(); + } else { + self.status = + "Toggle hidden files from the Files panel".to_string(); + } + } + ('W', KeyCode::Char('s')) | ('W', KeyCode::Char('S')) => { + self.split_active_pane(SplitAxis::Horizontal); + } + ('W', KeyCode::Char('v')) | ('W', KeyCode::Char('V')) => { + self.split_active_pane(SplitAxis::Vertical); + } ('d', KeyCode::Char('d')) => { // Clear input buffer self.input_buffer_mut().clear(); @@ -1534,7 +3287,40 @@ impl ChatApp { return Ok(AppState::Running); } + if matches!(self.focused_panel, FocusedPanel::Files) + && self.handle_file_panel_key(&key).await? + { + return Ok(AppState::Running); + } + + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('w') | KeyCode::Char('W')) + { + self.pending_key = Some('W'); + self.status = + "Split layout: press s for horizontal, v for vertical".to_string(); + return Ok(AppState::Running); + } + match (key.code, key.modifiers) { + (KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => { + self.handle_workspace_resize(PaneDirection::Left); + return Ok(AppState::Running); + } + (KeyCode::Right, modifiers) + if modifiers.contains(KeyModifiers::ALT) => + { + self.handle_workspace_resize(PaneDirection::Right); + return Ok(AppState::Running); + } + (KeyCode::Up, modifiers) if modifiers.contains(KeyModifiers::ALT) => { + self.handle_workspace_resize(PaneDirection::Up); + return Ok(AppState::Running); + } + (KeyCode::Down, modifiers) if modifiers.contains(KeyModifiers::ALT) => { + self.handle_workspace_resize(PaneDirection::Down); + return Ok(AppState::Running); + } (KeyCode::Char('q'), KeyModifiers::NONE) => { return Ok(AppState::Quit); } @@ -1557,9 +3343,12 @@ impl ChatApp { (KeyCode::Char('k'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.pending_focus_chord = Some(Instant::now()); + self.status = "Pane focus pending — use ←/→/↑/↓".to_string(); if self.show_model_info && self.model_info_viewport_height > 0 { self.model_info_panel.scroll_up(); } + return Ok(AppState::Running); } // Mode switches (KeyCode::Char('v'), KeyModifiers::NONE) => { @@ -1595,6 +3384,7 @@ impl ChatApp { self.visual_start = Some(cursor); self.visual_end = Some(cursor); } + FocusedPanel::Files => {} FocusedPanel::Code => {} } self.status = @@ -1667,10 +3457,15 @@ impl ChatApp { } } } + FocusedPanel::Files => { + self.file_tree_mut().move_cursor(-1); + } FocusedPanel::Code => { - let viewport = self.code_view_viewport_height.max(1); - if self.code_view_scroll.scroll > 0 { - self.code_view_scroll.on_user_scroll(-1, viewport); + let viewport = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() + && scroll.scroll > 0 + { + scroll.on_user_scroll(-1, viewport); } } FocusedPanel::Input => { @@ -1704,11 +3499,16 @@ impl ChatApp { } } } + FocusedPanel::Files => { + self.file_tree_mut().move_cursor(1); + } FocusedPanel::Code => { - let viewport = self.code_view_viewport_height.max(1); - let max_lines = self.code_view_scroll.content_len; - if self.code_view_scroll.scroll + viewport < max_lines { - self.code_view_scroll.on_user_scroll(1, viewport); + let viewport = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + let max_lines = scroll.content_len; + if scroll.scroll + viewport < max_lines { + scroll.on_user_scroll(1, viewport); + } } } FocusedPanel::Input => { @@ -1878,10 +3678,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.jump_to_bottom(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().jump_to_bottom(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport = self.code_view_viewport_height.max(1); - self.code_view_scroll.jump_to_bottom(viewport); + let viewport = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.jump_to_bottom(viewport); } } FocusedPanel::Input => {} @@ -1943,6 +3746,7 @@ impl ChatApp { (KeyCode::Tab, KeyModifiers::NONE) => { self.cycle_focus_forward(); let panel_name = match self.focused_panel { + FocusedPanel::Files => "Files", FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", @@ -1953,6 +3757,7 @@ impl ChatApp { (KeyCode::BackTab, KeyModifiers::SHIFT) => { self.cycle_focus_backward(); let panel_name = match self.focused_panel { + FocusedPanel::Files => "Files", FocusedPanel::Chat => "Chat", FocusedPanel::Thinking => "Thinking", FocusedPanel::Input => "Input", @@ -1975,6 +3780,134 @@ impl ChatApp { } } } + InputMode::RepoSearch => match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.mode = InputMode::Normal; + self.status = "Normal mode".to_string(); + } + (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => { + self.open_repo_search_scratch().await?; + } + (KeyCode::Enter, _) => { + if self.repo_search.running() { + self.status = "Search already running".to_string(); + } else if self.repo_search.dirty() || !self.repo_search.has_results() { + self.start_repo_search().await?; + } else { + self.open_repo_search_match().await?; + } + } + (KeyCode::Backspace, modifiers) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.repo_search.pop_query_char(); + *self.repo_search.status_mut() = + Some("Press Enter to search".to_string()); + self.status = format!("Query: {}", self.repo_search.query_input()); + } + (KeyCode::Char('u'), modifiers) + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.repo_search.clear_query(); + *self.repo_search.status_mut() = Some("Query cleared".to_string()); + self.status = "Query cleared".to_string(); + } + (KeyCode::Delete, _) => { + self.repo_search.clear_query(); + *self.repo_search.status_mut() = Some("Query cleared".to_string()); + self.status = "Query cleared".to_string(); + } + (KeyCode::Char(c), modifiers) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + && !c.is_control() => + { + self.repo_search.push_query_char(c); + *self.repo_search.status_mut() = + Some("Press Enter to search".to_string()); + self.status = format!("Query: {}", self.repo_search.query_input()); + } + (KeyCode::Up, _) + | (KeyCode::Char('k'), KeyModifiers::NONE) + | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.repo_search.move_selection(-1); + } + (KeyCode::Down, _) + | (KeyCode::Char('j'), KeyModifiers::NONE) + | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + self.repo_search.move_selection(1); + } + (KeyCode::PageUp, _) => { + self.repo_search.page(-1); + } + (KeyCode::PageDown, _) => { + self.repo_search.page(1); + } + (KeyCode::Home, _) => { + self.repo_search.scroll_to(0); + } + (KeyCode::End, _) => { + let max = self.repo_search.max_scroll(); + self.repo_search.scroll_to(max); + } + _ => {} + }, + InputMode::SymbolSearch => match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.mode = InputMode::Normal; + self.status = "Normal mode".to_string(); + } + (KeyCode::Enter, _) => { + if self.symbol_search.is_running() { + self.status = "Symbol index still building".to_string(); + } else { + self.open_symbol_search_entry().await?; + } + } + (KeyCode::Backspace, modifiers) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.symbol_search.pop_query_char(); + self.status = format!("Symbol filter: {}", self.symbol_search.query()); + } + (KeyCode::Char('u'), modifiers) + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.symbol_search.clear_query(); + self.status = "Symbol query cleared".to_string(); + } + (KeyCode::Delete, _) => { + self.symbol_search.clear_query(); + self.status = "Symbol query cleared".to_string(); + } + (KeyCode::Char(c), modifiers) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + && !c.is_control() => + { + self.symbol_search.push_query_char(c); + self.status = format!("Symbol filter: {}", self.symbol_search.query()); + } + (KeyCode::Up, _) + | (KeyCode::Char('k'), KeyModifiers::NONE) + | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.symbol_search.move_selection(-1); + } + (KeyCode::Down, _) + | (KeyCode::Char('j'), KeyModifiers::NONE) + | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + self.symbol_search.move_selection(1); + } + (KeyCode::PageUp, _) => { + self.symbol_search.page(-1); + } + (KeyCode::PageDown, _) => { + self.symbol_search.page(1); + } + _ => {} + }, InputMode::Editing => match (key.code, key.modifiers) { (KeyCode::Char('c'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => @@ -2092,6 +4025,7 @@ impl ChatApp { self.status = "Nothing to yank".to_string(); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } self.mode = InputMode::Normal; @@ -2125,6 +4059,7 @@ impl ChatApp { self.status = "Nothing to yank".to_string(); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } self.mode = InputMode::Normal; @@ -2145,6 +4080,7 @@ impl ChatApp { self.visual_end = Some((row, col - 1)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2159,6 +4095,7 @@ impl ChatApp { self.visual_end = Some((row, col + 1)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2177,6 +4114,7 @@ impl ChatApp { self.on_scroll(-1); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2202,6 +4140,7 @@ impl ChatApp { } } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2220,6 +4159,7 @@ impl ChatApp { self.visual_end = Some((row, new_col)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2238,6 +4178,7 @@ impl ChatApp { self.visual_end = Some((row, new_col)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2252,6 +4193,7 @@ impl ChatApp { self.visual_end = Some((row, 0)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2269,6 +4211,7 @@ impl ChatApp { self.visual_end = Some((row, line_len)); } } + FocusedPanel::Files => {} FocusedPanel::Code => {} } } @@ -2382,8 +4325,11 @@ impl ChatApp { } else { match self.controller.read_file_with_tools(path).await { Ok(content) => { + let absolute = + self.absolute_tree_path(Path::new(path)); self.set_code_view_content( path.to_string(), + Some(absolute), content, ); self.focused_panel = FocusedPanel::Code; @@ -2406,7 +4352,7 @@ impl ChatApp { } } "close" => { - if self.code_view_path.is_some() { + if self.has_loaded_code_view() { self.close_code_view(); self.status = "Closed code view".to_string(); self.set_system_status(String::new()); @@ -3219,10 +5165,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.on_user_scroll(delta, viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().move_cursor(delta); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.on_user_scroll(delta, viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.on_user_scroll(delta, viewport_height); } } FocusedPanel::Input => { @@ -3242,10 +5191,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_down(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().page_down(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.scroll_half_page_down(viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.scroll_half_page_down(viewport_height); } } FocusedPanel::Input => {} @@ -3263,10 +5215,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_half_page_up(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().page_up(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.scroll_half_page_up(viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.scroll_half_page_up(viewport_height); } } FocusedPanel::Input => {} @@ -3284,10 +5239,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_down(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().page_down(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.scroll_full_page_down(viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.scroll_full_page_down(viewport_height); } } FocusedPanel::Input => {} @@ -3305,10 +5263,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.scroll_full_page_up(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().page_up(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.scroll_full_page_up(viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.scroll_full_page_up(viewport_height); } } FocusedPanel::Input => {} @@ -3325,9 +5286,12 @@ impl ChatApp { FocusedPanel::Thinking => { self.thinking_scroll.jump_to_top(); } + FocusedPanel::Files => { + self.file_tree_mut().jump_to_top(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - self.code_view_scroll.jump_to_top(); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.jump_to_top(); } } FocusedPanel::Input => {} @@ -3356,10 +5320,13 @@ impl ChatApp { let viewport_height = self.thinking_viewport_height.max(1); self.thinking_scroll.jump_to_bottom(viewport_height); } + FocusedPanel::Files => { + self.file_tree_mut().jump_to_bottom(); + } FocusedPanel::Code => { - if self.code_view_path.is_some() { - let viewport_height = self.code_view_viewport_height.max(1); - self.code_view_scroll.jump_to_bottom(viewport_height); + let viewport_height = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.jump_to_bottom(viewport_height); } } FocusedPanel::Input => {} @@ -4209,8 +6176,6 @@ impl ChatApp { self.auto_scroll = AutoScroll::default(); self.thinking_scroll = AutoScroll::default(); - self.code_view_scroll = AutoScroll::default(); - self.code_view_viewport_height = 0; self.chat_cursor = (0, 0); self.thinking_cursor = (0, 0); @@ -4852,9 +6817,10 @@ impl ChatApp { Vec::new() } } + FocusedPanel::Files => Vec::new(), FocusedPanel::Code => { - if self.code_view_path.is_some() { - self.code_view_lines + if self.has_loaded_code_view() { + self.code_view_lines() .iter() .enumerate() .map(|(idx, line)| format!("{:>4} {}", idx + 1, line)) @@ -5638,6 +7604,39 @@ mod tests { } } +fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> { + if path.as_os_str().is_empty() { + return Err(anyhow!("Path cannot be empty")); + } + if path.is_absolute() { + return Err(anyhow!("Path must be relative to the workspace root")); + } + + let mut normal_segments = 0usize; + for component in path.components() { + match component { + Component::Normal(_) => { + normal_segments += 1; + } + Component::CurDir => { + return Err(anyhow!("Path cannot contain '.' segments")); + } + Component::ParentDir => { + return Err(anyhow!("Path cannot contain '..' segments")); + } + Component::RootDir | Component::Prefix(_) => { + return Err(anyhow!("Path must be relative to the workspace root")); + } + } + } + + if !allow_nested && normal_segments > 1 { + return Err(anyhow!("Name cannot include path separators")); + } + + Ok(()) +} + fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { textarea.set_placeholder_text("Type your message here..."); textarea.set_tab_length(4); diff --git a/crates/owlen-tui/src/state/file_tree.rs b/crates/owlen-tui/src/state/file_tree.rs new file mode 100644 index 0000000..db4558e --- /dev/null +++ b/crates/owlen-tui/src/state/file_tree.rs @@ -0,0 +1,705 @@ +use crate::commands; +use anyhow::{Context, Result}; +use globset::{Glob, GlobBuilder, GlobSetBuilder}; +use ignore::WalkBuilder; +use pathdiff::diff_paths; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Indicates which matching strategy is applied when filtering the file tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterMode { + Glob, + Fuzzy, +} + +/// Git-related decorations rendered alongside a file entry. +#[derive(Debug, Clone)] +pub struct GitDecoration { + pub badge: Option, + pub cleanliness: char, +} + +impl GitDecoration { + pub fn clean() -> Self { + Self { + badge: None, + cleanliness: '✓', + } + } + + pub fn staged(badge: Option) -> Self { + Self { + badge, + cleanliness: '○', + } + } + + pub fn dirty(badge: Option) -> Self { + Self { + badge, + cleanliness: '●', + } + } +} + +/// Node representing a single entry (file or directory) in the tree. +#[derive(Debug, Clone)] +pub struct FileNode { + pub name: String, + pub path: PathBuf, + pub parent: Option, + pub children: Vec, + pub depth: usize, + pub is_dir: bool, + pub is_expanded: bool, + pub is_hidden: bool, + pub git: GitDecoration, +} + +impl FileNode { + fn should_default_expand(&self) -> bool { + self.depth < 2 + } +} + +/// Visible entry metadata returned to the renderer. +#[derive(Debug, Clone)] +pub struct VisibleFileEntry { + pub index: usize, + pub depth: usize, +} + +/// Tracks the entire file tree state including filters, selection, and scroll. +#[derive(Debug, Clone)] +pub struct FileTreeState { + root: PathBuf, + repo_name: String, + nodes: Vec, + visible: Vec, + cursor: usize, + scroll_top: usize, + viewport_height: usize, + filter_mode: FilterMode, + filter_query: String, + show_hidden: bool, + filter_matches: Vec, + last_error: Option, + git_branch: Option, +} + +impl FileTreeState { + /// Construct a new file tree rooted at the provided path. + pub fn new(root: impl Into) -> Self { + let mut root_path = root.into(); + if let Ok(canonical) = root_path.canonicalize() { + root_path = canonical; + } + let repo_name = root_path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| root_path.display().to_string()); + + let mut state = Self { + root: root_path, + repo_name, + nodes: Vec::new(), + visible: Vec::new(), + cursor: 0, + scroll_top: 0, + viewport_height: 20, + filter_mode: FilterMode::Fuzzy, + filter_query: String::new(), + show_hidden: false, + filter_matches: Vec::new(), + last_error: None, + git_branch: None, + }; + + if let Err(err) = state.refresh() { + state.nodes.clear(); + state.visible.clear(); + state.filter_matches.clear(); + state.last_error = Some(err.to_string()); + } + + state + } + + /// Rebuild the file tree from disk and recompute visibility. + pub fn refresh(&mut self) -> Result<()> { + let git_map = collect_git_status(&self.root).unwrap_or_default(); + self.nodes = build_nodes(&self.root, self.show_hidden, git_map)?; + self.git_branch = current_git_branch(&self.root).unwrap_or(None); + if self.nodes.is_empty() { + self.visible.clear(); + self.filter_matches.clear(); + self.cursor = 0; + return Ok(()); + } + self.ensure_valid_cursor(); + self.recompute_filter_cache(); + self.rebuild_visible(); + Ok(()) + } + + pub fn repo_name(&self) -> &str { + &self.repo_name + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn is_empty(&self) -> bool { + self.visible.is_empty() + } + + pub fn visible_entries(&self) -> &[VisibleFileEntry] { + &self.visible + } + + pub fn nodes(&self) -> &[FileNode] { + &self.nodes + } + + pub fn selected_index(&self) -> Option { + self.visible.get(self.cursor).map(|entry| entry.index) + } + + pub fn selected_node(&self) -> Option<&FileNode> { + self.selected_index().and_then(|idx| self.nodes.get(idx)) + } + + pub fn selected_node_mut(&mut self) -> Option<&mut FileNode> { + let idx = self.selected_index()?; + self.nodes.get_mut(idx) + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn scroll_top(&self) -> usize { + self.scroll_top + } + + pub fn viewport_height(&self) -> usize { + self.viewport_height + } + + pub fn filter_mode(&self) -> FilterMode { + self.filter_mode + } + + pub fn filter_query(&self) -> &str { + &self.filter_query + } + + pub fn show_hidden(&self) -> bool { + self.show_hidden + } + + pub fn git_branch(&self) -> Option<&str> { + self.git_branch.as_deref() + } + + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + pub fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height.max(1); + self.ensure_cursor_in_view(); + } + + pub fn move_cursor(&mut self, delta: isize) { + if self.visible.is_empty() { + self.cursor = 0; + self.scroll_top = 0; + return; + } + + let len = self.visible.len() as isize; + let new_cursor = (self.cursor as isize + delta).clamp(0, len - 1) as usize; + self.cursor = new_cursor; + self.ensure_cursor_in_view(); + } + + pub fn jump_to_top(&mut self) { + if !self.visible.is_empty() { + self.cursor = 0; + self.scroll_top = 0; + } + } + + pub fn jump_to_bottom(&mut self) { + if !self.visible.is_empty() { + self.cursor = self.visible.len().saturating_sub(1); + let viewport = self.viewport_height.max(1); + self.scroll_top = self.visible.len().saturating_sub(viewport); + } + } + + pub fn page_down(&mut self) { + let amount = self.viewport_height.max(1) as isize; + self.move_cursor(amount); + } + + pub fn page_up(&mut self) { + let amount = -(self.viewport_height.max(1) as isize); + self.move_cursor(amount); + } + + pub fn toggle_expand(&mut self) { + if let Some(node) = self.selected_node_mut() { + if !node.is_dir { + return; + } + node.is_expanded = !node.is_expanded; + self.rebuild_visible(); + } + } + + pub fn set_filter_query(&mut self, query: impl Into) { + self.filter_query = query.into(); + self.recompute_filter_cache(); + self.rebuild_visible(); + } + + pub fn clear_filter(&mut self) { + self.filter_query.clear(); + self.recompute_filter_cache(); + self.rebuild_visible(); + } + + pub fn toggle_filter_mode(&mut self) { + self.filter_mode = match self.filter_mode { + FilterMode::Glob => FilterMode::Fuzzy, + FilterMode::Fuzzy => FilterMode::Glob, + }; + self.recompute_filter_cache(); + self.rebuild_visible(); + } + + pub fn toggle_hidden(&mut self) -> Result<()> { + self.show_hidden = !self.show_hidden; + self.refresh() + } + + /// Expand directories along the provided path and position the cursor. + pub fn reveal(&mut self, path: &Path) { + if self.nodes.is_empty() { + return; + } + + if let Some(rel) = diff_paths(path, &self.root) + && let Some(index) = self + .nodes + .iter() + .position(|node| node.path == rel || node.path == path) + { + self.expand_to(index); + if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) { + self.cursor = cursor_pos; + self.ensure_cursor_in_view(); + } + } + } + + fn expand_to(&mut self, index: usize) { + let mut current = Some(index); + while let Some(idx) = current { + if let Some(parent) = self.nodes.get(idx).and_then(|node| node.parent) { + if let Some(parent_node) = self.nodes.get_mut(parent) { + parent_node.is_expanded = true; + } + current = Some(parent); + } else { + current = None; + } + } + self.rebuild_visible(); + } + + fn ensure_valid_cursor(&mut self) { + if self.cursor >= self.visible.len() { + self.cursor = self.visible.len().saturating_sub(1); + } + } + + fn ensure_cursor_in_view(&mut self) { + if self.visible.is_empty() { + self.cursor = 0; + self.scroll_top = 0; + return; + } + + let viewport = self.viewport_height.max(1); + if self.cursor < self.scroll_top { + self.scroll_top = self.cursor; + } else if self.cursor >= self.scroll_top + viewport { + self.scroll_top = self.cursor + 1 - viewport; + } + } + + fn recompute_filter_cache(&mut self) { + let has_filter = !self.filter_query.trim().is_empty(); + self.filter_matches = if !has_filter { + vec![true; self.nodes.len()] + } else { + self.nodes + .iter() + .map(|node| match self.filter_mode { + FilterMode::Glob => glob_matches(self.filter_query.trim(), node), + FilterMode::Fuzzy => fuzzy_matches(self.filter_query.trim(), node), + }) + .collect() + }; + + if has_filter { + // Ensure parent directories of matches are preserved. + for idx in (0..self.nodes.len()).rev() { + let children = self.nodes[idx].children.clone(); + if !self.filter_matches[idx] + && children + .iter() + .any(|child| self.filter_matches.get(*child).copied().unwrap_or(false)) + { + self.filter_matches[idx] = true; + } + } + } + } + + fn rebuild_visible(&mut self) { + self.visible.clear(); + + if self.nodes.is_empty() { + self.cursor = 0; + self.scroll_top = 0; + return; + } + + let has_filter = !self.filter_query.trim().is_empty(); + self.walk_visible(0, has_filter); + if self.visible.is_empty() { + // At minimum show the root node. + self.visible.push(VisibleFileEntry { + index: 0, + depth: self.nodes[0].depth, + }); + } + let max_index = self.visible.len().saturating_sub(1); + self.cursor = self.cursor.min(max_index); + self.ensure_cursor_in_view(); + } + + fn walk_visible(&mut self, index: usize, filter_override: bool) { + if !self.filter_matches.get(index).copied().unwrap_or(true) { + return; + } + + let (depth, descend, children) = { + let node = match self.nodes.get(index) { + Some(node) => node, + None => return, + }; + let descend = if filter_override { + node.is_dir + } else { + node.is_dir && node.is_expanded + }; + let children = if node.is_dir { + node.children.clone() + } else { + Vec::new() + }; + (node.depth, descend, children) + }; + + self.visible.push(VisibleFileEntry { index, depth }); + + if descend { + for child in children { + self.walk_visible(child, filter_override); + } + } + } +} + +fn glob_matches(pattern: &str, node: &FileNode) -> bool { + if pattern.is_empty() { + return true; + } + + let mut builder = GlobSetBuilder::new(); + match GlobBuilder::new(pattern).literal_separator(true).build() { + Ok(glob) => { + builder.add(glob); + if let Ok(set) = builder.build() { + return set.is_match(&node.path) || set.is_match(node.name.as_str()); + } + } + Err(_) => { + if let Ok(glob) = Glob::new("**") { + builder.add(glob); + if let Ok(set) = builder.build() { + return set.is_match(&node.path); + } + } + } + } + + false +} + +fn fuzzy_matches(query: &str, node: &FileNode) -> bool { + if query.is_empty() { + return true; + } + + let path_str = node.path.to_string_lossy(); + let name = node.name.as_str(); + + commands::match_score(&path_str, query) + .or_else(|| commands::match_score(name, query)) + .is_some() +} + +fn build_nodes( + root: &Path, + show_hidden: bool, + git_map: HashMap, +) -> Result> { + let mut builder = WalkBuilder::new(root); + builder.hidden(!show_hidden); + builder.git_global(true); + builder.git_ignore(true); + builder.git_exclude(true); + builder.follow_links(false); + builder.sort_by_file_path(|a, b| a.file_name().cmp(&b.file_name())); + + let owlen_ignore = root.join(".owlenignore"); + if owlen_ignore.exists() { + builder.add_ignore(&owlen_ignore); + } + + let mut nodes: Vec = Vec::new(); + let mut index_by_path: HashMap = HashMap::new(); + + for result in builder.build() { + let entry = match result { + Ok(value) => value, + Err(err) => { + eprintln!("File tree walk error: {err}"); + continue; + } + }; + + // Skip errors or entries without metadata. + let file_type = match entry.file_type() { + Some(ft) => ft, + None => continue, + }; + + let depth = entry.depth(); + if depth == 0 && !file_type.is_dir() { + continue; + } + + let relative = if depth == 0 { + PathBuf::new() + } else { + diff_paths(entry.path(), root).unwrap_or_else(|| entry.path().to_path_buf()) + }; + + let name = if depth == 0 { + root.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| root.display().to_string()) + } else { + entry.file_name().to_string_lossy().into_owned() + }; + + let parent = if depth == 0 { + None + } else { + entry + .path() + .parent() + .and_then(|parent| diff_paths(parent, root)) + .and_then(|rel_parent| index_by_path.get(&rel_parent).copied()) + }; + + let git = git_map + .get(&relative) + .cloned() + .unwrap_or_else(GitDecoration::clean); + + let mut node = FileNode { + name, + path: relative.clone(), + parent, + children: Vec::new(), + depth, + is_dir: file_type.is_dir(), + is_expanded: false, + is_hidden: is_hidden(entry.file_name()), + git, + }; + + node.is_expanded = node.should_default_expand(); + + let index = nodes.len(); + if let Some(parent_idx) = parent + && let Some(parent_node) = nodes.get_mut(parent_idx) + { + parent_node.children.push(index); + } + + index_by_path.insert(relative, index); + nodes.push(node); + } + + propagate_directory_git_state(&mut nodes); + Ok(nodes) +} + +fn is_hidden(name: &OsStr) -> bool { + name.to_string_lossy().starts_with('.') +} + +fn propagate_directory_git_state(nodes: &mut [FileNode]) { + for idx in (0..nodes.len()).rev() { + if !nodes[idx].is_dir { + continue; + } + let mut has_dirty = false; + let mut has_staged = false; + for child in nodes[idx].children.clone() { + match nodes.get(child).map(|n| n.git.cleanliness) { + Some('●') => { + has_dirty = true; + break; + } + Some('○') => { + has_staged = true; + } + _ => {} + } + } + + nodes[idx].git = if has_dirty { + GitDecoration::dirty(None) + } else if has_staged { + GitDecoration::staged(None) + } else { + GitDecoration::clean() + }; + } +} + +fn collect_git_status(root: &Path) -> Result> { + if !root.join(".git").exists() { + return Ok(HashMap::new()); + } + + let output = Command::new("git") + .arg("-C") + .arg(root) + .arg("status") + .arg("--porcelain") + .output() + .with_context(|| format!("Failed to run git status in {}", root.display()))?; + + if !output.status.success() { + return Ok(HashMap::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut map = HashMap::new(); + + for line in stdout.lines() { + if line.len() < 3 { + continue; + } + + let mut chars = line.chars(); + let x = chars.next().unwrap_or(' '); + let y = chars.next().unwrap_or(' '); + if x == '!' || y == '!' { + // ignored entry + continue; + } + + let mut path_part = line[3..].trim(); + if let Some(idx) = path_part.rfind(" -> ") { + path_part = &path_part[idx + 4..]; + } + + let path = PathBuf::from(path_part); + + if let Some(decoration) = decode_git_status(x, y) { + map.insert(path, decoration); + } + } + + Ok(map) +} + +fn current_git_branch(root: &Path) -> Result> { + if !root.join(".git").exists() { + return Ok(None); + } + + let output = Command::new("git") + .arg("-C") + .arg(root) + .arg("rev-parse") + .arg("--abbrev-ref") + .arg("HEAD") + .output() + .with_context(|| format!("Failed to query git branch in {}", root.display()))?; + + if !output.status.success() { + return Ok(None); + } + + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() { + Ok(None) + } else { + Ok(Some(branch)) + } +} + +fn decode_git_status(x: char, y: char) -> Option { + if x == ' ' && y == ' ' { + return Some(GitDecoration::clean()); + } + + if x == '?' && y == '?' { + return Some(GitDecoration::dirty(Some('A'))); + } + + let badge = match (x, y) { + ('M', _) | (_, 'M') => Some('M'), + ('A', _) | (_, 'A') => Some('A'), + ('D', _) | (_, 'D') => Some('D'), + ('R', _) | (_, 'R') => Some('R'), + ('C', _) | (_, 'C') => Some('A'), + ('U', _) | (_, 'U') => Some('U'), + _ => None, + }; + + if y != ' ' { + Some(GitDecoration::dirty(badge)) + } else if x != ' ' { + Some(GitDecoration::staged(badge)) + } else { + Some(GitDecoration::clean()) + } +} diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index d184d9d..e7f5916 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -6,5 +6,20 @@ //! to test in isolation. mod command_palette; +mod file_tree; +mod search; +mod workspace; pub use command_palette::{CommandPalette, ModelPaletteEntry}; +pub use file_tree::{ + FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, +}; +pub use search::{ + RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind, + RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState, + spawn_repo_search_task, spawn_symbol_search_task, +}; +pub use workspace::{ + CodePane, CodeWorkspace, EditorTab, LayoutNode, PaneDirection, PaneId, PaneRestoreRequest, + SplitAxis, WorkspaceSnapshot, +}; diff --git a/crates/owlen-tui/src/state/search.rs b/crates/owlen-tui/src/state/search.rs new file mode 100644 index 0000000..6eb9e9e --- /dev/null +++ b/crates/owlen-tui/src/state/search.rs @@ -0,0 +1,1056 @@ +use crate::commands; +use anyhow::{Context, Result, anyhow}; +use ignore::WalkBuilder; +use pathdiff::diff_paths; +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, BufReader}, + process::{ChildStderr, Command}, + sync::mpsc, + task::JoinHandle, +}; +use tree_sitter::{Node, Parser, TreeCursor}; + +/// Single match returned from a repository-wide search. +#[derive(Debug, Clone)] +pub struct RepoSearchMatch { + pub line_number: u32, + pub column: u32, + pub preview: String, + pub matched: Option, +} + +/// Aggregated matches for a single file path. +#[derive(Debug, Clone)] +pub struct RepoSearchFile { + pub absolute: PathBuf, + pub display: String, + pub matches: Vec, +} + +/// Logical row rendered in the repo-search results list. +#[derive(Debug, Clone)] +pub struct RepoSearchRow { + pub file_index: usize, + pub kind: RepoSearchRowKind, +} + +/// Row variants used to render headers and match lines. +#[derive(Debug, Clone)] +pub enum RepoSearchRowKind { + FileHeader, + Match { match_index: usize }, +} + +/// UI state for the ripgrep-backed repository search overlay. +#[derive(Debug, Clone, Default)] +pub struct RepoSearchState { + query_input: String, + last_query: Option, + files: Vec, + rows: Vec, + selected_row: usize, + scroll_top: usize, + viewport_height: usize, + running: bool, + dirty: bool, + status: Option, + error: Option, +} + +impl RepoSearchState { + pub fn new() -> Self { + Self::default() + } + + pub fn reset(&mut self) { + self.query_input.clear(); + self.last_query = None; + self.files.clear(); + self.rows.clear(); + self.selected_row = 0; + self.scroll_top = 0; + self.viewport_height = 0; + self.running = false; + self.dirty = false; + self.status = None; + self.error = None; + } + + pub fn query_input(&self) -> &str { + &self.query_input + } + + pub fn set_query_input(&mut self, value: impl Into) { + self.query_input = value.into(); + self.dirty = true; + } + + pub fn push_query_char(&mut self, ch: char) { + self.query_input.push(ch); + self.dirty = true; + } + + pub fn pop_query_char(&mut self) { + self.query_input.pop(); + self.dirty = true; + } + + pub fn clear_query(&mut self) { + self.query_input.clear(); + self.last_query = None; + self.dirty = true; + } + + /// Prepare to launch a new search. Returns the trimmed query string if work should start. + pub fn prepare_run(&mut self) -> Option { + if self.running { + return None; + } + + let trimmed = self.query_input.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed.to_string(); + self.query_input = normalized.clone(); + self.last_query = Some(normalized.clone()); + self.files.clear(); + self.rows.clear(); + self.selected_row = 0; + self.scroll_top = 0; + self.running = true; + self.dirty = false; + self.status = Some(format!("Searching for \"{normalized}\"…")); + self.error = None; + Some(normalized) + } + + pub fn add_file(&mut self, absolute: PathBuf, display: String) -> usize { + let index = self.files.len(); + self.files.push(RepoSearchFile { + absolute, + display, + matches: Vec::new(), + }); + self.rows.push(RepoSearchRow { + file_index: index, + kind: RepoSearchRowKind::FileHeader, + }); + if self.rows.len() == 1 { + self.selected_row = 0; + } + index + } + + pub fn ensure_file_entry(&mut self, absolute: PathBuf, display: String) -> usize { + if let Some((idx, _)) = self + .files + .iter() + .enumerate() + .find(|(_, f)| f.absolute == absolute) + { + return idx; + } + self.add_file(absolute, display) + } + + pub fn add_match( + &mut self, + file_index: usize, + line_number: u32, + column: u32, + preview: String, + matched: Option, + ) { + if let Some(file) = self.files.get_mut(file_index) { + let match_index = file.matches.len(); + file.matches.push(RepoSearchMatch { + line_number, + column, + preview, + matched, + }); + self.rows.push(RepoSearchRow { + file_index, + kind: RepoSearchRowKind::Match { match_index }, + }); + + if matches!( + self.rows[self.selected_row].kind, + RepoSearchRowKind::FileHeader + ) && let Some(idx) = self + .rows + .iter() + .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) + { + self.selected_row = idx; + } + self.ensure_selection_visible(); + } + } + + pub fn finish(&mut self, match_count: usize) { + self.running = false; + if match_count == 0 { + self.status = Some("No matches".to_string()); + } else { + self.status = Some(format!("{match_count} match(es)")); + } + } + + pub fn mark_error(&mut self, message: impl Into) { + self.running = false; + self.error = Some(message.into()); + } + + pub fn running(&self) -> bool { + self.running + } + + pub fn dirty(&self) -> bool { + self.dirty + } + + pub fn last_query(&self) -> Option<&str> { + self.last_query.as_deref() + } + + pub fn files(&self) -> &[RepoSearchFile] { + &self.files + } + + pub fn rows(&self) -> &[RepoSearchRow] { + &self.rows + } + + pub fn status(&self) -> Option<&String> { + self.status.as_ref() + } + + pub fn status_mut(&mut self) -> &mut Option { + &mut self.status + } + + pub fn error(&self) -> Option<&String> { + self.error.as_ref() + } + + pub fn viewport_height(&self) -> usize { + self.viewport_height + } + + pub fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height; + if height == 0 { + self.scroll_top = 0; + return; + } + + let max_scroll = self.rows.len().saturating_sub(height); + if self.scroll_top > max_scroll { + self.scroll_top = max_scroll; + } + self.ensure_selection_visible(); + } + + pub fn scroll_top(&self) -> usize { + self.scroll_top + } + + pub fn move_selection(&mut self, delta: isize) { + if self.rows.is_empty() { + return; + } + + let len = self.rows.len() as isize; + let mut new_index = self.selected_row as isize + delta; + new_index = new_index.clamp(0, len - 1); + + if delta >= 0 { + while new_index < len + && matches!( + self.rows[new_index as usize].kind, + RepoSearchRowKind::FileHeader + ) + { + new_index += 1; + } + } else { + while new_index >= 0 + && matches!( + self.rows[new_index as usize].kind, + RepoSearchRowKind::FileHeader + ) + { + new_index -= 1; + } + } + + if new_index < 0 || new_index >= len { + // No match rows – stick to current selection. + return; + } + + self.selected_row = new_index as usize; + self.ensure_selection_visible(); + } + + pub fn page(&mut self, delta: isize) { + if self.viewport_height == 0 { + return; + } + let height = self.viewport_height as isize; + let new_scroll = + (self.scroll_top as isize + delta * height).clamp(0, self.max_scroll() as isize); + self.scroll_top = new_scroll as usize; + + if delta > 0 { + let target = (self.scroll_top + self.viewport_height).saturating_sub(1); + self.selected_row = self + .rows + .iter() + .enumerate() + .rev() + .find(|(idx, _)| *idx <= target) + .map(|(idx, row)| { + if matches!(row.kind, RepoSearchRowKind::FileHeader) && idx > 0 { + idx - 1 + } else { + idx + } + }) + .unwrap_or(self.selected_row); + } else if delta < 0 { + self.selected_row = self.scroll_top.min(self.rows.len().saturating_sub(1)); + } + + self.ensure_selection_visible(); + } + + pub fn selected_row_index(&self) -> usize { + self.selected_row + } + + pub fn selected_indices(&self) -> Option<(usize, usize)> { + let row = self.rows.get(self.selected_row)?; + match row.kind { + RepoSearchRowKind::Match { match_index } => Some((row.file_index, match_index)), + RepoSearchRowKind::FileHeader => None, + } + } + + pub fn selected_match(&self) -> Option<(&RepoSearchFile, &RepoSearchMatch)> { + let (file_idx, match_idx) = self.selected_indices()?; + let file = self.files.get(file_idx)?; + let m = file.matches.get(match_idx)?; + Some((file, m)) + } + + pub fn scroll_to(&mut self, offset: usize) { + self.scroll_top = offset.min(self.max_scroll()); + self.ensure_selection_visible(); + } + + pub fn visible_rows(&self) -> &[RepoSearchRow] { + let start = self.scroll_top; + let end = (start + self.viewport_height).min(self.rows.len()); + &self.rows[start..end] + } + + pub fn max_scroll(&self) -> usize { + self.rows.len().saturating_sub(self.viewport_height.max(1)) + } + + fn ensure_selection_visible(&mut self) { + if self.viewport_height == 0 { + return; + } + if self.selected_row < self.scroll_top { + self.scroll_top = self.selected_row; + } else if self.selected_row >= self.scroll_top + self.viewport_height { + self.scroll_top = self.selected_row + 1 - self.viewport_height; + } + } + + pub fn has_results(&self) -> bool { + self.files.iter().any(|file| !file.matches.is_empty()) + } +} + +/// Streamed events emitted by the ripgrep worker task. +#[derive(Debug)] +pub enum RepoSearchMessage { + File { + path: PathBuf, + }, + Match { + path: PathBuf, + line_number: u32, + column: u32, + preview: String, + matched: Option, + }, + Done { + matches: usize, + }, + Error(String), +} + +/// Launch an asynchronous ripgrep search task. +pub fn spawn_repo_search_task( + root: PathBuf, + query: String, +) -> Result<(JoinHandle<()>, mpsc::UnboundedReceiver)> { + let (tx, rx) = mpsc::unbounded_channel(); + + let handle = tokio::spawn(async move { + if let Err(err) = run_repo_search(root, query, tx.clone()).await { + let _ = tx.send(RepoSearchMessage::Error(err.to_string())); + } + }); + + Ok((handle, rx)) +} + +/// Classification of the symbol extracted from source code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymbolKind { + Function, + Struct, + Enum, + Trait, + Module, + TypeAlias, +} + +impl SymbolKind { + pub fn icon(self) -> &'static str { + match self { + SymbolKind::Function => "ƒ", + SymbolKind::Struct => "▣", + SymbolKind::Enum => "◇", + SymbolKind::Trait => "♦", + SymbolKind::Module => "▸", + SymbolKind::TypeAlias => "≡", + } + } + + pub fn label(self) -> &'static str { + match self { + SymbolKind::Function => "fn", + SymbolKind::Struct => "struct", + SymbolKind::Enum => "enum", + SymbolKind::Trait => "trait", + SymbolKind::Module => "mod", + SymbolKind::TypeAlias => "type", + } + } +} + +/// Single indexed symbol within the workspace. +#[derive(Debug, Clone)] +pub struct SymbolEntry { + pub name: String, + pub kind: SymbolKind, + pub file: PathBuf, + pub display_path: String, + pub line: u32, +} + +/// Interactive state for the tree-sitter-backed symbol search overlay. +#[derive(Debug, Clone, Default)] +pub struct SymbolSearchState { + query: String, + items: Vec, + filtered: Vec, + selected: usize, + scroll_top: usize, + viewport_height: usize, + running: bool, + status: Option, + error: Option, +} + +impl SymbolSearchState { + pub fn new() -> Self { + Self::default() + } + + pub fn begin_index(&mut self) { + self.items.clear(); + self.filtered.clear(); + self.selected = 0; + self.scroll_top = 0; + self.running = true; + self.status = Some("Indexing symbols…".to_string()); + self.error = None; + } + + pub fn is_running(&self) -> bool { + self.running + } + + pub fn status(&self) -> Option<&String> { + self.status.as_ref() + } + + pub fn status_mut(&mut self) -> &mut Option { + &mut self.status + } + + pub fn error(&self) -> Option<&String> { + self.error.as_ref() + } + + pub fn query(&self) -> &str { + &self.query + } + + pub fn set_query(&mut self, value: impl Into) { + self.query = value.into(); + self.refilter(); + } + + pub fn push_query_char(&mut self, ch: char) { + self.query.push(ch); + self.refilter(); + } + + pub fn pop_query_char(&mut self) { + self.query.pop(); + self.refilter(); + } + + pub fn clear_query(&mut self) { + self.query.clear(); + self.refilter(); + } + + pub fn add_symbols(&mut self, symbols: Vec) { + if symbols.is_empty() { + return; + } + let start_len = self.items.len(); + self.items.extend(symbols); + if self.filtered.len() == start_len && self.query.trim().is_empty() { + self.filtered.extend(start_len..self.items.len()); + } + self.refilter(); + } + + pub fn finish(&mut self) { + self.running = false; + if self.items.is_empty() { + self.status = Some("No symbols found".to_string()); + } else { + self.status = Some(format!("Indexed {} symbols", self.items.len())); + } + } + + pub fn mark_error(&mut self, message: impl Into) { + self.running = false; + self.error = Some(message.into()); + } + + pub fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height; + if self.viewport_height == 0 { + self.scroll_top = 0; + return; + } + let max_scroll = self.filtered.len().saturating_sub(self.viewport_height); + if self.scroll_top > max_scroll { + self.scroll_top = max_scroll; + } + self.ensure_selection_visible(); + } + + pub fn visible_indices(&self) -> &[usize] { + let start = self.scroll_top.min(self.filtered.len()); + let end = (start + self.viewport_height).min(self.filtered.len()); + &self.filtered[start..end] + } + + pub fn items(&self) -> &[SymbolEntry] { + &self.items + } + + pub fn move_selection(&mut self, delta: isize) { + if self.filtered.is_empty() { + return; + } + let len = self.filtered.len() as isize; + let mut new_index = self.selected as isize + delta; + new_index = new_index.clamp(0, len - 1); + self.selected = new_index as usize; + self.ensure_selection_visible(); + } + + pub fn page(&mut self, delta: isize) { + if self.viewport_height == 0 { + return; + } + let viewport = self.viewport_height as isize; + let new_scroll = + (self.scroll_top as isize + delta * viewport).clamp(0, self.max_scroll() as isize); + self.scroll_top = new_scroll as usize; + self.selected = self.scroll_top.min(self.filtered.len().saturating_sub(1)); + self.ensure_selection_visible(); + } + + pub fn scroll_to(&mut self, offset: usize) { + self.scroll_top = offset.min(self.max_scroll()); + self.ensure_selection_visible(); + } + + pub fn selected_entry(&self) -> Option<&SymbolEntry> { + self.filtered + .get(self.selected) + .and_then(|idx| self.items.get(*idx)) + } + + pub fn has_results(&self) -> bool { + !self.filtered.is_empty() + } + + pub fn selected_filtered_index(&self) -> Option { + self.filtered.get(self.selected).copied() + } + + pub fn scroll_top(&self) -> usize { + self.scroll_top + } + + pub fn filtered_len(&self) -> usize { + self.filtered.len() + } + + fn refilter(&mut self) { + if self.items.is_empty() { + self.filtered.clear(); + self.selected = 0; + self.scroll_top = 0; + return; + } + + let trimmed = self.query.trim(); + if trimmed.is_empty() { + self.filtered = (0..self.items.len()).collect(); + } else { + let mut scored: Vec<(usize, (usize, usize))> = Vec::new(); + for (idx, item) in self.items.iter().enumerate() { + if let Some(score) = commands::match_score(item.name.as_str(), trimmed) { + scored.push((idx, score)); + continue; + } + if let Some(score) = commands::match_score(item.display_path.as_str(), trimmed) { + scored.push((idx, score)); + } + } + scored.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); + self.filtered = scored.into_iter().map(|(idx, _)| idx).collect(); + } + + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + if self.filtered.is_empty() { + self.selected = 0; + self.scroll_top = 0; + } else { + self.ensure_selection_visible(); + } + } + + fn ensure_selection_visible(&mut self) { + if self.viewport_height == 0 || self.filtered.is_empty() { + return; + } + if self.selected < self.scroll_top { + self.scroll_top = self.selected; + } else if self.selected >= self.scroll_top + self.viewport_height { + self.scroll_top = self.selected + 1 - self.viewport_height; + } + } + + fn max_scroll(&self) -> usize { + self.filtered + .len() + .saturating_sub(self.viewport_height.max(1)) + } +} + +/// Messages emitted by the background symbol indexer. +#[derive(Debug)] +pub enum SymbolSearchMessage { + Symbols(Vec), + Done, + Error(String), +} + +pub fn spawn_symbol_search_task( + root: PathBuf, +) -> Result<(JoinHandle<()>, mpsc::UnboundedReceiver)> { + let (tx, rx) = mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { + if let Err(err) = run_symbol_indexer(root, tx.clone()).await { + let _ = tx.send(SymbolSearchMessage::Error(err.to_string())); + } + }); + Ok((handle, rx)) +} + +async fn run_symbol_indexer( + root: PathBuf, + sender: mpsc::UnboundedSender, +) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + let mut parser = Parser::new(); + parser + .set_language(tree_sitter_rust::language()) + .context("failed to initialise tree-sitter for Rust")?; + + let mut walker = WalkBuilder::new(&root); + walker.git_ignore(true); + walker.git_exclude(true); + walker.follow_links(false); + + for entry in walker.build() { + let entry = match entry { + Ok(value) => value, + Err(err) => { + eprintln!("symbol index walk error: {err}"); + continue; + } + }; + + if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + continue; + } + + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("rs") { + continue; + } + + let source = match fs::read(path) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("symbol index read error {}: {err}", path.display()); + continue; + } + }; + + if source.is_empty() { + continue; + } + + let tree = match parser.parse(&source, None) { + Some(tree) => tree, + None => continue, + }; + + let mut symbols = Vec::new(); + collect_rust_symbols(&tree, &source, path, &root, &mut symbols); + if !symbols.is_empty() && sender.send(SymbolSearchMessage::Symbols(symbols)).is_err() { + break; + } + } + + let _ = sender.send(SymbolSearchMessage::Done); + Ok(()) + }) + .await??; + Ok(()) +} + +fn collect_rust_symbols( + tree: &tree_sitter::Tree, + source: &[u8], + path: &Path, + root: &Path, + output: &mut Vec, +) { + let relative = diff_paths(path, root).unwrap_or_else(|| path.to_path_buf()); + let display_path = relative.to_string_lossy().into_owned(); + let mut cursor = tree.walk(); + traverse_rust(&mut cursor, source, path, &display_path, output); +} + +fn traverse_rust( + cursor: &mut TreeCursor, + source: &[u8], + path: &Path, + display_path: &str, + output: &mut Vec, +) { + loop { + let node = cursor.node(); + match node.kind() { + "function_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::Function, node, source, path, display_path) + { + output.push(entry); + } + } + "struct_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::Struct, node, source, path, display_path) + { + output.push(entry); + } + } + "enum_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::Enum, node, source, path, display_path) + { + output.push(entry); + } + } + "trait_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::Trait, node, source, path, display_path) + { + output.push(entry); + } + } + "mod_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::Module, node, source, path, display_path) + { + output.push(entry); + } + } + "type_item" => { + if let Some(entry) = + build_symbol_entry(SymbolKind::TypeAlias, node, source, path, display_path) + { + output.push(entry); + } + } + _ => {} + } + + if cursor.goto_first_child() { + traverse_rust(cursor, source, path, display_path, output); + cursor.goto_parent(); + } + + if !cursor.goto_next_sibling() { + break; + } + } +} + +fn build_symbol_entry( + kind: SymbolKind, + node: Node, + source: &[u8], + path: &Path, + display_path: &str, +) -> Option { + let name_node = node.child_by_field_name("name")?; + let name = name_node.utf8_text(source).ok()?.to_string(); + let line = name_node.start_position().row as u32 + 1; + Some(SymbolEntry { + name, + kind, + file: path.to_path_buf(), + display_path: display_path.to_string(), + line, + }) +} + +async fn run_repo_search( + root: PathBuf, + query: String, + sender: mpsc::UnboundedSender, +) -> Result<()> { + let mut command = Command::new("rg"); + command + .current_dir(&root) + .arg("--json") + .arg("--no-heading") + .arg("--color=never") + .arg("--line-number") + .arg("--column") + .arg("--smart-case") + .arg("--max-columns=300") + .arg(&query) + .arg(".") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = command.spawn().context("failed to spawn ripgrep")?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("ripgrep produced no stdout"))?; + + let stderr_future = child.stderr.take().map(capture_stderr); + + let mut reader = BufReader::new(stdout).lines(); + let mut matches = 0usize; + + while let Some(line) = reader.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(RipGrepEvent::Begin { path }) => { + let absolute = canonicalize_relative(&root, path.text); + let _ = sender.send(RepoSearchMessage::File { path: absolute }); + } + Ok(RipGrepEvent::Match(data)) => { + matches += 1; + let absolute = canonicalize_relative(&root, data.path.text); + let preview = data + .lines + .text + .trim_end_matches(&['\r', '\n'][..]) + .to_string(); + let mut column = 1; + let mut matched = None; + + if let Some(submatch) = data.submatches.first() { + column = compute_column(&preview, submatch.start); + matched = Some(submatch.matcher.text.clone()); + } + + let message = RepoSearchMessage::Match { + path: absolute, + line_number: data.line_number as u32, + column, + preview, + matched, + }; + let _ = sender.send(message); + } + Ok(RipGrepEvent::Summary { .. }) => { + // ignore summary; we derive counts from match events. + } + Ok(RipGrepEvent::End { .. }) => {} + Ok(RipGrepEvent::Other) => {} + Err(err) => { + let _ = sender.send(RepoSearchMessage::Error(format!( + "ripgrep parse error: {err}" + ))); + } + } + } + + let status = child.wait().await?; + let stderr_output = if let Some(fut) = stderr_future { + fut.await + } else { + String::new() + }; + + if status.success() || status.code() == Some(1) { + let _ = sender.send(RepoSearchMessage::Done { matches }); + } else { + let message = if stderr_output.trim().is_empty() { + format!("ripgrep exited with status: {status}") + } else { + stderr_output + }; + let _ = sender.send(RepoSearchMessage::Error(message)); + } + + Ok(()) +} + +fn canonicalize_relative(root: &Path, path_text: String) -> PathBuf { + let candidate = PathBuf::from(path_text); + if candidate.is_absolute() { + candidate + } else { + root.join(candidate) + } +} + +fn compute_column(preview: &str, byte_offset: usize) -> u32 { + let slice = preview + .get(..byte_offset) + .unwrap_or(preview) + .chars() + .count(); + (slice + 1) as u32 +} + +async fn capture_stderr(stderr: ChildStderr) -> String { + let mut reader = BufReader::new(stderr); + let mut buf = String::new(); + if reader.read_to_string(&mut buf).await.is_err() { + return String::new(); + } + buf +} + +#[derive(Deserialize)] +#[serde(tag = "type", content = "data")] +enum RipGrepEvent { + #[serde(rename = "begin")] + Begin { path: RgPath }, + #[serde(rename = "match")] + Match(RgMatch), + #[serde(rename = "summary")] + Summary { + #[serde(rename = "stats")] + _stats: Option, + }, + #[serde(rename = "end")] + End { + #[serde(rename = "path")] + _path: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +struct RgPath { + text: String, +} + +#[derive(Deserialize)] +struct RgMatch { + path: RgPath, + lines: RgLines, + #[serde(default)] + line_number: u64, + #[serde(default)] + submatches: Vec, +} + +#[derive(Deserialize)] +struct RgLines { + text: String, +} + +#[derive(Deserialize)] +struct RgSubMatch { + #[serde(rename = "match")] + matcher: RgSubMatchText, + start: usize, + #[allow(dead_code)] + end: usize, +} + +#[derive(Deserialize)] +struct RgSubMatchText { + text: String, +} + +#[derive(Deserialize)] +struct RgSummaryStats { + #[allow(dead_code)] + matches: Option, +} diff --git a/crates/owlen-tui/src/state/workspace.rs b/crates/owlen-tui/src/state/workspace.rs new file mode 100644 index 0000000..5517536 --- /dev/null +++ b/crates/owlen-tui/src/state/workspace.rs @@ -0,0 +1,883 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use owlen_core::state::AutoScroll; +use serde::{Deserialize, Serialize}; + +/// Cardinal direction used for navigating between panes or resizing splits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneDirection { + Left, + Right, + Up, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChildSide { + First, + Second, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PathEntry { + axis: SplitAxis, + side: ChildSide, +} + +/// Identifier assigned to each pane rendered inside a tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PaneId(u64); + +impl PaneId { + fn next(counter: &mut u64) -> Self { + *counter += 1; + PaneId(*counter) + } + + pub fn raw(self) -> u64 { + self.0 + } + + pub fn from_raw(raw: u64) -> Self { + PaneId(raw) + } +} + +/// Identifier used to refer to a tab within the workspace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TabId(u64); + +impl TabId { + fn next(counter: &mut u64) -> Self { + *counter += 1; + TabId(*counter) + } + + pub fn raw(self) -> u64 { + self.0 + } + + pub fn from_raw(raw: u64) -> Self { + TabId(raw) + } +} + +/// Direction used when splitting a pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SplitAxis { + /// Split horizontally to create a pane below the current one. + Horizontal, + /// Split vertically to create a pane to the right of the current one. + Vertical, +} + +/// Layout node describing either a leaf pane or a container split. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LayoutNode { + Leaf(PaneId), + Split { + axis: SplitAxis, + ratio: f32, + first: Box, + second: Box, + }, +} + +impl LayoutNode { + fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool { + match self { + LayoutNode::Leaf(id) => { + if *id == target { + *self = replacement; + true + } else { + false + } + } + LayoutNode::Split { first, second, .. } => { + first.replace_leaf(target, replacement.clone()) + || second.replace_leaf(target, replacement) + } + } + } + + pub fn iter_leaves<'a>(&'a self, panes: &'a HashMap) -> Vec<&'a CodePane> { + let mut collected = Vec::new(); + self.collect_leaves(panes, &mut collected); + collected + } + + fn collect_leaves<'a>( + &'a self, + panes: &'a HashMap, + output: &mut Vec<&'a CodePane>, + ) { + match self { + LayoutNode::Leaf(id) => { + if let Some(pane) = panes.get(id) { + output.push(pane); + } + } + LayoutNode::Split { first, second, .. } => { + first.collect_leaves(panes, output); + second.collect_leaves(panes, output); + } + } + } + + fn path_to(&self, target: PaneId) -> Option> { + let mut path = Vec::new(); + if self.path_to_inner(target, &mut path) { + Some(path) + } else { + None + } + } + + fn path_to_inner(&self, target: PaneId, path: &mut Vec) -> bool { + match self { + LayoutNode::Leaf(id) => *id == target, + LayoutNode::Split { + axis, + first, + second, + .. + } => { + path.push(PathEntry { + axis: *axis, + side: ChildSide::First, + }); + if first.path_to_inner(target, path) { + return true; + } + path.pop(); + path.push(PathEntry { + axis: *axis, + side: ChildSide::Second, + }); + if second.path_to_inner(target, path) { + return true; + } + path.pop(); + false + } + } + } + + fn subtree(&self, path: &[PathEntry]) -> Option<&LayoutNode> { + let mut node = self; + for entry in path { + match node { + LayoutNode::Split { first, second, .. } => { + node = match entry.side { + ChildSide::First => first.as_ref(), + ChildSide::Second => second.as_ref(), + }; + } + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + fn subtree_mut(&mut self, path: &[PathEntry]) -> Option<&mut LayoutNode> { + let mut node = self; + for entry in path { + match node { + LayoutNode::Split { first, second, .. } => { + node = match entry.side { + ChildSide::First => first.as_mut(), + ChildSide::Second => second.as_mut(), + }; + } + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + fn extreme_leaf(&self, prefer_second: bool) -> Option { + match self { + LayoutNode::Leaf(id) => Some(*id), + LayoutNode::Split { first, second, .. } => { + if prefer_second { + second + .extreme_leaf(prefer_second) + .or_else(|| first.extreme_leaf(prefer_second)) + } else { + first + .extreme_leaf(prefer_second) + .or_else(|| second.extreme_leaf(prefer_second)) + } + } + } + } +} + +/// Renderable pane that holds file contents and scroll state. +#[derive(Debug, Clone)] +pub struct CodePane { + pub id: PaneId, + pub absolute_path: Option, + pub display_path: Option, + pub title: String, + pub lines: Vec, + pub scroll: AutoScroll, + pub viewport_height: usize, + pub is_dirty: bool, + pub is_staged: bool, +} + +impl CodePane { + pub fn new(id: PaneId) -> Self { + Self { + id, + absolute_path: None, + display_path: None, + title: "Untitled".to_string(), + lines: Vec::new(), + scroll: AutoScroll::default(), + viewport_height: 0, + is_dirty: false, + is_staged: false, + } + } + + pub fn set_contents( + &mut self, + absolute_path: Option, + display_path: Option, + lines: Vec, + ) { + self.absolute_path = absolute_path; + self.display_path = display_path; + self.title = self + .absolute_path + .as_ref() + .and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned())) + .or_else(|| self.display_path.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + self.lines = lines; + self.scroll = AutoScroll::default(); + self.scroll.content_len = self.lines.len(); + self.scroll.stick_to_bottom = false; + self.scroll.scroll = 0; + } + + pub fn clear(&mut self) { + self.absolute_path = None; + self.display_path = None; + self.title = "Untitled".to_string(); + self.lines.clear(); + self.scroll = AutoScroll::default(); + self.viewport_height = 0; + self.is_dirty = false; + self.is_staged = false; + } + + pub fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height; + } + + pub fn display_path(&self) -> Option<&str> { + self.display_path.as_deref() + } + + pub fn absolute_path(&self) -> Option<&Path> { + self.absolute_path.as_deref() + } +} + +/// Individual tab containing a layout tree and panes. +#[derive(Debug, Clone)] +pub struct EditorTab { + pub id: TabId, + pub title: String, + pub root: LayoutNode, + pub panes: HashMap, + pub active: PaneId, +} + +impl EditorTab { + fn new(id: TabId, title: String, pane: CodePane) -> Self { + let active = pane.id; + let mut panes = HashMap::new(); + panes.insert(pane.id, pane); + Self { + id, + title, + root: LayoutNode::Leaf(active), + panes, + active, + } + } + + pub fn active_pane(&self) -> Option<&CodePane> { + self.panes.get(&self.active) + } + + pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { + self.panes.get_mut(&self.active) + } + + pub fn set_active(&mut self, pane: PaneId) { + if self.panes.contains_key(&pane) { + self.active = pane; + } + } + + pub fn update_title_from_active(&mut self) { + if let Some(pane) = self.active_pane() { + self.title = pane + .absolute_path + .as_ref() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned())) + .or_else(|| pane.display_path.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + } + } + + fn active_path(&self) -> Option> { + self.root.path_to(self.active) + } + + pub fn move_focus(&mut self, direction: PaneDirection) -> bool { + let path = match self.active_path() { + Some(path) => path, + None => return false, + }; + let axis = match direction { + PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, + PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, + }; + + for (idx, entry) in path.iter().enumerate().rev() { + if entry.axis != axis { + continue; + } + + let (required_side, target_side, prefer_second) = match direction { + PaneDirection::Left => (ChildSide::Second, ChildSide::First, true), + PaneDirection::Right => (ChildSide::First, ChildSide::Second, false), + PaneDirection::Up => (ChildSide::Second, ChildSide::First, true), + PaneDirection::Down => (ChildSide::First, ChildSide::Second, false), + }; + + if entry.side != required_side { + continue; + } + + let parent_path = &path[..idx]; + let Some(parent) = self.root.subtree(parent_path) else { + continue; + }; + + if let LayoutNode::Split { first, second, .. } = parent { + let target = match target_side { + ChildSide::First => first.as_ref(), + ChildSide::Second => second.as_ref(), + }; + if let Some(pane_id) = target.extreme_leaf(prefer_second) + && self.panes.contains_key(&pane_id) + { + self.active = pane_id; + self.update_title_from_active(); + return true; + } + } + } + + false + } + + pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option { + let path = self.active_path()?; + + let axis = match direction { + PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, + PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, + }; + + let (idx, entry) = path + .iter() + .enumerate() + .rev() + .find(|(_, entry)| entry.axis == axis)?; + + let parent_path = &path[..idx]; + let parent = self.root.subtree_mut(parent_path)?; + + let LayoutNode::Split { ratio, .. } = parent else { + return None; + }; + + let sign = match direction { + PaneDirection::Left => { + if entry.side == ChildSide::First { + 1.0 + } else { + -1.0 + } + } + PaneDirection::Right => { + if entry.side == ChildSide::First { + -1.0 + } else { + 1.0 + } + } + PaneDirection::Up => { + if entry.side == ChildSide::First { + 1.0 + } else { + -1.0 + } + } + PaneDirection::Down => { + if entry.side == ChildSide::First { + -1.0 + } else { + 1.0 + } + } + }; + + let mut new_ratio = (*ratio + amount * sign).clamp(0.1, 0.9); + if (new_ratio - *ratio).abs() < f32::EPSILON { + return Some(self.active_share_from(entry.side, new_ratio)); + } + *ratio = new_ratio; + new_ratio = new_ratio.clamp(0.1, 0.9); + Some(self.active_share_from(entry.side, new_ratio)) + } + + pub fn snap_active_share( + &mut self, + direction: PaneDirection, + desired_share: f32, + ) -> Option { + let path = self.active_path()?; + + let axis = match direction { + PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical, + PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal, + }; + + let (idx, entry) = path + .iter() + .enumerate() + .rev() + .find(|(_, entry)| entry.axis == axis)?; + + let parent_path = &path[..idx]; + let parent = self.root.subtree_mut(parent_path)?; + + let LayoutNode::Split { ratio, .. } = parent else { + return None; + }; + + let mut target_ratio = match entry.side { + ChildSide::First => desired_share, + ChildSide::Second => 1.0 - desired_share, + } + .clamp(0.1, 0.9); + + if (target_ratio - *ratio).abs() < f32::EPSILON { + return Some(self.active_share_from(entry.side, target_ratio)); + } + + *ratio = target_ratio; + target_ratio = target_ratio.clamp(0.1, 0.9); + Some(self.active_share_from(entry.side, target_ratio)) + } + + pub fn active_share(&self) -> Option { + let path = self.active_path()?; + let (idx, entry) = + path.iter().enumerate().rev().find(|(_, entry)| { + matches!(entry.axis, SplitAxis::Horizontal | SplitAxis::Vertical) + })?; + let parent_path = &path[..idx]; + let parent = self.root.subtree(parent_path)?; + if let LayoutNode::Split { ratio, .. } = parent { + Some(self.active_share_from(entry.side, *ratio)) + } else { + None + } + } + + fn active_share_from(&self, side: ChildSide, ratio: f32) -> f32 { + match side { + ChildSide::First => ratio, + ChildSide::Second => 1.0 - ratio, + } + } +} + +/// Top-level workspace managing tabs and panes for the code viewer. +#[derive(Debug, Clone)] +pub struct CodeWorkspace { + tabs: Vec, + active_tab: usize, + next_tab_id: u64, + next_pane_id: u64, +} + +const WORKSPACE_SNAPSHOT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSnapshot { + version: u32, + active_tab: usize, + next_tab_id: u64, + next_pane_id: u64, + tabs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TabSnapshot { + id: u64, + title: String, + active: u64, + root: LayoutNode, + panes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PaneSnapshot { + id: u64, + absolute_path: Option, + display_path: Option, + is_dirty: bool, + is_staged: bool, + scroll: ScrollSnapshot, + viewport_height: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScrollSnapshot { + pub scroll: usize, + pub stick_to_bottom: bool, +} + +#[derive(Debug, Clone)] +pub struct PaneRestoreRequest { + pub pane_id: PaneId, + pub absolute_path: Option, + pub display_path: Option, + pub scroll: ScrollSnapshot, +} + +impl Default for CodeWorkspace { + fn default() -> Self { + Self::new() + } +} + +impl CodeWorkspace { + pub fn new() -> Self { + let mut next_tab_id = 0; + let mut next_pane_id = 0; + let pane_id = PaneId::next(&mut next_pane_id); + let first_pane = CodePane::new(pane_id); + let tab_id = TabId::next(&mut next_tab_id); + let title = format!("Tab {}", tab_id.0); + let first_tab = EditorTab::new(tab_id, title, first_pane); + Self { + tabs: vec![first_tab], + active_tab: 0, + next_tab_id, + next_pane_id, + } + } + + pub fn tabs(&self) -> &[EditorTab] { + &self.tabs + } + + pub fn tabs_mut(&mut self) -> &mut [EditorTab] { + &mut self.tabs + } + + pub fn active_tab_index(&self) -> usize { + self.active_tab.min(self.tabs.len().saturating_sub(1)) + } + + pub fn active_tab(&self) -> Option<&EditorTab> { + self.tabs.get(self.active_tab_index()) + } + + pub fn active_tab_mut(&mut self) -> Option<&mut EditorTab> { + let idx = self.active_tab_index(); + self.tabs.get_mut(idx) + } + + pub fn active_pane(&self) -> Option<&CodePane> { + self.active_tab().and_then(|tab| tab.active_pane()) + } + + pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { + self.active_tab_mut().and_then(|tab| tab.active_pane_mut()) + } + + pub fn set_active_tab(&mut self, index: usize) { + if index < self.tabs.len() { + self.active_tab = index; + } + } + + pub fn ensure_tab(&mut self) { + if self.tabs.is_empty() { + let mut next_tab_id = self.next_tab_id; + let mut next_pane_id = self.next_pane_id; + let pane_id = PaneId::next(&mut next_pane_id); + let pane = CodePane::new(pane_id); + let tab_id = TabId::next(&mut next_tab_id); + let title = format!("Tab {}", tab_id.0); + let tab = EditorTab::new(tab_id, title, pane); + self.tabs.push(tab); + self.active_tab = 0; + self.next_tab_id = next_tab_id; + self.next_pane_id = next_pane_id; + } + } + + pub fn set_active_contents( + &mut self, + absolute: Option, + display: Option, + lines: Vec, + ) { + self.ensure_tab(); + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.set_contents(absolute, display, lines); + } + tab.update_title_from_active(); + } + } + + pub fn clear_active_pane(&mut self) { + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.clear(); + } + tab.update_title_from_active(); + } + } + + pub fn set_active_viewport_height(&mut self, height: usize) { + if let Some(pane) = self.active_pane_mut() { + pane.set_viewport_height(height); + } + } + + pub fn active_pane_id(&self) -> Option { + self.active_tab().map(|tab| tab.active) + } + + pub fn split_active(&mut self, axis: SplitAxis) -> Option { + self.ensure_tab(); + let active_id = self.active_tab()?.active; + let new_pane_id = PaneId::next(&mut self.next_pane_id); + let replacement = LayoutNode::Split { + axis, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf(active_id)), + second: Box::new(LayoutNode::Leaf(new_pane_id)), + }; + + self.active_tab_mut().and_then(|tab| { + if tab.root.replace_leaf(active_id, replacement) { + tab.panes.insert(new_pane_id, CodePane::new(new_pane_id)); + tab.active = new_pane_id; + Some(new_pane_id) + } else { + None + } + }) + } + + pub fn open_new_tab(&mut self) -> PaneId { + let pane_id = PaneId::next(&mut self.next_pane_id); + let pane = CodePane::new(pane_id); + let tab_id = TabId::next(&mut self.next_tab_id); + let title = format!("Tab {}", tab_id.0); + let tab = EditorTab::new(tab_id, title, pane); + self.tabs.push(tab); + self.active_tab = self.tabs.len().saturating_sub(1); + pane_id + } + + pub fn snapshot(&self) -> WorkspaceSnapshot { + let tabs = self + .tabs + .iter() + .map(|tab| { + let panes = tab + .panes + .values() + .map(|pane| PaneSnapshot { + id: pane.id.raw(), + absolute_path: pane + .absolute_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + display_path: pane.display_path.clone(), + is_dirty: pane.is_dirty, + is_staged: pane.is_staged, + scroll: ScrollSnapshot { + scroll: pane.scroll.scroll, + stick_to_bottom: pane.scroll.stick_to_bottom, + }, + viewport_height: pane.viewport_height, + }) + .collect(); + + TabSnapshot { + id: tab.id.raw(), + title: tab.title.clone(), + active: tab.active.raw(), + root: tab.root.clone(), + panes, + } + }) + .collect(); + + WorkspaceSnapshot { + version: WORKSPACE_SNAPSHOT_VERSION, + active_tab: self.active_tab_index(), + next_tab_id: self.next_tab_id, + next_pane_id: self.next_pane_id, + tabs, + } + } + + pub fn apply_snapshot(&mut self, snapshot: WorkspaceSnapshot) -> Vec { + if snapshot.version != WORKSPACE_SNAPSHOT_VERSION { + return Vec::new(); + } + + let mut restore_requests = Vec::new(); + let mut tabs = Vec::new(); + + for tab_snapshot in snapshot.tabs { + let mut panes = HashMap::new(); + for pane_snapshot in tab_snapshot.panes { + let pane_id = PaneId::from_raw(pane_snapshot.id); + let mut pane = CodePane::new(pane_id); + pane.absolute_path = pane_snapshot.absolute_path.as_ref().map(PathBuf::from); + pane.display_path = pane_snapshot.display_path.clone(); + pane.is_dirty = pane_snapshot.is_dirty; + pane.is_staged = pane_snapshot.is_staged; + pane.scroll.scroll = pane_snapshot.scroll.scroll; + pane.scroll.stick_to_bottom = pane_snapshot.scroll.stick_to_bottom; + pane.viewport_height = pane_snapshot.viewport_height; + pane.scroll.content_len = pane.lines.len(); + pane.title = pane + .absolute_path + .as_ref() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned())) + .or_else(|| pane.display_path.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + panes.insert(pane_id, pane); + + if pane_snapshot.absolute_path.is_some() { + restore_requests.push(PaneRestoreRequest { + pane_id, + absolute_path: pane_snapshot.absolute_path.map(PathBuf::from), + display_path: pane_snapshot.display_path.clone(), + scroll: pane_snapshot.scroll.clone(), + }); + } + } + + if panes.is_empty() { + continue; + } + + let tab_id = TabId::from_raw(tab_snapshot.id); + let mut tab = EditorTab { + id: tab_id, + title: tab_snapshot.title, + root: tab_snapshot.root, + panes, + active: PaneId::from_raw(tab_snapshot.active), + }; + tab.update_title_from_active(); + tabs.push(tab); + } + + if tabs.is_empty() { + return Vec::new(); + } + + self.tabs = tabs; + self.active_tab = snapshot.active_tab.min(self.tabs.len().saturating_sub(1)); + self.next_tab_id = snapshot.next_tab_id; + self.next_pane_id = snapshot.next_pane_id; + + restore_requests + } + + pub fn move_focus(&mut self, direction: PaneDirection) -> bool { + let active_index = self.active_tab_index(); + if let Some(tab) = self.tabs.get_mut(active_index) { + tab.move_focus(direction) + } else { + false + } + } + + pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option { + let active_index = self.active_tab_index(); + self.tabs + .get_mut(active_index) + .and_then(|tab| tab.resize_active_step(direction, amount)) + } + + pub fn snap_active_share( + &mut self, + direction: PaneDirection, + desired_share: f32, + ) -> Option { + let active_index = self.active_tab_index(); + self.tabs + .get_mut(active_index) + .and_then(|tab| tab.snap_active_share(direction, desired_share)) + } + + pub fn active_share(&self) -> Option { + self.active_tab().and_then(|tab| tab.active_share()) + } + + pub fn set_pane_contents( + &mut self, + pane_id: PaneId, + absolute: Option, + display: Option, + lines: Vec, + ) -> bool { + for tab in &mut self.tabs { + if let Some(pane) = tab.panes.get_mut(&pane_id) { + pane.set_contents(absolute, display, lines); + tab.update_title_from_active(); + return true; + } + } + false + } + + pub fn restore_scroll(&mut self, pane_id: PaneId, snapshot: &ScrollSnapshot) -> bool { + for tab in &mut self.tabs { + if let Some(pane) = tab.panes.get_mut(&pane_id) { + pane.scroll.scroll = snapshot.scroll; + pane.scroll.stick_to_bottom = snapshot.stick_to_bottom; + pane.scroll.content_len = pane.lines.len(); + return true; + } + } + false + } +} diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 88058db..1955c98 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4,11 +4,16 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; +use std::collections::HashMap; +use std::path::Path; use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; +use crate::state::{ + CodePane, EditorTab, FileFilterMode, LayoutNode, PaneId, RepoSearchRowKind, SplitAxis, +}; use owlen_core::model::DetailedModelInfo; use owlen_core::theme::Theme; use owlen_core::types::{ModelInfo, Role}; @@ -26,16 +31,32 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { let full_area = frame.area(); frame.render_widget(background_block, full_area); + let (file_area, main_area) = if app.is_file_panel_collapsed() || full_area.width < 40 { + (None, full_area) + } else { + let max_sidebar = full_area.width.saturating_sub(30).max(10); + let sidebar_width = app.file_panel_width().min(max_sidebar).max(10); + let segments = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(sidebar_width), Constraint::Min(30)]) + .split(full_area); + (Some(segments[0]), segments[1]) + }; + let (chat_area, code_area) = if app.should_show_code_view() { let segments = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) - .split(full_area); + .split(main_area); (segments[0], Some(segments[1])) } else { - (full_area, None) + (main_area, None) }; + if let Some(file_area) = file_area { + render_file_tree(frame, file_area, app); + } + // Calculate dynamic input height based on textarea content let available_width = chat_area.width; let max_input_rows = usize::from(app.input_max_rows()).max(1); @@ -132,6 +153,8 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { InputMode::SessionBrowser => render_session_browser(frame, app), InputMode::ThemeBrowser => render_theme_browser(frame, app), InputMode::Command => render_command_suggestions(frame, app), + InputMode::RepoSearch => render_repo_search(frame, app), + InputMode::SymbolSearch => render_symbol_search(frame, app), _ => {} } } @@ -151,10 +174,202 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { } if let Some(area) = code_area { - render_code_view(frame, area, app); + render_code_workspace(frame, area, app); } } +fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { + let theme = app.theme().clone(); + let has_focus = matches!(app.focused_panel(), FocusedPanel::Files); + let (repo_name, filter_query, filter_mode, show_hidden) = { + let tree = app.file_tree(); + ( + tree.repo_name().to_string(), + tree.filter_query().to_string(), + tree.filter_mode(), + tree.show_hidden(), + ) + }; + let mut title_spans = vec![Span::styled( + format!("Files ▸ {}", repo_name), + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + )]; + + if !filter_query.is_empty() { + let mode_label = match filter_mode { + FileFilterMode::Glob => "glob", + FileFilterMode::Fuzzy => "fuzzy", + }; + title_spans.push(Span::styled( + format!(" {}:{}", mode_label, filter_query), + Style::default().fg(theme.info), + )); + } + + if show_hidden { + title_spans.push(Span::styled( + " hidden:on", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC), + )); + } + + let hint_style = if has_focus { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM) + } else { + Style::default() + .fg(theme.unfocused_panel_border) + .add_modifier(Modifier::DIM) + }; + + title_spans.push(Span::styled( + " ↩ open · o split↓ · O split→ · t tab · y abs · Y rel · a file · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / mode", + hint_style, + )); + + let border_color = if has_focus { + theme.focused_panel_border + } else { + theme.unfocused_panel_border + }; + + let block = Block::default() + .title(Line::from(title_spans)) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let inner = block.inner(area); + let viewport_height = inner.height as usize; + + if viewport_height == 0 || inner.width == 0 { + frame.render_widget(block, area); + return; + } + + { + let tree = app.file_tree_mut(); + tree.set_viewport_height(viewport_height); + } + + let tree = app.file_tree(); + let entries = tree.visible_entries(); + let start = tree.scroll_top().min(entries.len()); + let end = (start + viewport_height).min(entries.len()); + let error_message = tree.last_error().map(|msg| msg.to_string()); + + let mut items = Vec::new(); + + if let Some((prompt_text, is_destructive)) = app.file_panel_prompt_text() { + let prompt_style = if is_destructive { + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD | Modifier::ITALIC) + } else { + Style::default() + .fg(theme.info) + .add_modifier(Modifier::ITALIC) + }; + items.push(ListItem::new(Line::from(vec![Span::styled( + prompt_text, + prompt_style, + )]))); + } + if start >= end { + items.push( + ListItem::new(Line::from(vec![Span::styled( + "No files", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )])) + .style(Style::default()), + ); + } else { + for (offset, entry) in entries[start..end].iter().enumerate() { + let node = &tree.nodes()[entry.index]; + let indent_level = entry.depth.saturating_sub(1); + let mut spans: Vec> = Vec::new(); + if indent_level > 0 { + spans.push(Span::raw(" ".repeat(indent_level))); + } + + let toggle_symbol = if node.is_dir { + if node.children.is_empty() { + " " + } else if node.is_expanded { + "▾ " + } else { + "▸ " + } + } else { + " " + }; + spans.push(Span::styled( + toggle_symbol.to_string(), + Style::default().fg(theme.text), + )); + + let marker_style = match node.git.cleanliness { + '●' => Style::default().fg(theme.error), + '○' => Style::default().fg(theme.info), + _ => Style::default().fg(theme.text).add_modifier(Modifier::DIM), + }; + spans.push(Span::styled( + format!("{} ", node.git.cleanliness), + marker_style, + )); + + if let Some(badge) = node.git.badge { + spans.push(Span::styled( + format!("{badge} "), + Style::default().fg(theme.info), + )); + } else { + spans.push(Span::raw(" ".to_string())); + } + + let mut name_style = Style::default().fg(theme.text); + if node.is_dir { + name_style = name_style.add_modifier(Modifier::BOLD); + } + if node.is_hidden { + name_style = name_style.add_modifier(Modifier::DIM); + } + + spans.push(Span::styled(node.name.clone(), name_style)); + + let absolute_idx = start + offset; + let mut line_style = Style::default(); + if absolute_idx == tree.cursor() { + line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg); + } else if !has_focus { + line_style = line_style.fg(theme.text).add_modifier(Modifier::DIM); + } + + items.push(ListItem::new(Line::from(spans)).style(line_style)); + } + } + + if let Some(err) = error_message { + items.insert( + 0, + ListItem::new(Line::from(vec![Span::styled( + format!("⚠ {err}"), + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD | Modifier::ITALIC), + )])) + .style(Style::default()), + ); + } + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} + fn render_editable_textarea( frame: &mut Frame<'_>, area: Rect, @@ -1168,6 +1383,8 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ", InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ", InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ", + InputMode::RepoSearch => " Repo Search (Enter=run · Alt+Enter=scratch · Esc=close) ", + InputMode::SymbolSearch => " Symbol Search (Ctrl+P then @) ", _ => " Input (Press 'i' to start typing) ", }; @@ -1306,40 +1523,94 @@ where fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); - let (mode_text, mode_bg_color) = match app.mode() { - InputMode::Normal => (" NORMAL", theme.mode_normal), - InputMode::Editing => (" INPUT", theme.mode_editing), - InputMode::ModelSelection => (" MODEL", theme.mode_model_selection), - InputMode::ProviderSelection => (" PROVIDER", theme.mode_provider_selection), - InputMode::Help => (" HELP", theme.mode_help), - InputMode::Visual => (" VISUAL", theme.mode_visual), - InputMode::Command => (" COMMAND", theme.mode_command), - InputMode::SessionBrowser => (" SESSIONS", theme.mode_command), - InputMode::ThemeBrowser => (" THEMES", theme.mode_help), + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.status_background)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 || inner.width == 0 { + return; + } + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(inner); + + let (mode_label, mode_color) = match app.mode() { + InputMode::Normal => ("NORMAL", theme.mode_normal), + InputMode::Editing => ("INSERT", theme.mode_editing), + InputMode::ModelSelection => ("MODEL", theme.mode_model_selection), + InputMode::ProviderSelection => ("PROVIDER", theme.mode_provider_selection), + InputMode::Help => ("HELP", theme.mode_help), + InputMode::Visual => ("VISUAL", theme.mode_visual), + InputMode::Command => ("COMMAND", theme.mode_command), + InputMode::SessionBrowser => ("SESSIONS", theme.mode_command), + InputMode::ThemeBrowser => ("THEMES", theme.mode_help), + InputMode::RepoSearch => ("SEARCH", theme.mode_command), + InputMode::SymbolSearch => ("SYMBOLS", theme.mode_command), }; - let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit"; + let (op_label, op_fg, op_bg) = match app.get_mode() { + owlen_core::mode::Mode::Chat => ("CHAT", theme.operating_chat_fg, theme.operating_chat_bg), + owlen_core::mode::Mode::Code => ("CODE", theme.operating_code_fg, theme.operating_code_bg), + }; - let mut spans = vec![Span::styled( - format!(" {} ", mode_text), - Style::default() - .fg(theme.background) - .bg(mode_bg_color) - .add_modifier(Modifier::BOLD), - )]; + let focus_label = match app.focused_panel() { + FocusedPanel::Files => "FILES", + FocusedPanel::Chat => "CHAT", + FocusedPanel::Thinking => "THINK", + FocusedPanel::Input => "INPUT", + FocusedPanel::Code => "CODE", + }; + + let mut left_spans = vec![ + Span::styled( + format!(" {} ", mode_label), + Style::default() + .bg(mode_color) + .fg(theme.background) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " │ ", + Style::default() + .fg(theme.unfocused_panel_border) + .add_modifier(Modifier::DIM), + ), + Span::styled( + format!(" {} ", op_label), + Style::default() + .bg(op_bg) + .fg(op_fg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" │ {}", focus_label), + Style::default() + .fg(theme.info) + .add_modifier(Modifier::ITALIC), + ), + ]; - // Add agent status indicator if agent mode is active if app.is_agent_running() { - spans.push(Span::styled( - " 🤖 AGENT RUNNING ", + left_spans.push(Span::styled( + " 🤖 RUN", Style::default() .fg(theme.agent_badge_running_fg) .bg(theme.agent_badge_running_bg) .add_modifier(Modifier::BOLD), )); } else if app.is_agent_mode() { - spans.push(Span::styled( - " 🤖 AGENT MODE ", + left_spans.push(Span::styled( + " 🤖 ARM", Style::default() .fg(theme.agent_badge_idle_fg) .bg(theme.agent_badge_idle_bg) @@ -1347,87 +1618,268 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { )); } - // Add operating mode indicator - let operating_mode = app.get_mode(); - let (op_mode_text, op_mode_fg, op_mode_bg) = match operating_mode { - owlen_core::mode::Mode::Chat => { - (" 💬 CHAT", theme.operating_chat_fg, theme.operating_chat_bg) - } - owlen_core::mode::Mode::Code => { - (" 💻 CODE", theme.operating_code_fg, theme.operating_code_bg) - } + let left_paragraph = Paragraph::new(Line::from(left_spans)) + .alignment(Alignment::Left) + .style(Style::default().bg(theme.status_background).fg(theme.text)); + frame.render_widget(left_paragraph, columns[0]); + + let file_tree = app.file_tree(); + let repo_label = if let Some(branch) = file_tree.git_branch() { + format!("{}@{}", branch, file_tree.repo_name()) + } else { + file_tree.repo_name().to_string() }; - spans.push(Span::styled( - op_mode_text, + + let current_path = if let Some(path) = app.code_view_path() { + Some(path.to_string()) + } else if let Some(node) = file_tree.selected_node() { + if node.path.as_os_str().is_empty() { + None + } else { + Some(node.path.to_string_lossy().into_owned()) + } + } else { + None + }; + + let position_label = status_cursor_position(app); + let encoding_label = "UTF-8 LF"; + let language_label = language_label_for_path(current_path.as_deref()); + + let mut mid_parts = vec![repo_label]; + if let Some(path) = current_path.as_ref() { + mid_parts.push(path.clone()); + } + mid_parts.push(position_label); + mid_parts.push(encoding_label.to_string()); + mid_parts.push(language_label.to_string()); + + let mid_paragraph = Paragraph::new(mid_parts.join(" · ")) + .alignment(Alignment::Center) + .style(Style::default().bg(theme.status_background).fg(theme.text)); + frame.render_widget(mid_paragraph, columns[1]); + + let provider = app.current_provider(); + let model_label = app.active_model_label(); + let mut right_spans = vec![Span::styled( + format!("{} ▸ {}", provider, model_label), + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + )]; + + if app.is_loading() || app.is_streaming() { + let spinner = app.get_loading_indicator(); + let spinner = if spinner.is_empty() { "…" } else { spinner }; + right_spans.push(Span::styled( + format!(" · {} streaming", spinner), + Style::default().fg(theme.info), + )); + right_spans.push(Span::styled( + " · p:Pause r:Resume s:Stop", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC), + )); + } + + right_spans.push(Span::styled( + " · LSP:✓", Style::default() - .fg(op_mode_fg) - .bg(op_mode_bg) - .add_modifier(Modifier::BOLD), + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), )); - spans.push(Span::styled(" ", Style::default().fg(theme.text))); - spans.push(Span::styled( - "Provider: ", - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::ITALIC), - )); - spans.push(Span::styled( - app.current_provider().to_string(), - Style::default().fg(theme.text), - )); - spans.push(Span::styled( - " i:Insert m:Model ?:Help : Command", - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::ITALIC), - )); - spans.push(Span::styled(" ", Style::default().fg(theme.text))); - spans.push(Span::styled( - "Model: ", - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::ITALIC), - )); - spans.push(Span::styled( - app.selected_model().to_string(), - Style::default() - .fg(theme.user_message_role) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled(" ", Style::default().fg(theme.text))); - spans.push(Span::styled(help_text, Style::default().fg(theme.info))); + let right_paragraph = Paragraph::new(Line::from(right_spans)) + .alignment(Alignment::Right) + .style(Style::default().bg(theme.status_background).fg(theme.text)); + frame.render_widget(right_paragraph, columns[2]); +} - let paragraph = Paragraph::new(Line::from(spans)) +fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { + let theme = app.theme().clone(); + frame.render_widget(Clear, area); + + if area.width == 0 || area.height == 0 { + return; + } + + if app.workspace().tabs().is_empty() { + render_empty_workspace(frame, area, &theme); + return; + } + + let show_tab_bar = area.height > 2; + let content_area = if show_tab_bar { + let segments = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(area); + render_code_tab_bar(frame, segments[0], app, &theme); + segments[1] + } else { + area + }; + + render_code_tab_content(frame, content_area, app, &theme); +} + +fn render_code_tab_bar(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, theme: &Theme) { + if area.width == 0 || area.height == 0 { + return; + } + + let tabs = app.workspace().tabs(); + if tabs.is_empty() { + return; + } + + let active_index = app.workspace().active_tab_index(); + let mut spans: Vec> = Vec::new(); + + for (index, tab) in tabs.iter().enumerate() { + if index > 0 { + spans.push(Span::styled( + " ", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + } + + let dirty_marker = tab + .panes + .get(&tab.active) + .map(|pane| pane.is_dirty) + .unwrap_or(false); + let dirty_char = if dirty_marker { "●" } else { " " }; + let label = format!(" {} {} {} ", index + 1, dirty_char, tab.title); + + let style = if index == active_index { + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM) + }; + + spans.push(Span::styled(label, style)); + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) .alignment(Alignment::Left) .style(Style::default().bg(theme.status_background).fg(theme.text)) .block( Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.unfocused_panel_border)) - .style(Style::default().bg(theme.status_background).fg(theme.text)), + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(theme.unfocused_panel_border)), ); frame.render_widget(paragraph, area); } -fn render_code_view(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { - let path = match app.code_view_path() { - Some(p) => p.to_string(), - None => { - frame.render_widget(Clear, area); +fn render_code_tab_content(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp, theme: &Theme) { + if area.width == 0 || area.height == 0 { + return; + } + + let has_focus = matches!(app.focused_panel(), FocusedPanel::Code); + let active_index = app.workspace().active_tab_index(); + + let workspace = app.workspace_mut(); + let tabs = workspace.tabs_mut(); + if let Some(tab) = tabs.get_mut(active_index) { + let EditorTab { + root, + panes, + active, + .. + } = tab; + if panes.is_empty() { + render_empty_workspace(frame, area, theme); return; } - }; + let active_pane = *active; + render_workspace_node(frame, area, root, panes, active_pane, theme, has_focus); + } else { + render_empty_workspace(frame, area, theme); + } +} - let theme = app.theme().clone(); - frame.render_widget(Clear, area); +fn render_workspace_node( + frame: &mut Frame<'_>, + area: Rect, + node: &mut LayoutNode, + panes: &mut HashMap, + active_pane: PaneId, + theme: &Theme, + has_focus: bool, +) { + if area.width == 0 || area.height == 0 { + return; + } + + match node { + LayoutNode::Leaf(id) => { + if let Some(pane) = panes.get_mut(id) { + let is_active = *id == active_pane; + render_code_pane(frame, area, pane, theme, has_focus, is_active); + } else { + render_empty_workspace(frame, area, theme); + } + } + LayoutNode::Split { + axis, + ratio, + first, + second, + } => { + let (first_area, second_area) = split_rect(area, *axis, *ratio); + if first_area.width > 0 && first_area.height > 0 { + render_workspace_node( + frame, + first_area, + first.as_mut(), + panes, + active_pane, + theme, + has_focus, + ); + } + if second_area.width > 0 && second_area.height > 0 { + render_workspace_node( + frame, + second_area, + second.as_mut(), + panes, + active_pane, + theme, + has_focus, + ); + } + } + } +} + +fn render_code_pane( + frame: &mut Frame<'_>, + area: Rect, + pane: &mut CodePane, + theme: &Theme, + has_focus: bool, + is_active: bool, +) { + if area.width == 0 || area.height == 0 { + return; + } let viewport_height = area.height.saturating_sub(2) as usize; - app.set_code_view_viewport_height(viewport_height); + pane.set_viewport_height(viewport_height); let mut lines: Vec = Vec::new(); - if app.code_view_lines().is_empty() { + if pane.lines.is_empty() { lines.push(Line::from(Span::styled( "(empty file)", Style::default() @@ -1435,52 +1887,202 @@ fn render_code_view(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .add_modifier(Modifier::ITALIC), ))); } else { - for (idx, content) in app.code_view_lines().iter().enumerate() { + for (idx, content) in pane.lines.iter().enumerate() { let number = format!("{:>4} ", idx + 1); - let spans = vec![ - Span::styled( - number, - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - ), - Span::styled(content.clone(), Style::default().fg(theme.text)), - ]; + let mut spans = vec![Span::styled( + number, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )]; + + let mut line_style = Style::default().fg(theme.text); + if !is_active { + line_style = line_style.add_modifier(Modifier::DIM); + } + + spans.push(Span::styled(content.clone(), line_style)); lines.push(Line::from(spans)); } } - let scroll_state = app.code_view_scroll_mut(); - scroll_state.content_len = lines.len(); - scroll_state.on_viewport(viewport_height); - let scroll_position = scroll_state.scroll.min(u16::MAX as usize) as u16; + pane.scroll.content_len = lines.len(); + pane.scroll.on_viewport(viewport_height); + let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16; - let border_color = if matches!(app.focused_panel(), FocusedPanel::Code) { - theme.focused_panel_border + let title = pane + .display_path() + .map(|s| s.to_string()) + .or_else(|| { + pane.absolute_path() + .map(|p| p.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| pane.title.clone()); + + let mut title_style = Style::default().fg(theme.placeholder); + if is_active { + title_style = Style::default() + .fg(if has_focus { + theme.focused_panel_border + } else { + theme.unfocused_panel_border + }) + .add_modifier(Modifier::BOLD); } else { - theme.unfocused_panel_border - }; + title_style = title_style.add_modifier(Modifier::DIM); + } - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .style(Style::default().bg(theme.background).fg(theme.text)) - .title(Span::styled( - path, - Style::default() - .fg(theme.focused_panel_border) - .add_modifier(Modifier::BOLD), - )); + let mut border_style = Style::default().fg(theme.unfocused_panel_border); + if is_active && has_focus { + border_style = Style::default().fg(theme.focused_panel_border); + } else if is_active { + border_style = border_style.fg(theme.unfocused_panel_border); + } else { + border_style = border_style.add_modifier(Modifier::DIM); + } let paragraph = Paragraph::new(lines) .style(Style::default().bg(theme.background).fg(theme.text)) - .block(block) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(Span::styled(title, title_style)), + ) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } +fn render_empty_workspace(frame: &mut Frame<'_>, area: Rect, theme: &Theme) { + if area.width == 0 || area.height == 0 { + return; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.placeholder)) + .title(Span::styled( + "No file open", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC), + )); + + let paragraph = Paragraph::new(Line::from(Span::styled( + "Open a file from the tree or palette", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + ))) + .alignment(Alignment::Center) + .block(block); + + frame.render_widget(paragraph, area); +} + +fn split_rect(area: Rect, axis: SplitAxis, ratio: f32) -> (Rect, Rect) { + if area.width == 0 || area.height == 0 { + return (area, Rect::new(area.x, area.y, 0, 0)); + } + + let ratio = ratio.clamp(0.1, 0.9); + match axis { + SplitAxis::Horizontal => { + let (first, _) = split_lengths(area.height, ratio); + let first_rect = Rect::new(area.x, area.y, area.width, first); + let second_rect = Rect::new( + area.x, + area.y.saturating_add(first), + area.width, + area.height.saturating_sub(first), + ); + (first_rect, second_rect) + } + SplitAxis::Vertical => { + let (first, _) = split_lengths(area.width, ratio); + let first_rect = Rect::new(area.x, area.y, first, area.height); + let second_rect = Rect::new( + area.x.saturating_add(first), + area.y, + area.width.saturating_sub(first), + area.height, + ); + (first_rect, second_rect) + } + } +} + +fn split_lengths(total: u16, ratio: f32) -> (u16, u16) { + if total <= 1 { + return (total, 0); + } + let mut first = ((total as f32) * ratio).round() as u16; + first = first.max(1).min(total - 1); + let second = total - first; + (first, second) +} + +fn status_cursor_position(app: &ChatApp) -> String { + let (line, col) = match app.focused_panel() { + FocusedPanel::Chat => { + let (row, col) = app.chat_cursor(); + (row + 1, col + 1) + } + FocusedPanel::Thinking => { + let (row, col) = app.thinking_cursor(); + (row + 1, col + 1) + } + FocusedPanel::Input => { + let (row, col) = app.textarea().cursor(); + (row + 1, col + 1) + } + FocusedPanel::Code => { + let row = app + .code_view_scroll() + .map(|scroll| scroll.scroll + 1) + .unwrap_or(1); + (row, 1) + } + FocusedPanel::Files => (app.file_tree().cursor() + 1, 1), + }; + + format!("{}:{}", line, col) +} + +fn language_label_for_path(path: Option<&str>) -> &'static str { + let Some(path) = path else { + return "Plain Text"; + }; + + let Some(ext) = Path::new(path).extension().and_then(|ext| ext.to_str()) else { + return "Plain Text"; + }; + + match ext.to_ascii_lowercase().as_str() { + "rs" => "Rust 2024", + "py" => "Python 3", + "ts" => "TypeScript", + "tsx" => "TypeScript", + "js" => "JavaScript", + "jsx" => "JavaScript", + "go" => "Go", + "java" => "Java", + "kt" => "Kotlin", + "sh" => "Shell", + "bash" => "Shell", + "md" => "Markdown", + "toml" => "TOML", + "json" => "JSON", + "yaml" | "yml" => "YAML", + "html" => "HTML", + "css" => "CSS", + _ => "Plain Text", + } +} + fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let area = centered_rect(60, 60, frame.area()); @@ -2685,6 +3287,416 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(list, popup_area); } +fn render_repo_search(frame: &mut Frame<'_>, app: &mut ChatApp) { + let theme = app.theme().clone(); + let popup = centered_rect(70, 70, frame.area()); + frame.render_widget(Clear, popup); + + let block = Block::default() + .title(Span::styled( + " Repo Search · ripgrep ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.focused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)); + + frame.render_widget(block.clone(), popup); + let inner = block.inner(popup); + if inner.width == 0 || inner.height == 0 { + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(1), + ]) + .split(inner); + + let (query, running, dirty, status_line, error_line) = { + let state = app.repo_search(); + ( + state.query_input().to_string(), + state.running(), + state.dirty(), + state.status().cloned(), + state.error().cloned(), + ) + }; + + { + let viewport = layout[2].height.saturating_sub(1) as usize; + app.repo_search_mut().set_viewport_height(viewport.max(1)); + } + + let state = app.repo_search(); + let mut query_spans = vec![Span::styled( + "Pattern: ", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )]; + let mut query_style = Style::default().fg(theme.text); + if dirty { + query_style = query_style.add_modifier(Modifier::ITALIC); + } + if query.is_empty() { + query_spans.push(Span::styled( + "", + query_style.add_modifier(Modifier::DIM), + )); + } else { + query_spans.push(Span::styled(query.clone(), query_style)); + } + if running { + query_spans.push(Span::styled( + " ⟳ searching…", + Style::default() + .fg(theme.info) + .add_modifier(Modifier::ITALIC), + )); + } + + let query_para = Paragraph::new(Line::from(query_spans)).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(theme.unfocused_panel_border)), + ); + frame.render_widget(query_para, layout[0]); + + let status_span = if let Some(err) = error_line { + Span::styled(err, Style::default().fg(theme.error)) + } else if let Some(status) = status_line { + Span::styled(status, Style::default().fg(theme.placeholder)) + } else { + Span::styled( + "Enter=search Alt+Enter=scratch Esc=cancel", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + ) + }; + let status_para = Paragraph::new(Line::from(status_span)) + .alignment(Alignment::Left) + .style(Style::default().bg(theme.background).fg(theme.text)); + frame.render_widget(status_para, layout[1]); + + let rows = state.visible_rows(); + let files = state.files(); + let selected_row = state.selected_row_index(); + let mut items: Vec = Vec::new(); + + for (offset, row) in rows.iter().enumerate() { + let absolute_index = state.scroll_top() + offset; + match &row.kind { + RepoSearchRowKind::FileHeader => { + let file = &files[row.file_index]; + let mut spans = vec![Span::styled( + file.display.clone(), + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + )]; + if !file.matches.is_empty() { + spans.push(Span::styled( + format!(" ({} matches)", file.matches.len()), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + } + items.push( + ListItem::new(Line::from(spans)) + .style(Style::default().bg(theme.background).fg(theme.text)), + ); + } + RepoSearchRowKind::Match { match_index } => { + let file = &files[row.file_index]; + if let Some(m) = file.matches.get(*match_index) { + let is_selected = absolute_index == selected_row; + let prefix_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM) + }; + let mut spans = vec![Span::styled( + format!(" {:>6}:{:<3} ", m.line_number, m.column), + prefix_style, + )]; + if is_selected { + spans.push(Span::styled( + m.preview.clone(), + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD), + )); + } else if let Some(matched) = &m.matched { + if let Some(idx) = m.preview.find(matched) { + let head = &m.preview[..idx]; + let tail = &m.preview[idx + matched.len()..]; + if !head.is_empty() { + spans.push(Span::styled( + head.to_string(), + Style::default().fg(theme.text), + )); + } + spans.push(Span::styled( + matched.to_string(), + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )); + if !tail.is_empty() { + spans.push(Span::styled( + tail.to_string(), + Style::default().fg(theme.text), + )); + } + } else { + spans.push(Span::styled( + m.preview.clone(), + Style::default().fg(theme.text), + )); + } + } else { + spans.push(Span::styled( + m.preview.clone(), + Style::default().fg(theme.text), + )); + } + + let item_style = if is_selected { + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(theme.background).fg(theme.text) + }; + items.push(ListItem::new(Line::from(spans)).style(item_style)); + } + } + } + } + + if items.is_empty() { + let placeholder = if state.running() { + "Searching…" + } else if state.query_input().is_empty() { + "Type a pattern and press Enter" + } else { + "No results" + }; + items.push( + ListItem::new(Line::from(vec![Span::styled( + placeholder, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM | Modifier::ITALIC), + )])) + .style(Style::default().bg(theme.background)), + ); + } + + let list = List::new(items).block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(theme.unfocused_panel_border)), + ); + + frame.render_widget(list, layout[2]); +} + +fn render_symbol_search(frame: &mut Frame<'_>, app: &mut ChatApp) { + let theme = app.theme().clone(); + let area = centered_rect(70, 70, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(Span::styled( + " Symbol Search · tree-sitter ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.focused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)); + + frame.render_widget(block.clone(), area); + let inner = block.inner(area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(1), + ]) + .split(inner); + + let (query, running, status_text, error_text, filtered, total) = { + let state = app.symbol_search(); + ( + state.query().to_string(), + state.is_running(), + state.status().cloned(), + state.error().cloned(), + state.filtered_len(), + state.items().len(), + ) + }; + + { + let viewport = layout[2].height.saturating_sub(1) as usize; + app.symbol_search_mut().set_viewport_height(viewport.max(1)); + } + + let state = app.symbol_search(); + let mut query_spans = vec![Span::styled( + "Filter: ", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )]; + if query.is_empty() { + query_spans.push(Span::styled( + "", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC), + )); + } else { + query_spans.push(Span::styled(query.clone(), Style::default().fg(theme.text))); + } + if running { + query_spans.push(Span::styled( + " ⟳ indexing…", + Style::default() + .fg(theme.info) + .add_modifier(Modifier::ITALIC), + )); + } + + let query_para = Paragraph::new(Line::from(query_spans)).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(theme.unfocused_panel_border)), + ); + frame.render_widget(query_para, layout[0]); + + let mut status_spans = Vec::new(); + if let Some(err) = error_text.as_ref() { + status_spans.push(Span::styled(err, Style::default().fg(theme.error))); + } else if let Some(status) = status_text.as_ref() { + status_spans.push(Span::styled(status, Style::default().fg(theme.placeholder))); + } else { + status_spans.push(Span::styled( + "Type to filter · Enter=jump · Esc=close", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + } + + if error_text.is_none() { + status_spans.push(Span::styled( + format!(" {} of {} symbols", filtered, total), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + } + + let status_para = Paragraph::new(Line::from(status_spans)) + .style(Style::default().bg(theme.background).fg(theme.text)); + frame.render_widget(status_para, layout[1]); + + let visible = state.visible_indices(); + let items = state.items(); + let selected_idx = state.selected_filtered_index(); + let mut list_items: Vec = Vec::new(); + + for &item_index in visible.iter() { + if let Some(entry) = items.get(item_index) { + let is_selected = selected_idx == Some(item_index); + let mut spans = Vec::new(); + let icon_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.info) + }; + spans.push(Span::styled(format!(" {} ", entry.kind.icon()), icon_style)); + let name_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + spans.push(Span::styled(entry.name.clone(), name_style)); + spans.push(Span::raw(" ")); + let path_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::DIM) + } else { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM) + }; + spans.push(Span::styled( + format!("{}:{}", entry.display_path, entry.line), + path_style, + )); + + let item_style = if is_selected { + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(theme.background).fg(theme.text) + }; + + list_items.push(ListItem::new(Line::from(spans)).style(item_style)); + } + } + + if list_items.is_empty() { + let placeholder = if running { + "Indexing symbols…" + } else if query.is_empty() { + "No symbols discovered" + } else { + "No symbols match filter" + }; + list_items.push( + ListItem::new(Line::from(vec![Span::styled( + placeholder, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM | Modifier::ITALIC), + )])) + .style(Style::default().bg(theme.background)), + ); + } + + let list = List::new(list_items).block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(theme.unfocused_panel_border)), + ); + + frame.render_widget(list, layout[2]); +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical)