feat(tui): add :create command, introduce :files/:explorer toggles, default filter to glob and update UI hints

This commit is contained in:
2025-10-13 21:59:03 +02:00
parent 58dd6f3efa
commit cc2b85a86d
4 changed files with 140 additions and 54 deletions

View File

@@ -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<String> {
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 => {
if self.file_tree().filter_query().is_empty() {
{
let tree = self.file_tree_mut();
tree.toggle_filter_mode();
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",
};
let query = self.file_tree().filter_query();
if query.is_empty() {
self.status = format!("Filter mode: {}", mode);
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,11 +5292,58 @@ impl ChatApp {
let _ = self.save_active_code_buffer(path_arg, force).await?;
}
"q" => {
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" => {
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 <path>".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();
self.chat_line_offset = 0;
@@ -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

View File

@@ -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.

View File

@@ -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<()> {

View File

@@ -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 <path> → open file in code side panel"),
Line::from(" :create <path> → 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)"),