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);
|
mutate(&mut query);
|
||||||
|
|
||||||
|
let query_is_empty = query.is_empty();
|
||||||
|
|
||||||
{
|
{
|
||||||
let tree = self.file_tree_mut();
|
let tree = self.file_tree_mut();
|
||||||
|
if query_is_empty {
|
||||||
|
tree.set_filter_mode(FileFilterMode::Glob);
|
||||||
|
}
|
||||||
tree.set_filter_query(query.clone());
|
tree.set_filter_query(query.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.is_empty() {
|
if query_is_empty {
|
||||||
self.status = "Filter cleared".to_string();
|
self.status = "Filter cleared".to_string();
|
||||||
} else {
|
} 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;
|
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)> {
|
pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> {
|
||||||
self.pending_file_action.as_ref().map(|prompt| {
|
self.pending_file_action.as_ref().map(|prompt| {
|
||||||
(
|
(
|
||||||
@@ -3766,23 +3802,6 @@ impl ChatApp {
|
|||||||
self.copy_selected_path(true);
|
self.copy_selected_path(true);
|
||||||
return Ok(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 => {
|
KeyCode::Char('A') if shift && !ctrl && !alt => {
|
||||||
if let Some(selected) = self.selected_file_node() {
|
if let Some(selected) = self.selected_file_node() {
|
||||||
let base = if selected.is_dir {
|
let base = if selected.is_dir {
|
||||||
@@ -3873,19 +3892,20 @@ impl ChatApp {
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
KeyCode::Char('/') if !ctrl && !alt => {
|
KeyCode::Char('/') if !ctrl && !alt => {
|
||||||
|
if self.file_tree().filter_query().is_empty() {
|
||||||
{
|
{
|
||||||
let tree = self.file_tree_mut();
|
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() {
|
let mode = match self.file_tree().filter_mode() {
|
||||||
FileFilterMode::Glob => "glob",
|
FileFilterMode::Glob => "glob",
|
||||||
FileFilterMode::Fuzzy => "fuzzy",
|
FileFilterMode::Fuzzy => "fuzzy",
|
||||||
};
|
};
|
||||||
let query = self.file_tree().filter_query();
|
self.status = format!("Filter ({mode}): type to search");
|
||||||
if query.is_empty() {
|
self.error = None;
|
||||||
self.status = format!("Filter mode: {}", mode);
|
|
||||||
} else {
|
} else {
|
||||||
self.status = format!("Filter mode: {} · {}", mode, query);
|
self.append_file_filter_char('/');
|
||||||
}
|
}
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -3914,7 +3934,6 @@ impl ChatApp {
|
|||||||
| ('g', _)
|
| ('g', _)
|
||||||
| ('d', _)
|
| ('d', _)
|
||||||
| ('m', _)
|
| ('m', _)
|
||||||
| ('a', _)
|
|
||||||
| ('A', true)
|
| ('A', true)
|
||||||
| ('r', _)
|
| ('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!(
|
let is_question_mark = matches!(
|
||||||
(key.code, key.modifiers),
|
(key.code, key.modifiers),
|
||||||
(KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT)
|
(KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT)
|
||||||
@@ -4126,19 +4157,6 @@ impl ChatApp {
|
|||||||
&& key.modifiers.contains(KeyModifiers::SHIFT)
|
&& key.modifiers.contains(KeyModifiers::SHIFT)
|
||||||
&& matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P'));
|
&& 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) {
|
if is_reveal_active && matches!(self.mode, InputMode::Normal) {
|
||||||
self.reveal_active_file();
|
self.reveal_active_file();
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
@@ -5274,11 +5292,58 @@ impl ChatApp {
|
|||||||
let _ = self.save_active_code_buffer(path_arg, force).await?;
|
let _ = self.save_active_code_buffer(path_arg, force).await?;
|
||||||
}
|
}
|
||||||
"q" => {
|
"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);
|
self.close_active_code_buffer(force);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"quit" => {
|
"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);
|
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" => {
|
"c" | "clear" => {
|
||||||
self.controller.clear();
|
self.controller.clear();
|
||||||
self.chat_line_offset = 0;
|
self.chat_line_offset = 0;
|
||||||
@@ -6232,9 +6297,10 @@ impl ChatApp {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
InputMode::Help => match key.code {
|
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.set_input_mode(InputMode::Normal);
|
||||||
self.help_tab_index = 0; // Reset to first tab
|
self.help_tab_index = 0; // Reset to first tab
|
||||||
|
self.reset_status();
|
||||||
}
|
}
|
||||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||||
// Next tab
|
// Next tab
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "open",
|
keyword: "open",
|
||||||
description: "Open a file in the code view",
|
description: "Open a file in the code view",
|
||||||
},
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "create",
|
||||||
|
description: "Create a file (creates missing directories)",
|
||||||
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "close",
|
keyword: "close",
|
||||||
description: "Close the active code view",
|
description: "Close the active code view",
|
||||||
@@ -196,6 +200,14 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "layout load",
|
keyword: "layout load",
|
||||||
description: "Restore the last saved pane layout",
|
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.
|
/// Return the static catalog of commands.
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ impl FileTreeState {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
scroll_top: 0,
|
scroll_top: 0,
|
||||||
viewport_height: 20,
|
viewport_height: 20,
|
||||||
filter_mode: FilterMode::Fuzzy,
|
filter_mode: FilterMode::Glob,
|
||||||
filter_query: String::new(),
|
filter_query: String::new(),
|
||||||
show_hidden: false,
|
show_hidden: false,
|
||||||
filter_matches: Vec::new(),
|
filter_matches: Vec::new(),
|
||||||
@@ -198,6 +198,14 @@ impl FileTreeState {
|
|||||||
&self.filter_query
|
&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 {
|
pub fn show_hidden(&self) -> bool {
|
||||||
self.show_hidden
|
self.show_hidden
|
||||||
}
|
}
|
||||||
@@ -276,12 +284,11 @@ impl FileTreeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_filter_mode(&mut self) {
|
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::Glob => FilterMode::Fuzzy,
|
||||||
FilterMode::Fuzzy => FilterMode::Glob,
|
FilterMode::Fuzzy => FilterMode::Glob,
|
||||||
};
|
};
|
||||||
self.recompute_filter_cache();
|
self.set_filter_mode(next);
|
||||||
self.rebuild_visible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_hidden(&mut self) -> Result<()> {
|
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(
|
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),
|
panel_hint_style(has_focus, &theme),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -3222,6 +3222,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
.fg(theme.user_message_role),
|
.fg(theme.user_message_role),
|
||||||
)]),
|
)]),
|
||||||
Line::from(" :open <path> → open file in code side panel"),
|
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(" :close → close the code side panel"),
|
||||||
Line::from(" :w[!] [path] → write active file (optionally to path)"),
|
Line::from(" :w[!] [path] → write active file (optionally to path)"),
|
||||||
Line::from(" :q[!] → close active file (append ! to discard)"),
|
Line::from(" :q[!] → close active file (append ! to discard)"),
|
||||||
|
|||||||
Reference in New Issue
Block a user