diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index baa9c96..f246260 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -3260,15 +3260,24 @@ impl ChatApp { mutate(&mut query); + let query_is_empty = query.is_empty(); + { let tree = self.file_tree_mut(); + if query_is_empty { + tree.set_filter_mode(FileFilterMode::Glob); + } tree.set_filter_query(query.clone()); } - if query.is_empty() { + if query_is_empty { self.status = "Filter cleared".to_string(); } else { - self.status = format!("Filter: {}", query); + let mode = match self.file_tree().filter_mode() { + FileFilterMode::Glob => "glob", + FileFilterMode::Fuzzy => "fuzzy", + }; + self.status = format!("Filter ({mode}): {}", query); } self.error = None; } @@ -3308,6 +3317,33 @@ impl ChatApp { } } + fn create_file_from_command(&mut self, path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(anyhow!("File path cannot be empty")); + } + + let relative = PathBuf::from(trimmed); + validate_relative_path(&relative, true)?; + + let file_name = relative + .file_name() + .ok_or_else(|| anyhow!("File path must include a file name"))? + .to_string_lossy() + .into_owned(); + + let base = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.to_path_buf()) + .unwrap_or_else(PathBuf::new); + + let prompt = FileActionPrompt::new(FileActionKind::CreateFile { base }, file_name); + let message = self.perform_file_action(prompt)?; + self.expand_file_panel(); + Ok(message) + } + pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> { self.pending_file_action.as_ref().map(|prompt| { ( @@ -3766,23 +3802,6 @@ impl ChatApp { 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 { @@ -3873,19 +3892,20 @@ impl ChatApp { 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); + if self.file_tree().filter_query().is_empty() { + { + let tree = self.file_tree_mut(); + tree.set_filter_mode(FileFilterMode::Fuzzy); + tree.set_filter_query(String::new()); + } + let mode = match self.file_tree().filter_mode() { + FileFilterMode::Glob => "glob", + FileFilterMode::Fuzzy => "fuzzy", + }; + self.status = format!("Filter ({mode}): type to search"); + self.error = None; } else { - self.status = format!("Filter mode: {} · {}", mode, query); + self.append_file_filter_char('/'); } return Ok(true); } @@ -3914,7 +3934,6 @@ impl ChatApp { | ('g', _) | ('d', _) | ('m', _) - | ('a', _) | ('A', true) | ('r', _) | ('/', _) @@ -4111,7 +4130,19 @@ impl ChatApp { } } - let is_file_focus_key = matches!(key.code, KeyCode::F(1)); + if matches!(key.code, KeyCode::F(1)) { + if matches!(self.mode, InputMode::Help) { + self.set_input_mode(InputMode::Normal); + self.help_tab_index = 0; + self.reset_status(); + } else { + self.set_input_mode(InputMode::Help); + self.status = "Help".to_string(); + self.error = None; + } + return Ok(AppState::Running); + } + let is_question_mark = matches!( (key.code, key.modifiers), (KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT) @@ -4126,19 +4157,6 @@ impl ChatApp { && key.modifiers.contains(KeyModifiers::SHIFT) && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')); - if is_file_focus_key && matches!(self.mode, InputMode::Normal) { - let was_collapsed = self.is_file_panel_collapsed(); - self.toggle_file_panel(); - if was_collapsed && !self.is_file_panel_collapsed() { - self.status = "Files panel shown".to_string(); - } else if !was_collapsed && self.is_file_panel_collapsed() { - self.status = "Files panel hidden".to_string(); - } else { - // No visual change (e.g., width constraints), keep prior status - } - return Ok(AppState::Running); - } - if is_reveal_active && matches!(self.mode, InputMode::Normal) { self.reveal_active_file(); return Ok(AppState::Running); @@ -5274,10 +5292,57 @@ impl ChatApp { let _ = self.save_active_code_buffer(path_arg, force).await?; } "q" => { - self.close_active_code_buffer(force); + if matches!(self.focused_panel, FocusedPanel::Files) + && !self.is_file_panel_collapsed() + { + self.collapse_file_panel(); + self.status = "Files panel hidden".to_string(); + self.error = None; + } else { + self.close_active_code_buffer(force); + } } "quit" => { - return Ok(AppState::Quit); + if matches!(self.focused_panel, FocusedPanel::Files) + && !self.is_file_panel_collapsed() + { + self.collapse_file_panel(); + self.status = "Files panel hidden".to_string(); + self.error = None; + } else { + return Ok(AppState::Quit); + } + } + "create" => { + if args.is_empty() { + self.error = Some("Usage: :create ".to_string()); + } else { + let path_arg = args.join(" "); + match self.create_file_from_command(&path_arg) { + Ok(message) => { + self.status = message; + self.error = None; + } + Err(err) => { + self.status = "File creation failed".to_string(); + self.error = Some(err.to_string()); + } + } + } + } + "files" | "explorer" => { + let was_collapsed = self.is_file_panel_collapsed(); + self.toggle_file_panel(); + let now_collapsed = self.is_file_panel_collapsed(); + self.error = None; + + if was_collapsed && !now_collapsed { + self.status = "Files panel shown".to_string(); + } else if !was_collapsed && now_collapsed { + self.status = "Files panel hidden".to_string(); + } else { + self.status = "Files panel unchanged".to_string(); + } } "c" | "clear" => { self.controller.clear(); @@ -6232,9 +6297,10 @@ impl ChatApp { _ => {} }, InputMode::Help => match key.code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => { self.set_input_mode(InputMode::Normal); self.help_tab_index = 0; // Reset to first tab + self.reset_status(); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { // Next tab diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index a29444e..42e268e 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -56,6 +56,10 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "open", description: "Open a file in the code view", }, + CommandSpec { + keyword: "create", + description: "Create a file (creates missing directories)", + }, CommandSpec { keyword: "close", description: "Close the active code view", @@ -196,6 +200,14 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "layout load", description: "Restore the last saved pane layout", }, + CommandSpec { + keyword: "files", + description: "Toggle the files panel", + }, + CommandSpec { + keyword: "explorer", + description: "Alias for files", + }, ]; /// Return the static catalog of commands. diff --git a/crates/owlen-tui/src/state/file_tree.rs b/crates/owlen-tui/src/state/file_tree.rs index db4558e..90112d2 100644 --- a/crates/owlen-tui/src/state/file_tree.rs +++ b/crates/owlen-tui/src/state/file_tree.rs @@ -110,7 +110,7 @@ impl FileTreeState { cursor: 0, scroll_top: 0, viewport_height: 20, - filter_mode: FilterMode::Fuzzy, + filter_mode: FilterMode::Glob, filter_query: String::new(), show_hidden: false, filter_matches: Vec::new(), @@ -198,6 +198,14 @@ impl FileTreeState { &self.filter_query } + pub fn set_filter_mode(&mut self, mode: FilterMode) { + if self.filter_mode != mode { + self.filter_mode = mode; + self.recompute_filter_cache(); + self.rebuild_visible(); + } + } + pub fn show_hidden(&self) -> bool { self.show_hidden } @@ -276,12 +284,11 @@ impl FileTreeState { } pub fn toggle_filter_mode(&mut self) { - self.filter_mode = match self.filter_mode { + let next = match self.filter_mode { FilterMode::Glob => FilterMode::Fuzzy, FilterMode::Fuzzy => FilterMode::Glob, }; - self.recompute_filter_cache(); - self.rebuild_visible(); + self.set_filter_mode(next); } pub fn toggle_hidden(&mut self) -> Result<()> { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index c6c619d..20d603a 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -582,7 +582,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } 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", + " ↩ open · o split↓ · O split→ · t tab · y abs · Y rel · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / fuzzy search", panel_hint_style(has_focus, &theme), )); @@ -3222,6 +3222,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { .fg(theme.user_message_role), )]), Line::from(" :open → open file in code side panel"), + Line::from(" :create → create file (makes parent directories)"), Line::from(" :close → close the code side panel"), Line::from(" :w[!] [path] → write active file (optionally to path)"), Line::from(" :q[!] → close active file (append ! to discard)"),