Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion app/src/search/ai_context_menu/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ pub fn is_valid_search_query(is_navigation: bool, prev_query: &str, query: &str)
// We need a simple heuristic to handle when somebody jumps to the end
// of the line. Since spaces are valid characters, we only count
// how many spaces the users likely jumped over between queries
let new_chars = query.chars().skip(prev_query.len());
let new_chars = query.chars().skip(prev_query.chars().count());
return new_chars.filter(|c| *c == ' ').count() < MAX_NEW_SPACES;
}
true
}

#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
9 changes: 9 additions & 0 deletions app/src/search/ai_context_menu/search_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use super::is_valid_search_query;

#[test]
fn navigation_heuristic_counts_chars_not_bytes() {
let prev_query = "→";
let query = "→ ls";

assert!(!is_valid_search_query(true, prev_query, query));
}
49 changes: 43 additions & 6 deletions app/src/search/command_palette/navigation/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ fn searchable_session_string_and_ranges(
session: &SessionNavigationData,
) -> (String, SearchableSessionStringRanges) {
let mut searchable_string = session.prompt().to_string();
let prompt_end = session.prompt().chars().count();
let prompt = session.prompt();
let prompt_end = if prompt.is_ascii() {
prompt.len()
} else {
prompt.chars().count()
};

let command_range = match session.command_context() {
CommandContext::LastRunCommand {
Expand All @@ -142,7 +147,12 @@ fn searchable_session_string_and_ranges(
searchable_string.push_str(last_run_command.as_str());

let start = prompt_end + 1;
let end = start + last_run_command.chars().count();
let end = start
+ if last_run_command.is_ascii() {
last_run_command.len()
} else {
last_run_command.chars().count()
};
Some(start..end)
}
CommandContext::RunningCommand { running_command } => {
Expand All @@ -151,7 +161,12 @@ fn searchable_session_string_and_ranges(
searchable_string.push_str(running_command.as_str());

let start = prompt_end + 1;
let end = start + running_command.chars().count();
let end = start
+ if running_command.is_ascii() {
running_command.len()
} else {
running_command.chars().count()
};
Some(start..end)
}
CommandContext::LastRunAIBlock { prompt } | CommandContext::RunningAIBlock { prompt } => {
Expand All @@ -160,7 +175,12 @@ fn searchable_session_string_and_ranges(
searchable_string.push_str(prompt.as_str());

let start = prompt_end + 1;
let end = start + prompt.chars().count();
let end = start
+ if prompt.is_ascii() {
prompt.len()
} else {
prompt.chars().count()
};
Some(start..end)
}
CommandContext::None => None,
Expand All @@ -172,12 +192,22 @@ fn searchable_session_string_and_ranges(
let hint_text_range = match &command_range {
Some(command_range) => {
let start = command_range.end + 1;
let end = start + command_info.hint_text.chars().count();
let end = start
+ if command_info.hint_text.is_ascii() {
command_info.hint_text.len()
} else {
command_info.hint_text.chars().count()
};
start..end
}
None => {
let start = prompt_end + 1;
let end = start + command_info.hint_text.chars().count();
let end = start
+ if command_info.hint_text.is_ascii() {
command_info.hint_text.len()
} else {
command_info.hint_text.chars().count()
};
start..end
}
};
Expand Down Expand Up @@ -363,6 +393,13 @@ mod full_text_searcher {
/// char-based indices that align with the char-based ranges used by
/// [`SessionHighlightIndices`].
pub(super) fn byte_indices_to_char_indices(text: &str, byte_indices: Vec<usize>) -> Vec<usize> {
if text.is_ascii() {
return byte_indices
.into_iter()
.filter(|&byte_idx| byte_idx < text.len())
.collect();
}

let byte_to_char: HashMap<usize, usize> = text
.char_indices()
.enumerate()
Expand Down
41 changes: 40 additions & 1 deletion app/src/search/command_palette/navigation/search_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
use warpui::{EntityId, WindowId};

use super::full_text_searcher::byte_indices_to_char_indices;
use super::{SearchableSessionStringRanges, SessionHighlightIndices};
use super::{
searchable_session_string_and_ranges, SearchableSessionStringRanges, SessionHighlightIndices,
};
use crate::pane_group::PaneId;
use crate::session_management::{
CommandContext, SessionNavigationData, SessionNavigationPromptElements,
};
use crate::terminal::shared_session::SharedSessionStatus;
use crate::workspace::PaneViewLocator;

// ── byte_indices_to_char_indices ─────────────────────────────────────

Expand Down Expand Up @@ -68,6 +78,35 @@ fn out_of_bounds_byte_indices_are_dropped() {
);
}

#[test]
fn ascii_searchable_session_ranges_use_byte_lengths() {
let session = SessionNavigationData::new(
"abc".to_string(),
SessionNavigationPromptElements {
ps1_prompt_grid: None,
prompt_chip_snapshot: None,
},
CommandContext::LastRunCommand {
last_run_command: "ls".to_string(),
mins_since_completion: Some(3),
},
PaneViewLocator {
pane_group_id: EntityId::new(),
pane_id: PaneId::dummy_pane_id(),
},
None,
false,
WindowId::new(),
SharedSessionStatus::NotShared,
);

let (searchable, ranges) = searchable_session_string_and_ranges(&session);

assert_eq!(searchable, "abc ls Completed 3 minutes ago");
assert_eq!(ranges.command_range, Some(4..6));
assert_eq!(ranges.hint_text_range, 7..30);
}

// ── End-to-end: highlight pipeline with multi-byte prompt ────────────

/// Simulates the same range construction that `searchable_session_string_and_ranges`
Expand Down
116 changes: 74 additions & 42 deletions app/src/search/files/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,54 +402,25 @@ impl FileSearchModel {
/// Prioritizes filename matches with 2x weight compared to path matches
/// Supports space-separated queries - all parts must match for a result
pub fn fuzzy_match_path(path: &str, query: &str) -> Option<FuzzyMatchResult> {
if query.is_empty() {
return Some(FuzzyMatchResult::no_match());
}

// Split query by spaces to support multi-term searches
let query_parts: Vec<&str> = query.split_whitespace().collect();

if query_parts.is_empty() {
return Some(FuzzyMatchResult::no_match());
}

// If there's only one query part, use the original logic
if query_parts.len() == 1 {
return Self::fuzzy_match_path_single(path, query_parts[0]);
}

// For multiple query parts, each part must match somewhere in the path
let mut combined_score = 0i64;
let mut all_matched_indices = Vec::new();

for query_part in &query_parts {
if let Some(part_result) = Self::fuzzy_match_path_single(path, query_part) {
combined_score += part_result.score;
all_matched_indices.extend(part_result.matched_indices);
} else {
// If any part doesn't match, the entire query fails
return None;
}
}

// Remove duplicates and sort indices
all_matched_indices.sort_unstable();
all_matched_indices.dedup();

Some(FuzzyMatchResult {
score: combined_score,
matched_indices: all_matched_indices,
})
let query = FuzzyPathQuery::new(query);
query.match_path(path)
}

/// Helper method for single-term fuzzy matching (supports wildcards)
pub fn fuzzy_match_path_single(path: &str, query: &str) -> Option<FuzzyMatchResult> {
Self::fuzzy_match_path_single_with_wildcard_flag(path, query, contains_wildcards(query))
}

fn fuzzy_match_path_single_with_wildcard_flag(
path: &str,
query: &str,
has_wildcards: bool,
) -> Option<FuzzyMatchResult> {
if query.is_empty() {
return Some(FuzzyMatchResult::no_match());
}

// Check if query contains wildcards
if contains_wildcards(query) {
if has_wildcards {
// Use wildcard matching for the entire path
return match_wildcard_pattern_case_insensitive(path, query);
}
Expand Down Expand Up @@ -608,8 +579,7 @@ impl FileSearchModel {
return original_score;
}

let path_chars: Vec<char> = path.chars().collect();
let total_chars = path_chars.len();
let total_chars = path.chars().count();

// Calculate weighted proximity score based on distance from end of path
let proximity_bonus: i64 = matched_indices
Expand Down Expand Up @@ -674,6 +644,68 @@ impl FileSearchModel {
}
}

struct FuzzyPathQueryPart<'a> {
text: &'a str,
has_wildcards: bool,
}

struct FuzzyPathQuery<'a> {
parts: Vec<FuzzyPathQueryPart<'a>>,
}

impl<'a> FuzzyPathQuery<'a> {
fn new(query: &'a str) -> Self {
let parts = query
.split_whitespace()
.map(|text| FuzzyPathQueryPart {
has_wildcards: contains_wildcards(text),
text,
})
.collect();

Self { parts }
}

fn match_path(&self, path: &str) -> Option<FuzzyMatchResult> {
if self.parts.is_empty() {
return Some(FuzzyMatchResult::no_match());
}

if self.parts.len() == 1 {
let part = &self.parts[0];
return FileSearchModel::fuzzy_match_path_single_with_wildcard_flag(
path,
part.text,
part.has_wildcards,
);
}

let mut combined_score = 0i64;
let mut all_matched_indices = Vec::new();

for part in &self.parts {
if let Some(part_result) = FileSearchModel::fuzzy_match_path_single_with_wildcard_flag(
path,
part.text,
part.has_wildcards,
) {
combined_score += part_result.score;
all_matched_indices.extend(part_result.matched_indices);
} else {
return None;
}
}

all_matched_indices.sort_unstable();
all_matched_indices.dedup();

Some(FuzzyMatchResult {
score: combined_score,
matched_indices: all_matched_indices,
})
}
}

impl SingletonEntity for FileSearchModel {}

impl Entity for FileSearchModel {
Expand Down
Loading