feat(tui): add :create command, introduce :files/:explorer toggles, default filter to glob and update UI hints
This commit is contained in:
@@ -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 => {
|
||||
{
|
||||
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 <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();
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
Reference in New Issue
Block a user