From b5bbae81a6129cf6775349483b8a5d7176b6898b Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 15:12:26 +0530 Subject: [PATCH 01/25] docs(skills): refine release notes tone guidelines to favor clarity over marketing language --- .forge/skills/write-release-notes/SKILL.md | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.forge/skills/write-release-notes/SKILL.md b/.forge/skills/write-release-notes/SKILL.md index bd5263315f..7bd6617784 100644 --- a/.forge/skills/write-release-notes/SKILL.md +++ b/.forge/skills/write-release-notes/SKILL.md @@ -5,7 +5,7 @@ description: Generate engaging, high-energy release notes for a given version ta # Write Release Notes -Generate compelling, high-energy release notes by pulling live data from GitHub and synthesizing every PR into a cohesive narrative. +Generate clear, informative, and enthusiastic release notes by pulling live data from GitHub and synthesizing every PR into a cohesive narrative. ## Workflow @@ -40,23 +40,23 @@ Dependency bumps (e.g. Dependabot PRs) go into Maintenance. Skip PRs with `error ### 3. Write the Release Notes -Produce a Markdown document with the following structure. Keep the tone **exciting, punchy, and developer-friendly** — celebrate wins, highlight impact, and make readers feel the momentum. +Produce a Markdown document with the following structure. Keep the tone **informative and enthusiastic** — explain what changed and why it matters, without resorting to marketing fluff. ```markdown -# [Product Name] [Version] — [Punchy Tagline] +# [Product Name] [Version] — [Descriptive Tagline] -> One-sentence hook that captures the spirit of this release. +> One-sentence summary of what this release focuses on. ## What's New -[2-4 sentence narrative that weaves together the biggest features and fixes. -Speak to impact, not implementation. Use active voice. Be enthusiastic.] +[2-4 sentence narrative covering the biggest features and fixes. +Describe what changed and what users can now do. Use active voice. Be factual but upbeat.] ## Highlights ### [Feature/Fix Category] -**[PR Title rephrased as user benefit]** -[1-2 sentences expanding on the PR description. Focus on what the user gains. +**[PR Title rephrased as a clear description of the change]** +[1-2 sentences expanding on the PR description. Explain what changed and what users can now do differently. If the PR body has useful context, distill it. If empty, infer from the title.] [Repeat for each significant PR — skip pure chores/dep bumps unless noteworthy] @@ -80,13 +80,14 @@ A huge thank you to everyone who made this release happen: [list @handles — ex ### 4. Tone & Style Guidelines -- **Lead with value**: "You can now..." beats "We added..." -- **Be specific**: Name the feature, not just the category -- **Use energy words**: "blazing", "seamless", "rock-solid", "powerful", "finally" +- **Lead with what changed**: "You can now..." or "Forge now..." beats "We added..." +- **Be specific**: Name the feature and describe what it does, not just the category +- **Be informative, not marketty**: Avoid vague adjectives like "seamless", "smarter", "blazing", "powerful", "rock-solid". Instead, state the concrete fact (e.g. "editor no longer spawns a git process on every keystroke" beats "blazing-fast editor") +- **Enthusiasm through substance**: Let the actual improvement speak for itself. Use active, direct language. - **Short paragraphs**: Max 3 sentences per block - **Skip internal jargon**: Translate crate names and internal concepts into plain language -- **Celebrate contributors**: Name them enthusiastically -- **Tagline formula**: `[Version] — [Adjective] [Theme]` (e.g. "v1.32.0 — Smarter Config, Smoother Workflows") +- **Celebrate contributors**: Name them by handle +- **Tagline formula**: `[Version] — [Factual Theme Description]` (e.g. "v1.32.0 — Terminal Context, File Drop Support, Windows Performance") - **No implementation details**: Do not mention internal module names, struct names, function names, crate names, or how something was implemented. Focus purely on what the user experiences or gains. - **No PR/issue references**: Do not include PR numbers, issue numbers, or links to GitHub PRs/issues in the release notes. Focus on the changes themselves, not their tracking identifiers. From 07d265e5873a04d12743fb779826d45fcbb8a2c6 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 15:39:59 +0530 Subject: [PATCH 02/25] feat(completer): replace nucleo fuzzy matcher with fzf-based file picker and add preview support --- crates/forge_main/Cargo.toml | 1 - .../src/completer/input_completer.rs | 376 +++--------------- crates/forge_select/src/select.rs | 33 +- crates/forge_select/src/widget.rs | 2 + 4 files changed, 83 insertions(+), 329 deletions(-) diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index b18cee0319..ad29d5bd66 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -38,7 +38,6 @@ lazy_static.workspace = true reedline.workspace = true crossterm = "0.29.0" nu-ansi-term.workspace = true -nucleo.workspace = true tracing.workspace = true chrono.workspace = true serde_json.workspace = true diff --git a/crates/forge_main/src/completer/input_completer.rs b/crates/forge_main/src/completer/input_completer.rs index 27c8eded03..7e88eab6cb 100644 --- a/crates/forge_main/src/completer/input_completer.rs +++ b/crates/forge_main/src/completer/input_completer.rs @@ -1,29 +1,22 @@ use std::path::PathBuf; use std::sync::Arc; +use forge_select::ForgeWidget; use forge_walker::Walker; -use nucleo::pattern::{CaseMatching, Normalization, Pattern}; -use nucleo::{Config, Matcher, Utf32Str}; -use reedline::{Completer, Suggestion}; +use reedline::{Completer, Span, Suggestion}; use crate::completer::CommandCompleter; use crate::completer::search_term::SearchTerm; use crate::model::ForgeCommandManager; pub struct InputCompleter { - walker: Walker, + cwd: PathBuf, command: CommandCompleter, - fuzzy_matcher: Matcher, } impl InputCompleter { pub fn new(cwd: PathBuf, command_manager: Arc) -> Self { - let walker = Walker::max_all().cwd(cwd).skip_binary(true); - Self { - walker, - command: CommandCompleter::new(command_manager), - fuzzy_matcher: Matcher::new(Config::DEFAULT.match_paths()), - } + Self { cwd, command: CommandCompleter::new(command_manager) } } } @@ -39,329 +32,58 @@ impl Completer for InputCompleter { } if let Some(query) = SearchTerm::new(line, pos).process() { - let files = self.walker.get_blocking().unwrap_or_default(); - let pattern = Pattern::parse( - escape_for_pattern_parse(query.term).as_str(), - CaseMatching::Smart, - Normalization::Smart, - ); - let mut scored_matches: Vec<(u32, Suggestion)> = files + let walker = Walker::max_all().cwd(self.cwd.clone()).skip_binary(true); + let files: Vec = walker + .get_blocking() + .unwrap_or_default() .into_iter() - .filter(|file| !file.is_dir()) - .filter_map(|file| { - let mut haystack_buf = Vec::new(); - let haystack = Utf32Str::new(&file.path, &mut haystack_buf); - if let Some(score) = pattern.score(haystack, &mut self.fuzzy_matcher) { - let path_md_fmt = format!("[{}]", file.path); - Some(( - score, - Suggestion { - description: None, - value: path_md_fmt, - style: None, - extra: None, - span: query.span, - append_whitespace: true, - match_indices: None, - display_override: None, - }, - )) - } else { - None - } - }) + .map(|file| file.path) .collect(); - // Sort by fuzzy match score (higher is better) - scored_matches.sort_by_key(|b| std::cmp::Reverse(b.0)); + // Preview command: show directory listing for dirs, file contents for files. + // {2} references the path column (items are formatted as "{idx}\t{path}"). + // Use bat for syntax-highlighted file previews when available, falling back + // to cat. Mirrors the shell plugin's _FORGE_CAT_CMD and completion.zsh preview. + let cat_cmd = if which_bat() { + "bat --color=always --style=numbers,changes --line-range=:500" + } else { + "cat" + }; + let preview_cmd = format!( + "if [ -d {{2}} ]; then ls -la --color=always {{2}} 2>/dev/null || ls -la {{2}}; else {cat_cmd} {{2}}; fi" + ); - // Extract suggestions from scored matches - scored_matches - .into_iter() - .map(|(_, suggestion)| suggestion) - .collect() - } else { - vec![] + let mut builder = ForgeWidget::select("File", files) + .with_preview(preview_cmd) + .with_preview_window("bottom:75%:wrap:border-sharp"); + if !query.term.is_empty() { + builder = builder.with_initial_text(query.term); + } + + if let Ok(Some(selected)) = builder.prompt() { + let value = format!("[{}]", selected); + return vec![Suggestion { + description: None, + value, + style: None, + extra: None, + span: Span::new(query.span.start, query.span.end), + append_whitespace: true, + match_indices: None, + display_override: None, + }]; + } } - } -} -fn escape_for_pattern_parse(term: &str) -> String { - let mut term_string = term.to_string(); - if term_string.ends_with('$') { - term_string.insert(term_string.len() - 1, '\\'); - } - if term_string.starts_with('\'') || term_string.starts_with('^') || term_string.starts_with('!') - { - term_string = format!("\\{term_string}"); + vec![] } - term_string } -#[cfg(test)] -mod tests { - use std::fs; - use std::sync::Arc; - - use tempfile::TempDir; - - use super::*; - use crate::model::ForgeCommandManager; - - fn create_test_fixture() -> (TempDir, InputCompleter) { - let temp_dir = TempDir::new().unwrap(); - let temp_path = temp_dir.path().to_path_buf(); - - // Create test files - fs::write(temp_path.join("config.rs"), "").unwrap(); - fs::write(temp_path.join("main.rs"), "").unwrap(); - fs::write(temp_path.join("lib.rs"), "").unwrap(); - fs::write(temp_path.join("test_file.txt"), "").unwrap(); - fs::write(temp_path.join("another_config.toml"), "").unwrap(); - fs::write(temp_path.join("main$"), "").unwrap(); - fs::write(temp_path.join("$main"), "").unwrap(); - fs::write(temp_path.join("ma$in"), "").unwrap(); - fs::write(temp_path.join("^main"), "").unwrap(); - fs::write(temp_path.join("main^"), "").unwrap(); - fs::write(temp_path.join("ma^in"), "").unwrap(); - fs::write(temp_path.join("!test"), "").unwrap(); - fs::write(temp_path.join("test!"), "").unwrap(); - fs::write(temp_path.join("te!st"), "").unwrap(); - fs::write(temp_path.join("'lib"), "").unwrap(); - fs::write(temp_path.join("lib'"), "").unwrap(); - fs::write(temp_path.join("li'b"), "").unwrap(); - - let command_manager = Arc::new(ForgeCommandManager::default()); - let completer = InputCompleter::new(temp_path, command_manager); - - (temp_dir, completer) - } - - #[test] - fn test_fuzzy_matching_works() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test fuzzy matching - "cfg" should match "config.rs" - let actual = completer.complete("@cfg", 4); - - // Should find config.rs and another_config.toml - assert!(!actual.is_empty()); - let config_match = actual.iter().find(|s| s.value.contains("config.rs")); - assert!( - config_match.is_some(), - "Should find config.rs with fuzzy matching" - ); - } - - #[test] - fn test_fuzzy_matching_ordering() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test that better matches come first - let actual = completer.complete("@config", 7); - - // config.rs should rank higher than another_config.toml for "config" query - assert!(actual.len() >= 2); - let first_match = &actual[0]; - assert!( - first_match.value.contains("config.rs"), - "config.rs should be the top match for 'config' query, got: {}", - first_match.value - ); - } - - #[test] - fn test_literal_fallback() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test that literal matching still works for exact substrings - let actual = completer.complete("@main", 5); - - assert!(!actual.is_empty()); - let main_match = actual.iter().find(|s| s.value.contains("main.rs")); - assert!( - main_match.is_some(), - "Should find main.rs with literal matching" - ); - } - - #[test] - fn test_special_character_dollar_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' at the end - let actual = completer.complete("@main$", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("main$")); - assert!( - match_found.is_some(), - "Should find main$ with literal matching in the end" - ); - } - - #[test] - fn test_special_character_dollar_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' at the start - let actual = completer.complete("@$main", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("$main")); - assert!( - match_found.is_some(), - "Should find $main with literal matching at the start" - ); - } - - #[test] - fn test_special_character_dollar_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' in the middle - let actual = completer.complete("@ma$in", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("ma$in")); - assert!( - match_found.is_some(), - "Should find ma$in with literal matching in the middle" - ); - } - - #[test] - fn test_special_character_caret_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' at the start - let actual = completer.complete("@^main", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("^main")); - assert!( - match_found.is_some(), - "Should find ^main with literal matching" - ); - } - - #[test] - fn test_special_character_caret_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' at the end - let actual = completer.complete("@main^", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("main^")); - assert!( - match_found.is_some(), - "Should find main^ with literal matching" - ); - } - - #[test] - fn test_special_character_caret_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' in the middle - let actual = completer.complete("@ma^in", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("ma^in")); - assert!( - match_found.is_some(), - "Should find ma^in with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' at the start - let actual = completer.complete("@!test", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("!test")); - assert!( - match_found.is_some(), - "Should find !test with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' at the end - let actual = completer.complete("@test!", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("test!")); - assert!( - match_found.is_some(), - "Should find test! with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' in the middle - let actual = completer.complete("@te!st", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("te!st")); - assert!( - match_found.is_some(), - "Should find te!st with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' at the start - let actual = completer.complete("@'lib", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("'lib")); - assert!( - match_found.is_some(), - "Should find 'lib with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' at the end - let actual = completer.complete("@lib'", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("lib'")); - assert!( - match_found.is_some(), - "Should find lib' with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' in the middle - let actual = completer.complete("@li'b", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("li'b")); - assert!( - match_found.is_some(), - "Should find li'b with literal matching" - ); - } +/// Returns `true` if the `bat` binary is available on `PATH`. +fn which_bat() -> bool { + std::process::Command::new("which") + .arg("bat") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } diff --git a/crates/forge_select/src/select.rs b/crates/forge_select/src/select.rs index 3d0b7605b6..2eb0c5b70f 100644 --- a/crates/forge_select/src/select.rs +++ b/crates/forge_select/src/select.rs @@ -13,6 +13,8 @@ pub struct SelectBuilder { pub(crate) help_message: Option<&'static str>, pub(crate) initial_text: Option, pub(crate) header_lines: usize, + pub(crate) preview: Option, + pub(crate) preview_window: Option, } /// Builds an `Fzf` instance with standard layout and an optional header. @@ -48,6 +50,8 @@ fn build_fzf( initial_text: Option<&str>, starting_cursor: Option, header_lines: usize, + preview: Option<&str>, + preview_window: Option<&str>, ) -> Fzf { let mut builder = Fzf::builder(); builder.layout(Layout::Reverse); @@ -77,6 +81,12 @@ fn build_fzf( if header_lines > 0 { args.push(format!("--header-lines={}", header_lines)); } + if let Some(cmd) = preview { + args.push(format!("--preview={}", cmd)); + } + if let Some(window) = preview_window { + args.push(format!("--preview-window={}", window)); + } builder.custom_args(args); builder @@ -110,6 +120,25 @@ impl SelectBuilder { self } + /// Set a preview command shown in a side panel as the user navigates items. + /// + /// The command is passed directly to fzf's `--preview` flag. Use `{2}` to + /// reference the display field of the currently highlighted item (field 2 + /// after the internal index tab-prefix). + pub fn with_preview(mut self, command: impl Into) -> Self { + self.preview = Some(command.into()); + self + } + + /// Set the layout of the preview panel. + /// + /// Passed directly to fzf's `--preview-window` flag (e.g. + /// `"bottom:75%:wrap:border-sharp"`). + pub fn with_preview_window(mut self, layout: impl Into) -> Self { + self.preview_window = Some(layout.into()); + self + } + /// Set default for confirm (only works with bool options). pub fn with_default(mut self, default: bool) -> Self { self.default = Some(default); @@ -179,6 +208,8 @@ impl SelectBuilder { self.initial_text.as_deref(), self.starting_cursor, self.header_lines, + self.preview.as_deref(), + self.preview_window.as_deref(), ); let selected = run_with_output(fzf, indexed_items(&display_options)); @@ -205,7 +236,7 @@ fn prompt_confirm(message: &str, default: Option) -> Result> Some(0) }; - let fzf = build_fzf(message, None, None, starting_cursor, 0); + let fzf = build_fzf(message, None, None, starting_cursor, 0, None, None); let selected = run_with_output(fzf, items.iter().copied()); let result: Option = match selected.as_deref().map(str::trim) { diff --git a/crates/forge_select/src/widget.rs b/crates/forge_select/src/widget.rs index b3c49a9d91..ae64ceadd6 100644 --- a/crates/forge_select/src/widget.rs +++ b/crates/forge_select/src/widget.rs @@ -20,6 +20,8 @@ impl ForgeWidget { help_message: None, initial_text: None, header_lines: 0, + preview: None, + preview_window: None, } } From d8ff3251edd77bd88fc693dd7ec23cdd91c2e8e1 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 16:16:01 +0530 Subject: [PATCH 03/25] feat(cli): add `list file` command and migrate shell plugin file listing to it --- crates/forge_infra/src/fs_read_dir.rs | 1 + crates/forge_infra/src/walker.rs | 3 +- crates/forge_main/src/cli.rs | 7 + crates/forge_main/src/ui.rs | 32 ++++ crates/forge_walker/src/walker.rs | 231 +++++++++++++++++--------- shell-plugin/lib/completion.zsh | 2 +- shell-plugin/lib/config.zsh | 1 + 7 files changed, 200 insertions(+), 77 deletions(-) diff --git a/crates/forge_infra/src/fs_read_dir.rs b/crates/forge_infra/src/fs_read_dir.rs index 02e370af48..761103fe71 100644 --- a/crates/forge_infra/src/fs_read_dir.rs +++ b/crates/forge_infra/src/fs_read_dir.rs @@ -37,6 +37,7 @@ impl ForgeDirectoryReaderService { .cwd(directory.to_path_buf()) .max_depth(1) .skip_binary(true) + .hidden(true) .get() .await?; diff --git a/crates/forge_infra/src/walker.rs b/crates/forge_infra/src/walker.rs index ba7f6d1dac..344e0264bc 100644 --- a/crates/forge_infra/src/walker.rs +++ b/crates/forge_infra/src/walker.rs @@ -16,7 +16,8 @@ impl ForgeWalkerService { && config.max_files.is_none() && config.max_total_size.is_none() { - forge_walker::Walker::max_all() + // Agent-facing walker: keep hidden files excluded by default. + forge_walker::Walker::max_all().hidden(true) } else { forge_walker::Walker::min_all() }; diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index d0c1e9eef3..1af889ab80 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -366,6 +366,13 @@ pub enum ListCommand { #[arg(long)] custom: bool, }, + + /// List files and directories in the current workspace. + /// + /// Includes hidden files and directories (dotfiles), respects .gitignore, + /// and outputs one path per line. Directories are suffixed with `/`. + #[command(alias = "files")] + File, } /// Shell extension commands. diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 35d55f75cd..c0f8c3f05d 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -25,6 +25,7 @@ use forge_fs::ForgeFS; use forge_select::ForgeWidget; use forge_spinner::SpinnerManager; use forge_tracker::ToolCallPayload; +use forge_walker::Walker; use futures::future; use tokio_stream::StreamExt; use url::Url; @@ -439,6 +440,9 @@ impl A + Send + Sync> UI ListCommand::Skill { custom } => { self.on_show_skills(porcelain, custom).await?; } + ListCommand::File => { + self.on_list_files(porcelain).await?; + } } return Ok(()); } @@ -1451,6 +1455,34 @@ impl A + Send + Sync> UI Ok(()) } + /// Lists files and directories in the current workspace. + /// + /// Uses the same `Walker::max_all()` configuration as the REPL file picker + /// and the shell plugin (`fd --type f --type d --hidden --exclude .git`): + /// hidden files included, respects `.gitignore`, directories suffixed with `/`. + async fn on_list_files(&mut self, porcelain: bool) -> anyhow::Result<()> { + let env = self.api.environment(); + let files = Walker::max_all() + .cwd(env.cwd.clone()) + .get() + .await + .context("Failed to walk workspace files")?; + + if porcelain { + for file in files { + self.writeln(file.path)?; + } + } else { + let mut info = Info::new(); + for file in &files { + info = info.add_key_value("path", file.path.clone()); + } + self.writeln(info)?; + } + + Ok(()) + } + /// Lists current configuration values async fn on_show_config(&mut self, porcelain: bool) -> anyhow::Result<()> { // Get the effective resolved config diff --git a/crates/forge_walker/src/walker.rs b/crates/forge_walker/src/walker.rs index f19ad1a2d3..58c817f91d 100644 --- a/crates/forge_walker/src/walker.rs +++ b/crates/forge_walker/src/walker.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use derive_setters::Setters; @@ -41,6 +42,11 @@ pub struct Walker { /// Whether to skip binary files skip_binary: bool, + + /// Whether to hide hidden files and directories (those starting with `.`). + /// When `true` (the default), dotfiles are excluded from results. + /// Set to `false` to include them, matching `fd --hidden`. + hidden: bool, } const DEFAULT_MAX_FILE_SIZE: u64 = 1024 * 1024; // 1MB @@ -61,6 +67,7 @@ impl Walker { max_files: DEFAULT_MAX_FILES, max_total_size: DEFAULT_MAX_TOTAL_SIZE, skip_binary: true, + hidden: true, } } @@ -76,6 +83,8 @@ impl Walker { max_files: usize::MAX, max_total_size: u64::MAX, skip_binary: false, + // Include hidden files (dotfiles) — matches `fd --hidden`. + hidden: false, } } } @@ -107,99 +116,163 @@ impl Walker { /// Blocking function to scan filesystem. Use this when you already have /// a runtime or want to avoid spawning a new one. pub fn get_blocking(&self) -> Result> { - let mut files = Vec::new(); - let mut total_size = 0u64; - let mut dir_entries: HashMap = HashMap::new(); - let mut file_count = 0; + // Shared state collected across parallel walker threads. + let collected: Arc>> = Arc::new(Mutex::new(Vec::new())); + // Per-directory entry counters for breadth limiting (shared across threads). + let dir_entries: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + // Global counters protected by a single mutex to enforce total limits. + // Layout: (total_size, file_count, quit) + let global: Arc> = Arc::new(Mutex::new((0, 0, false))); + + let cwd = self.cwd.clone(); + let max_depth = self.max_depth; + let max_breadth = self.max_breadth; + let max_file_size = self.max_file_size; + let max_files = self.max_files; + let max_total_size = self.max_total_size; + let skip_binary = self.skip_binary; // TODO: Convert to async and return a stream - let walk = WalkBuilder::new(&self.cwd) + let walk_parallel = WalkBuilder::new(&self.cwd) .standard_filters(true) // use standard ignore filters. + .hidden(self.hidden) .require_git(false) .max_depth(Some(self.max_depth)) // Skip files that exceed size limit .max_filesize(Some(self.max_file_size)) - // TODO: use build_parallel() for better performance - .build(); - - 'walk_loop: for entry in walk.flatten() { - let path = entry.path(); - - // Skip symlinks — we only process real files and directories. - if entry.path_is_symlink() { - continue; - } + .filter_entry(|entry| { + // Always exclude the `.git` directory, matching `fd --exclude .git`. + entry.file_name() != ".git" + }) + .build_parallel(); + + walk_parallel.run(|| { + // Each thread gets its own clone of the shared state. + let collected = Arc::clone(&collected); + let dir_entries = Arc::clone(&dir_entries); + let global = Arc::clone(&global); + let cwd = cwd.clone(); + + Box::new(move |result| { + // Check if a previous thread already triggered the quit signal. + { + let g = global.lock().unwrap(); + if g.2 { + return ignore::WalkState::Quit; + } + } - // Calculate depth relative to base directory - let depth = path - .strip_prefix(&self.cwd) - .map(|p| p.components().count()) - .unwrap_or(0); + let entry = match result { + Ok(e) => e, + Err(_) => return ignore::WalkState::Continue, + }; - if depth > self.max_depth { - continue; - } + let path = entry.path(); - // Handle breadth limit - if let Some(parent) = path.parent() { - let parent_path = parent.to_string_lossy().to_string(); - let entry_count = dir_entries.entry(parent_path).or_insert(0); - *entry_count += 1; - - if *entry_count > self.max_breadth { - continue; + // Skip symlinks — we only process real files and directories. + if entry.path_is_symlink() { + return ignore::WalkState::Continue; } - } - let is_dir = path.is_dir(); + // Calculate depth relative to base directory. + let depth = path + .strip_prefix(&cwd) + .map(|p| p.components().count()) + .unwrap_or(0); - // Skip binary files if configured - if self.skip_binary && !is_dir && Self::is_likely_binary(path) { - continue; - } + // Skip the root directory itself (depth 0 = the cwd), matching + // `fd` behaviour which never emits the starting directory. + if depth == 0 { + return ignore::WalkState::Continue; + } - let metadata = match path.metadata() { - Ok(meta) => meta, - Err(_) => continue, // Skip files we can't read metadata for - }; + if depth > max_depth { + return ignore::WalkState::Continue; + } - let file_size = metadata.len(); + // Handle breadth limit — uses a shared mutex. + if let Some(parent) = path.parent() { + let parent_path = parent.to_string_lossy().to_string(); + let mut de = dir_entries.lock().unwrap(); + let entry_count = de.entry(parent_path).or_insert(0); + *entry_count += 1; + if *entry_count > max_breadth { + return ignore::WalkState::Continue; + } + } - // Check total size limit - if total_size + file_size > self.max_total_size { - break 'walk_loop; - } + let is_dir = path.is_dir(); - // Check if we've hit the file count limit (only count non-directories) - if !is_dir { - file_count += 1; - if file_count > self.max_files { - break 'walk_loop; + // Skip binary files if configured. + if skip_binary && !is_dir && Walker::is_likely_binary(path) { + return ignore::WalkState::Continue; } - } - let relative_path = path - .strip_prefix(&self.cwd) - .with_context(|| format!("Failed to strip prefix from path: {}", path.display()))?; - let path_string = relative_path.to_string_lossy().to_string(); + let metadata = match path.metadata() { + Ok(meta) => meta, + Err(_) => return ignore::WalkState::Continue, + }; + + let file_size = metadata.len(); + + // Enforce global total-size and file-count limits atomically. + { + let mut g = global.lock().unwrap(); + if g.2 { + return ignore::WalkState::Quit; + } + if g.0 + file_size > max_total_size { + g.2 = true; + return ignore::WalkState::Quit; + } + if !is_dir { + if g.1 >= max_files { + g.2 = true; + return ignore::WalkState::Quit; + } + g.1 += 1; + g.0 += file_size; + } + } - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()); + // Build relative path string. + let relative_path = match path.strip_prefix(&cwd) { + Ok(p) => p, + Err(_) => return ignore::WalkState::Continue, + }; + let path_string = relative_path.to_string_lossy().to_string(); + + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()); + + // Ensure directory paths end with '/' for is_dir(). + let path_string = if is_dir { + format!("{path_string}/") + } else { + path_string + }; + + // Filter out entries whose file_size exceeds the per-file limit. + // (WalkBuilder::max_filesize only applies to regular files; double-check.) + if !is_dir && file_size > max_file_size { + return ignore::WalkState::Continue; + } - // Ensure directory paths end with '/' for is_dir() function - let path_string = if is_dir { - format!("{path_string}/") - } else { - path_string - }; + collected + .lock() + .unwrap() + .push(File { path: path_string, file_name, size: file_size }); - files.push(File { path: path_string, file_name, size: file_size }); + ignore::WalkState::Continue + }) + }); - if !is_dir { - total_size += file_size; - } - } + let files = Arc::try_unwrap(collected) + .expect("all walker threads finished") + .into_inner() + .unwrap(); Ok(files) } @@ -383,10 +456,14 @@ mod tests { #[tokio::test] async fn test_file_name_and_is_dir() { - let fixture = fixtures::create_sized_files(&[("test.txt".into(), 100)]).unwrap(); + // Use a file inside a subdirectory so the walker emits both a directory + // entry ("subdir/") and a file entry ("subdir/test.txt"). + // The root directory itself is never emitted (matching `fd` behaviour). + let fixture = fixtures::Fixture::default(); + fixture.add_file("subdir/test.txt", "hello").unwrap(); let actual = Walker::min_all() - .cwd(fixture.path().to_path_buf()) + .cwd(fixture.as_path().to_path_buf()) .get() .await .unwrap(); @@ -443,7 +520,8 @@ mod tests { .await .unwrap(); - let mut expected = vec!["included/main.rs", "included/test.rs", "base.rs"]; + // .ignore itself is a dotfile and is visible when hidden: false (matches fd --hidden). + let mut expected = vec![".ignore", "included/main.rs", "included/test.rs", "base.rs"]; expected.sort(); let mut actual_files: Vec<_> = actual @@ -577,7 +655,8 @@ mod tests { .map(|f| f.path.as_str()) .collect(); actual.sort(); - let expected = vec!["frontend/src/main.ts", "src/main.rs"]; + // .gitignore files are dotfiles and visible when hidden: false (matches fd --hidden). + let expected = vec![".gitignore", "frontend/.gitignore", "frontend/src/main.ts", "src/main.rs"]; assert_eq!(actual, expected, "should respect nested .gitignore files"); } @@ -615,7 +694,9 @@ mod tests { .map(|f| f.path.as_str()) .collect(); actual.sort(); - let expected = vec!["frontend/src/main.ts", "src/main.rs"]; + // .gitignore files are dotfiles and visible when hidden: false (matches fd --hidden). + // .git directory is always excluded (matching fd --exclude .git). + let expected = vec![".gitignore", "frontend/.gitignore", "frontend/src/main.ts", "src/main.rs"]; assert_eq!( actual, expected, "should respect nested .gitignore in git repos" diff --git a/shell-plugin/lib/completion.zsh b/shell-plugin/lib/completion.zsh index 50cf9f440a..ceb196cbab 100644 --- a/shell-plugin/lib/completion.zsh +++ b/shell-plugin/lib/completion.zsh @@ -14,7 +14,7 @@ function forge-completion() { $_FORGE_PREVIEW_WINDOW ) - local file_list=$($_FORGE_FD_CMD --type f --type d --hidden --exclude .git) + local file_list=$(${FORGE_BIN:-forge} list files --porcelain) if [[ -n "$filter_text" ]]; then selected=$(echo "$file_list" | _forge_fzf --query "$filter_text" "${fzf_args[@]}") else diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index 4086f72f27..42aa5089ce 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -10,6 +10,7 @@ typeset -h _FORGE_DELIMITER='\s\s+' typeset -h _FORGE_PREVIEW_WINDOW="--preview-window=bottom:75%:wrap:border-sharp" # Detect fd command - Ubuntu/Debian use 'fdfind', others use 'fd' +# FIXME: Can drop FD requirement if it remains unused typeset -h _FORGE_FD_CMD="$(command -v fdfind 2>/dev/null || command -v fd 2>/dev/null || echo 'fd')" # Detect bat command - use bat if available, otherwise fall back to cat From 67af95b241af97bed64be68681fc14902ff323c4 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 16:22:17 +0530 Subject: [PATCH 04/25] feat(completer): replace command completion list with fzf-based picker --- crates/forge_main/src/completer/command.rs | 71 ++++++++++++++++------ crates/forge_main/src/lib.rs | 2 +- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/forge_main/src/completer/command.rs b/crates/forge_main/src/completer/command.rs index 327830f853..eae2ea0206 100644 --- a/crates/forge_main/src/completer/command.rs +++ b/crates/forge_main/src/completer/command.rs @@ -1,8 +1,20 @@ use std::sync::Arc; +use forge_select::ForgeWidget; use reedline::{Completer, Span, Suggestion}; -use crate::model::ForgeCommandManager; +use crate::model::{ForgeCommand, ForgeCommandManager}; + +/// A display wrapper for `ForgeCommand` that renders the name and description +/// side-by-side for fzf. +#[derive(Clone)] +struct CommandRow(ForgeCommand); + +impl std::fmt::Display for CommandRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:<30} {}", self.0.name, self.0.description) + } +} #[derive(Clone)] pub struct CommandCompleter(Arc); @@ -15,35 +27,60 @@ impl CommandCompleter { impl Completer for CommandCompleter { fn complete(&mut self, line: &str, _: usize) -> Vec { - self.0 + // Build the list of display names prefixed with `/` or `!`. + let commands: Vec = self + .0 .list() .into_iter() .filter_map(|cmd| { - // For command completion, we want to show commands with `/` prefix let display_name = if cmd.name.starts_with('!') { - // Shell commands already have the `!` prefix cmd.name.clone() } else { - // Add `/` prefix for slash commands format!("/{}", cmd.name) }; - // Check if the display name starts with what the user typed + // Only include commands that match what the user has typed so far. if display_name.starts_with(line) { - Some(Suggestion { - value: display_name, - description: Some(cmd.description), - style: None, - extra: None, - span: Span::new(0, line.len()), - append_whitespace: false, - match_indices: None, - display_override: None, - }) + Some(CommandRow(ForgeCommand { + name: display_name, + description: cmd.description, + value: cmd.value, + })) } else { None } }) - .collect() + .collect(); + + if commands.is_empty() { + return vec![]; + } + + // Extract the initial query text (everything after the leading `/` or `!`). + let initial_query = line + .strip_prefix('/') + .or_else(|| line.strip_prefix('!')) + .unwrap_or(line); + + let mut builder = ForgeWidget::select("Command", commands); + if !initial_query.is_empty() { + builder = builder.with_initial_text(initial_query); + } + + match builder.prompt() { + Ok(Some(row)) => { + vec![Suggestion { + value: row.0.name, + description: None, + style: None, + extra: None, + span: Span::new(0, line.len()), + append_whitespace: true, + match_indices: None, + display_override: None, + }] + } + _ => vec![], + } } } diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 1fc22a116d..1ce2b5a7ac 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -26,7 +26,7 @@ mod update; use std::sync::LazyLock; -pub use cli::{Cli, TopLevelCommand}; +pub use cli::{Cli, ListCommand, ListCommandGroup, TopLevelCommand}; pub use sandbox::Sandbox; pub use title_display::*; pub use ui::UI; From 393624a0da962b276f9b2f89caf282c405e056db Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 16:26:36 +0530 Subject: [PATCH 05/25] feat(highlighter): add syntax highlighter for slash commands, shell commands, and file mentions --- crates/forge_main/src/editor.rs | 2 + crates/forge_main/src/highlighter.rs | 219 +++++++++++++++++++++++++++ crates/forge_main/src/lib.rs | 1 + 3 files changed, 222 insertions(+) create mode 100644 crates/forge_main/src/highlighter.rs diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index c787850bca..65be838ee6 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -12,6 +12,7 @@ use reedline::{ use super::completer::InputCompleter; use super::zsh::paste::wrap_pasted_text; +use crate::highlighter::ForgeHighlighter; use crate::model::ForgeCommandManager; use crate::prompt::ForgePrompt; @@ -92,6 +93,7 @@ impl ForgeEditor { let editor = Reedline::create() .with_completer(Box::new(InputCompleter::new(env.cwd, manager))) .with_history(history) + .with_highlighter(Box::new(ForgeHighlighter)) .with_hinter(Box::new( DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)), )) diff --git a/crates/forge_main/src/highlighter.rs b/crates/forge_main/src/highlighter.rs new file mode 100644 index 0000000000..00e3e55ae0 --- /dev/null +++ b/crates/forge_main/src/highlighter.rs @@ -0,0 +1,219 @@ +use nu_ansi_term::{Color, Style}; +use reedline::{Highlighter, StyledText}; + +/// Syntax highlighter for the forge readline prompt. +/// +/// Applies visual styles to recognised input patterns as the user types: +/// - Slash commands (`/foo`) are rendered in yellow bold. +/// - File mentions (`@[path]`) are rendered in cyan bold. +/// - Shell pass-through commands (`!cmd`) are rendered in magenta. +/// - All other text is rendered in the default terminal style. +pub struct ForgeHighlighter; + +impl Highlighter for ForgeHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> StyledText { + let mut styled = StyledText::new(); + + if line.is_empty() { + return styled; + } + + // Slash command: highlight the command token (e.g. `/compact`) in yellow bold, + // then the remainder (arguments) without special styling. + if line.starts_with('/') { + let end = line + .find(|c: char| c.is_whitespace()) + .unwrap_or(line.len()); + styled.push((Style::new().bold().fg(Color::Yellow), line[..end].to_string())); + if end < line.len() { + styled.push((Style::new(), line[end..].to_string())); + } + return styled; + } + + // Shell pass-through: `!` rendered in magenta. + if line.starts_with('!') { + styled.push((Style::new().fg(Color::Magenta), line.to_string())); + return styled; + } + + // General message text — scan for `@[...]` file mentions and colour them cyan bold. + highlight_mentions(line, &mut styled); + + styled + } +} + +/// Walk through `line` and emit styled segments, colouring every `@[...]` +/// mention (matching the ZSH pattern `@\[[^]]*\]`) in cyan bold and leaving +/// surrounding text unstyled. +/// +/// Mirrors `ZSH_HIGHLIGHT_PATTERNS+=('@\[[^]]#\]' 'fg=cyan,bold')` exactly: +/// - Requires `@[` opener. +/// - Matches zero or more non-`]` characters inside the brackets. +/// - Requires closing `]` — unterminated tags are left unstyled. +fn highlight_mentions(line: &str, styled: &mut StyledText) { + let mut remaining = line; + + while !remaining.is_empty() { + // Find the next `@[` opener. + match remaining.find("@[") { + None => { + // No more mentions — emit the rest as plain text. + styled.push((Style::new(), remaining.to_string())); + break; + } + Some(start) => { + // Emit any plain text before the `@[`. + if start > 0 { + styled.push((Style::new(), remaining[..start].to_string())); + } + + // `after_open` starts at `@[`. + let after_open = &remaining[start..]; + + // Look for a `]` that is not immediately after `@[`. + // The ZSH pattern `[^]]#` matches zero-or-more non-`]` chars, + // so `@[]` (empty brackets) also qualifies. + // We search for `]` starting from position 2 (after `@[`). + match after_open[2..].find(']') { + None => { + // No closing `]` — emit `@[` and the rest as plain text + // to match ZSH behaviour (unterminated tag = no highlight). + styled.push((Style::new(), after_open.to_string())); + break; + } + Some(rel_close) => { + // Absolute position of `]` within `after_open`. + let close = 2 + rel_close; + // Emit `@[...]` in cyan bold (inclusive of both brackets). + let mention = &after_open[..=close]; + styled.push(( + Style::new().bold().fg(Color::Cyan), + mention.to_string(), + )); + remaining = &after_open[close + 1..]; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(styled: &StyledText) -> String { + styled.buffer.iter().map(|(_, s)| s.as_str()).collect() + } + + fn styles(styled: &StyledText) -> Vec