The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
1424 lines
50 KiB
Rust
1424 lines
50 KiB
Rust
//! Shared session picker helpers.
|
|
//!
|
|
//! Centralises data types, entry building, and index-mapping logic used by
|
|
//! both the welcome-screen session picker (`welcome/mod.rs` + `app_view.rs`)
|
|
//! and the modal session picker (`ActiveModal::SessionPicker` in
|
|
//! `agent_view.rs`).
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use indexmap::IndexMap;
|
|
|
|
use crate::app::app_view::SessionPickerEntry;
|
|
use crate::views::picker::{PickerEntry, PickerField, PickerRow, PickerState};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Offset added to content-hit indices in the picker `expanded` set so
|
|
/// they don't collide with fuzzy-entry indices.
|
|
pub const CONTENT_EXPAND_OFFSET: usize = 100_000;
|
|
|
|
/// Derive a short repo display name from a CWD path.
|
|
///
|
|
/// Uses the last 2 normal path components joined by `-`. For paths with
|
|
/// only one normal component (e.g., `/xai`), returns that component alone.
|
|
/// Does not perform tilde expansion — callers provide absolute paths.
|
|
/// Returns `"unknown"` for empty input.
|
|
///
|
|
/// Examples: `/home/user/fw/1` → `"fw-1"`, `/xai` → `"xai"`, `/` → `"/"`.
|
|
///
|
|
/// Shared by the session-list builder (which stamps each entry's `repo_name`)
|
|
/// and the picker pinning below, so the current-cwd key matches a group key.
|
|
/// Callers pass the *live* cwd (`app.cwd` / `agent.session.cwd`) so a project
|
|
/// switch (`Effect::SetWorkingDir`) is reflected immediately.
|
|
pub(crate) fn repo_name_from_cwd(cwd: &str) -> String {
|
|
let path = std::path::Path::new(cwd);
|
|
let components: Vec<&str> = path
|
|
.components()
|
|
.filter_map(|c| match c {
|
|
std::path::Component::Normal(os) => os.to_str(),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if components.is_empty() {
|
|
return if cwd.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
cwd.to_string()
|
|
};
|
|
}
|
|
let start = components.len().saturating_sub(2);
|
|
let tail = &components[start..];
|
|
tail.join("-")
|
|
}
|
|
|
|
/// Order repo groups alphabetically, then pin the current working
|
|
/// directory's repo group (if present) to the front. Shared by
|
|
/// [`build_entry_map`] and [`build_grouped_picker_entries`] so the
|
|
/// index-mapping and rendering paths stay in lock-step.
|
|
fn order_repo_groups(groups: &mut IndexMap<&str, Vec<usize>>, current_repo: Option<&str>) {
|
|
groups.sort_keys();
|
|
if let Some(cur) = current_repo
|
|
&& let Some(pos) = groups.keys().position(|k| *k == cur)
|
|
{
|
|
groups.move_index(pos, 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Which underlying data a picker position maps to.
|
|
#[derive(Debug, Clone)]
|
|
pub enum PickerItem {
|
|
Fuzzy { original_index: usize },
|
|
Content { hit_index: usize },
|
|
}
|
|
|
|
/// Owned data for a single session picker row. Built once per frame and
|
|
/// then borrowed by `PickerEntry` / `PickerField` slices. Shared between
|
|
/// the welcome-screen `render_session_picker` and the
|
|
/// `ActiveModal::SessionPicker` rendering in `agent_view.rs`.
|
|
pub struct SessionEntryData {
|
|
pub summary: String,
|
|
pub right_text: String,
|
|
pub is_selected: bool,
|
|
pub is_expanded: bool,
|
|
pub field_data: Vec<(String, String)>,
|
|
/// Short snippet preview for content search hits (always visible).
|
|
pub snippet_preview: Option<String>,
|
|
pub badge: &'static str,
|
|
pub collapsible: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum SessionPickerPendingNotice {
|
|
Empty(String),
|
|
Error(String),
|
|
}
|
|
|
|
/// Native/foreign completion state shared by session picker surfaces.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SessionPickerLanes {
|
|
pub(crate) foreign_loading: bool,
|
|
pub(crate) pending_notice: Option<SessionPickerPendingNotice>,
|
|
}
|
|
|
|
impl SessionPickerLanes {
|
|
pub(crate) fn take_ready_notice(&mut self, has_entries: bool) -> Option<String> {
|
|
match self.pending_notice.take() {
|
|
Some(SessionPickerPendingNotice::Empty(message)) if !has_entries => Some(message),
|
|
Some(SessionPickerPendingNotice::Error(message)) => Some(message),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source filter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Filter session entries by native, remote, or external source.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum SourceFilter {
|
|
#[default]
|
|
All,
|
|
Local,
|
|
Remote,
|
|
External,
|
|
}
|
|
|
|
impl SourceFilter {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::All => "All",
|
|
Self::Local => "Local",
|
|
Self::Remote => "Remote",
|
|
Self::External => "External",
|
|
}
|
|
}
|
|
|
|
pub fn next(self) -> Self {
|
|
match self {
|
|
Self::All => Self::Local,
|
|
Self::Local => Self::Remote,
|
|
Self::Remote => Self::External,
|
|
Self::External => Self::All,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` when a non-default filter is selected.
|
|
pub fn is_active(self) -> bool {
|
|
self != Self::All
|
|
}
|
|
|
|
/// Returns `true` if a session with the given `source` string passes the filter.
|
|
///
|
|
/// kimi.com conversations carry `source == "conversation"` and live remotely,
|
|
/// so they pass the `Remote` filter (and `All`) but not `Local`.
|
|
pub fn matches(self, source: &str) -> bool {
|
|
match self {
|
|
Self::All => true,
|
|
Self::Local => source == "local" || source == "both",
|
|
Self::Remote => source == "remote" || source == "both" || source == "conversation",
|
|
Self::External => crate::app::is_foreign_picker_source(source),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PickerSelectionAnchor {
|
|
key: Option<PickerSelectionKey>,
|
|
fallback_index: usize,
|
|
scroll_delta: Option<isize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum PickerSelectionKey {
|
|
Fuzzy { source: String, id: String },
|
|
Content { id: String },
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn capture_picker_selection(
|
|
entries: Option<&[SessionPickerEntry]>,
|
|
content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
|
|
state: &PickerState,
|
|
query: &str,
|
|
grouped: bool,
|
|
content_loading: bool,
|
|
source_filter: SourceFilter,
|
|
current_repo: Option<&str>,
|
|
) -> PickerSelectionAnchor {
|
|
let map = build_entry_map(
|
|
entries,
|
|
content_results,
|
|
query,
|
|
grouped,
|
|
content_loading,
|
|
source_filter,
|
|
current_repo,
|
|
);
|
|
let key = map
|
|
.get(state.selected)
|
|
.and_then(|item| item.as_ref())
|
|
.and_then(|item| match item {
|
|
PickerItem::Fuzzy { original_index } => entries
|
|
.and_then(|entries| entries.get(*original_index))
|
|
.map(|entry| PickerSelectionKey::Fuzzy {
|
|
source: entry.source.clone(),
|
|
id: entry.id.clone(),
|
|
}),
|
|
PickerItem::Content { hit_index } => content_results
|
|
.and_then(|results| results.get(*hit_index))
|
|
.map(|hit| PickerSelectionKey::Content {
|
|
id: hit.session_id.clone(),
|
|
}),
|
|
});
|
|
PickerSelectionAnchor {
|
|
key,
|
|
fallback_index: state.selected,
|
|
scroll_delta: state
|
|
.scroll_offset
|
|
.map(|offset| state.selected as isize - offset as isize),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn restore_picker_selection(
|
|
anchor: PickerSelectionAnchor,
|
|
entries: Option<&[SessionPickerEntry]>,
|
|
content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
|
|
state: &mut PickerState,
|
|
query: &str,
|
|
grouped: bool,
|
|
content_loading: bool,
|
|
source_filter: SourceFilter,
|
|
current_repo: Option<&str>,
|
|
) {
|
|
let map = build_entry_map(
|
|
entries,
|
|
content_results,
|
|
query,
|
|
grouped,
|
|
content_loading,
|
|
source_filter,
|
|
current_repo,
|
|
);
|
|
let selected = anchor
|
|
.key
|
|
.as_ref()
|
|
.and_then(|key| {
|
|
map.iter().position(|item| match (key, item.as_ref()) {
|
|
(
|
|
PickerSelectionKey::Fuzzy { source, id },
|
|
Some(PickerItem::Fuzzy { original_index }),
|
|
) => entries
|
|
.and_then(|entries| entries.get(*original_index))
|
|
.is_some_and(|entry| &entry.source == source && &entry.id == id),
|
|
(PickerSelectionKey::Content { id }, Some(PickerItem::Content { hit_index })) => {
|
|
content_results
|
|
.and_then(|results| results.get(*hit_index))
|
|
.is_some_and(|hit| &hit.session_id == id)
|
|
}
|
|
_ => false,
|
|
})
|
|
})
|
|
.or_else(|| selectable_fallback(&map, anchor.fallback_index))
|
|
.unwrap_or(0);
|
|
state.selected = selected;
|
|
state.scroll_offset = anchor.scroll_delta.map(|delta| {
|
|
(selected as isize - delta)
|
|
.max(0)
|
|
.min(map.len().saturating_sub(1) as isize) as usize
|
|
});
|
|
}
|
|
|
|
fn selectable_fallback<T>(map: &[Option<T>], preferred: usize) -> Option<usize> {
|
|
if map.is_empty() {
|
|
return None;
|
|
}
|
|
let preferred = preferred.min(map.len() - 1);
|
|
(preferred..map.len())
|
|
.find(|index| map[*index].is_some())
|
|
.or_else(|| (0..preferred).rev().find(|index| map[*index].is_some()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Filtering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Case-insensitive substring match (callers pass a pre-lowercased query).
|
|
///
|
|
/// Deliberately not an ordered-chars subsequence match: that matched so
|
|
/// loosely (e.g. "rc" hitting "rust-check") that spurious title rows
|
|
/// drowned out the results users actually searched for.
|
|
pub(crate) fn fuzzy_matches_session(name: &str, query: &str) -> bool {
|
|
query.is_empty() || name.to_lowercase().contains(query)
|
|
}
|
|
|
|
/// The query the picker's local fuzzy filter should apply on top of the
|
|
/// current entries.
|
|
///
|
|
/// When `entries_query` (the query the entries were server-fetched with)
|
|
/// matches the live query, the entries are already filtered server-side — by
|
|
/// message content as well as title — so the local fuzzy match is skipped:
|
|
/// re-applying it would hide content-only hits. Every consumer of
|
|
/// [`filter_session_entries`] / [`build_entry_map`] on picker state must use
|
|
/// this so input handling, rendering, and cursor re-anchoring agree on row
|
|
/// indices.
|
|
pub(crate) fn effective_filter_query<'a>(
|
|
live_query: &'a str,
|
|
entries_query: Option<&str>,
|
|
) -> &'a str {
|
|
if entries_query.is_some_and(|q| q.trim() == live_query.trim()) {
|
|
""
|
|
} else {
|
|
live_query
|
|
}
|
|
}
|
|
|
|
/// Filter session entries by query and source filter, returning indices of matching entries.
|
|
///
|
|
/// When the query is empty, all entries match the text filter. The
|
|
/// `source_filter` is always applied (entries whose `source` field
|
|
/// does not pass [`SourceFilter::matches`] are excluded).
|
|
pub(crate) fn filter_session_entries(
|
|
entries: Option<&[SessionPickerEntry]>,
|
|
query: &str,
|
|
source_filter: SourceFilter,
|
|
) -> Vec<usize> {
|
|
let Some(entries) = entries else {
|
|
return vec![];
|
|
};
|
|
let q = query.to_lowercase();
|
|
entries
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| {
|
|
source_filter.matches(&e.source)
|
|
&& (query.is_empty()
|
|
|| fuzzy_matches_session(&e.id, &q)
|
|
|| fuzzy_matches_session(&e.summary, &q))
|
|
})
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry map building
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a flat list of picker items from fuzzy + content results,
|
|
/// deduplicating content hits that already appear in the fuzzy list.
|
|
pub(crate) fn build_virtual_list(
|
|
entries: Option<&[SessionPickerEntry]>,
|
|
content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
|
|
query: &str,
|
|
source_filter: SourceFilter,
|
|
) -> Vec<PickerItem> {
|
|
let fuzzy_indices = filter_session_entries(entries, query, source_filter);
|
|
let fuzzy_ids: HashSet<&str> = entries
|
|
.map(|ents| {
|
|
fuzzy_indices
|
|
.iter()
|
|
.filter_map(|&i| {
|
|
ents.get(i)
|
|
.filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
|
|
.map(|entry| entry.id.as_str())
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let mut items: Vec<PickerItem> = fuzzy_indices
|
|
.into_iter()
|
|
.map(|i| PickerItem::Fuzzy { original_index: i })
|
|
.collect();
|
|
if source_filter != SourceFilter::External
|
|
&& let Some(hits) = content_results
|
|
{
|
|
for (hit_idx, hit) in hits.iter().enumerate() {
|
|
if !fuzzy_ids.contains(hit.session_id.as_str()) {
|
|
items.push(PickerItem::Content { hit_index: hit_idx });
|
|
}
|
|
}
|
|
}
|
|
items
|
|
}
|
|
|
|
/// Build a position-indexed entry map for the session picker.
|
|
///
|
|
/// Each element is `Some(item)` for selectable rows or `None` for
|
|
/// non-selectable headers. When `grouped` is true, repo-group headers
|
|
/// are interleaved so indices match what the renderer stores in hit areas.
|
|
/// `current_repo` pins the matching repo group to the top of the list.
|
|
pub(crate) fn build_entry_map(
|
|
entries: Option<&[SessionPickerEntry]>,
|
|
content_results: Option<&[kigi_shell::extensions::session_search::SearchSessionHit]>,
|
|
query: &str,
|
|
grouped: bool,
|
|
content_loading: bool,
|
|
source_filter: SourceFilter,
|
|
current_repo: Option<&str>,
|
|
) -> Vec<Option<PickerItem>> {
|
|
if grouped {
|
|
let entries_data = entries.unwrap_or(&[]);
|
|
let filtered = filter_session_entries(entries, query, source_filter);
|
|
let mut map: Vec<Option<PickerItem>> = Vec::new();
|
|
{
|
|
let mut groups: IndexMap<&str, Vec<usize>> = IndexMap::new();
|
|
for &orig_idx in &filtered {
|
|
groups
|
|
.entry(entries_data[orig_idx].repo_name.as_str())
|
|
.or_default()
|
|
.push(orig_idx);
|
|
}
|
|
order_repo_groups(&mut groups, current_repo);
|
|
for (_repo, members) in &groups {
|
|
map.push(None); // repo group header
|
|
for &orig_idx in members {
|
|
map.push(Some(PickerItem::Fuzzy {
|
|
original_index: orig_idx,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
// Append deduplicated content results (only when searching).
|
|
let fuzzy_ids: HashSet<&str> = filtered
|
|
.iter()
|
|
.filter_map(|&i| {
|
|
entries_data
|
|
.get(i)
|
|
.filter(|entry| !crate::app::is_foreign_picker_source(&entry.source))
|
|
.map(|entry| entry.id.as_str())
|
|
})
|
|
.collect();
|
|
let content_items: Vec<usize> = if source_filter != SourceFilter::External
|
|
&& let Some(hits) = content_results
|
|
&& !query.is_empty()
|
|
{
|
|
hits.iter()
|
|
.enumerate()
|
|
.filter(|(_, h)| !fuzzy_ids.contains(h.session_id.as_str()))
|
|
.map(|(idx, _)| idx)
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let show_content_header = !content_items.is_empty()
|
|
|| (source_filter != SourceFilter::External
|
|
&& content_loading
|
|
&& !query.trim().is_empty());
|
|
if show_content_header {
|
|
map.push(None); // content header
|
|
}
|
|
for hit_idx in content_items {
|
|
map.push(Some(PickerItem::Content { hit_index: hit_idx }));
|
|
}
|
|
map
|
|
} else {
|
|
let content_for_flat = if query.is_empty() || source_filter == SourceFilter::External {
|
|
None
|
|
} else {
|
|
content_results
|
|
};
|
|
let virtual_list = build_virtual_list(entries, content_for_flat, query, source_filter);
|
|
let fuzzy_count = virtual_list
|
|
.iter()
|
|
.filter(|i| matches!(i, PickerItem::Fuzzy { .. }))
|
|
.count();
|
|
let content_count = virtual_list.len() - fuzzy_count;
|
|
let has_header = content_count > 0
|
|
|| (source_filter != SourceFilter::External
|
|
&& content_loading
|
|
&& !query.trim().is_empty());
|
|
let mut map = Vec::with_capacity(virtual_list.len() + usize::from(has_header));
|
|
for (i, item) in virtual_list.into_iter().enumerate() {
|
|
if has_header && i == fuzzy_count {
|
|
map.push(None); // content header
|
|
}
|
|
map.push(Some(item));
|
|
}
|
|
if has_header && content_count == 0 {
|
|
map.push(None); // loading header with no results yet
|
|
}
|
|
map
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session entry data building
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build owned rendering data for each session entry in the filtered list.
|
|
///
|
|
/// The caller zips the result with `PickerField` slices and builds
|
|
/// `PickerEntry` items that borrow from the returned data.
|
|
pub(crate) fn build_session_entry_data(
|
|
entries_data: &[SessionPickerEntry],
|
|
filtered_indices: &[usize],
|
|
state: &PickerState,
|
|
content_width: u16,
|
|
) -> Vec<SessionEntryData> {
|
|
use crate::render::line_utils::truncate_str;
|
|
|
|
filtered_indices
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(fi, &orig_idx)| {
|
|
let entry = &entries_data[orig_idx];
|
|
let summary = if entry.summary.is_empty() {
|
|
"(no prompt)".to_string()
|
|
} else {
|
|
entry.summary.clone()
|
|
};
|
|
// Prefer last_active_at; fall back to updated_at (not created_at)
|
|
// so pre-migration sessions don't jump to their creation date.
|
|
let right_text = format_time_ago(entry.last_active_at.unwrap_or(entry.updated_at));
|
|
let is_selected = !state.selection_hidden && fi == state.selected;
|
|
let is_foreign = crate::app::is_foreign_picker_source(&entry.source);
|
|
let is_expanded = !is_foreign && state.expanded.contains(&orig_idx);
|
|
|
|
let mut field_data: Vec<(String, String)> = Vec::new();
|
|
if is_expanded {
|
|
field_data.push(("ID".into(), entry.id.clone()));
|
|
field_data.push(("CWD".into(), entry.cwd.clone()));
|
|
if let Some(ref model) = entry.model_id {
|
|
field_data.push(("Model".into(), model.clone()));
|
|
}
|
|
let fmt_time = |dt: chrono::DateTime<chrono::Utc>| {
|
|
dt.with_timezone(&chrono::Local)
|
|
.format("%b %d, %l:%M%P")
|
|
.to_string()
|
|
};
|
|
field_data.push(("Created".into(), fmt_time(entry.created_at)));
|
|
field_data.push(("Updated".into(), fmt_time(entry.updated_at)));
|
|
field_data.push(("Source".into(), entry.source.clone()));
|
|
if let Some(ref host) = entry.hostname {
|
|
field_data.push(("Host".into(), host.clone()));
|
|
}
|
|
if entry.num_messages > 0 {
|
|
field_data.push(("Messages".into(), entry.num_messages.to_string()));
|
|
}
|
|
if let Some(ref detail) = entry.card_detail {
|
|
field_data.push((
|
|
"Turns".into(),
|
|
format!("{} Tools {}", detail.turn_count, detail.tool_call_count),
|
|
));
|
|
if !detail.first_prompt_preview.is_empty() {
|
|
let max_w = content_width.saturating_sub(4 + 12) as usize;
|
|
let preview = truncate_str(&detail.first_prompt_preview, max_w);
|
|
field_data.push(("Prompt".into(), preview));
|
|
}
|
|
}
|
|
}
|
|
|
|
SessionEntryData {
|
|
summary,
|
|
right_text,
|
|
is_selected,
|
|
is_expanded,
|
|
field_data,
|
|
snippet_preview: None,
|
|
badge: crate::app::badge_for_picker_source(&entry.source),
|
|
collapsible: !is_foreign,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build grouped picker entries: sessions grouped by `repo_name` (the
|
|
/// current working directory's repo pinned first, the rest alphabetical)
|
|
/// with non-selectable `Header` rows separating each group. Returns the
|
|
/// entry list and a boolean mask where `true` marks non-selectable header rows.
|
|
pub(crate) fn build_grouped_picker_entries<'a>(
|
|
entries_data: &'a [SessionPickerEntry],
|
|
filtered_indices: &[usize],
|
|
built: &'a [SessionEntryData],
|
|
fields_vecs: &'a [Vec<PickerField<'a>>],
|
|
state: &PickerState,
|
|
current_repo: Option<&str>,
|
|
) -> (Vec<PickerEntry<'a>>, Vec<bool>) {
|
|
// Group filtered entries by repo_name, sort alphabetically, then pin the
|
|
// current working directory's repo group to the top.
|
|
let mut groups: IndexMap<&str, Vec<usize>> = IndexMap::new();
|
|
for (fi, &orig_idx) in filtered_indices.iter().enumerate() {
|
|
let repo = entries_data[orig_idx].repo_name.as_str();
|
|
groups.entry(repo).or_default().push(fi);
|
|
}
|
|
order_repo_groups(&mut groups, current_repo);
|
|
|
|
let mut result: Vec<PickerEntry<'a>> = Vec::new();
|
|
let mut non_selectable: Vec<bool> = Vec::new();
|
|
|
|
// Track the grouped position (including headers) to correctly compute selection.
|
|
let mut grouped_pos: usize = 0;
|
|
for (repo_name, member_indices) in &groups {
|
|
// Insert a non-selectable header for this repo group.
|
|
non_selectable.push(true);
|
|
result.push(PickerEntry::Header { label: repo_name });
|
|
grouped_pos += 1;
|
|
|
|
// Insert each session row indented under the header.
|
|
for &fi in member_indices {
|
|
let b = &built[fi];
|
|
let fields = &fields_vecs[fi];
|
|
non_selectable.push(false);
|
|
// Use grouped position for selection, not flat filtered index.
|
|
let selected = !state.selection_hidden && grouped_pos == state.selected;
|
|
result.push(PickerEntry::Row(PickerRow {
|
|
label: &b.summary,
|
|
right_label: &b.right_text,
|
|
selected,
|
|
expanded: b.is_expanded,
|
|
fields,
|
|
description_lines: &[],
|
|
summary_lines: &[],
|
|
dimmed: false,
|
|
indent: 1,
|
|
badge: b.badge,
|
|
badge_color: None,
|
|
collapsible: b.collapsible,
|
|
}));
|
|
grouped_pos += 1;
|
|
}
|
|
}
|
|
|
|
(result, non_selectable)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content search helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build owned rendering data for content search (deep search) result rows.
|
|
///
|
|
/// Deduplicates hits that already appear in the fuzzy results. The returned
|
|
/// entries should be appended after the fuzzy section (and its header row).
|
|
pub(crate) fn build_content_entry_data(
|
|
hits: &[kigi_shell::extensions::session_search::SearchSessionHit],
|
|
entries_data: &[SessionPickerEntry],
|
|
filtered_indices: &[usize],
|
|
state: &PickerState,
|
|
content_start: usize,
|
|
) -> Vec<SessionEntryData> {
|
|
let fuzzy_ids: HashSet<&str> = entries_data
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, entry)| {
|
|
filtered_indices.contains(i) && !crate::app::is_foreign_picker_source(&entry.source)
|
|
})
|
|
.map(|(_, e)| e.id.as_str())
|
|
.collect();
|
|
let mut row_offset = 0usize;
|
|
hits.iter()
|
|
.enumerate()
|
|
.filter(|(_, h)| !fuzzy_ids.contains(h.session_id.as_str()))
|
|
.map(|(hit_idx, h)| {
|
|
let summary = if h.summary.is_empty() {
|
|
h.snippet
|
|
.as_deref()
|
|
.unwrap_or("(no summary)")
|
|
.lines()
|
|
.next()
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
} else {
|
|
h.summary.clone()
|
|
};
|
|
let right_text = if let Ok(dt) = h.updated_at.parse::<chrono::DateTime<chrono::Utc>>() {
|
|
format_time_ago(dt)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let is_selected =
|
|
!state.selection_hidden && state.selected == content_start + row_offset;
|
|
let is_expanded = state.expanded.contains(&(CONTENT_EXPAND_OFFSET + hit_idx));
|
|
row_offset += 1;
|
|
|
|
let mut field_data = Vec::new();
|
|
if is_expanded {
|
|
field_data.push(("ID".into(), h.session_id.clone()));
|
|
field_data.push(("CWD".into(), h.cwd.clone()));
|
|
}
|
|
|
|
let snippet_preview = h.snippet.as_deref().and_then(|s| {
|
|
let line = s.lines().find(|l| !l.trim().is_empty())?;
|
|
if line.chars().count() > 80 {
|
|
let truncated: String = line.chars().take(77).collect();
|
|
Some(format!("{truncated}..."))
|
|
} else {
|
|
Some(line.to_string())
|
|
}
|
|
});
|
|
|
|
SessionEntryData {
|
|
summary,
|
|
right_text,
|
|
is_selected,
|
|
is_expanded,
|
|
field_data,
|
|
snippet_preview,
|
|
badge: "",
|
|
collapsible: true,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the content header label (spinner or "Content matches").
|
|
///
|
|
/// Returns an empty string when there are no content rows and no loading
|
|
/// spinner — the caller should skip rendering the header in that case.
|
|
pub(crate) fn build_content_header_label(
|
|
content_loading: bool,
|
|
has_content_rows: bool,
|
|
tick: u64,
|
|
) -> String {
|
|
if content_loading {
|
|
let spinner_frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (tick / 4) as usize % spinner_frames.len();
|
|
format!(
|
|
"{} Searching session content\u{2026}",
|
|
spinner_frames[frame_idx]
|
|
)
|
|
} else if has_content_rows {
|
|
"Extended search results (remote and local sessions)".to_string()
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Format a timestamp as a human-readable relative time.
|
|
pub(crate) fn format_time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
|
|
let now = chrono::Utc::now();
|
|
let duration = now.signed_duration_since(dt);
|
|
|
|
let raw = if duration.num_minutes() < 1 {
|
|
"just now".to_string()
|
|
} else if duration.num_minutes() < 60 {
|
|
format!("{}m ago", duration.num_minutes())
|
|
} else if duration.num_hours() < 24 {
|
|
format!("{}h ago", duration.num_hours())
|
|
} else if duration.num_days() < 30 {
|
|
format!("{}d ago", duration.num_days())
|
|
} else {
|
|
format!("{}mo ago", duration.num_days() / 30)
|
|
};
|
|
// Right-align to fixed width so the column doesn't jump
|
|
format!("{:>8}", raw)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_two_components() {
|
|
assert_eq!(repo_name_from_cwd("/home/user/fw/1"), "fw-1");
|
|
}
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_standard_path() {
|
|
assert_eq!(repo_name_from_cwd("/home/user/xai"), "user-xai");
|
|
}
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_empty() {
|
|
assert_eq!(repo_name_from_cwd(""), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_deep_path() {
|
|
assert_eq!(
|
|
repo_name_from_cwd("/home/user/projects/rust/myapp"),
|
|
"rust-myapp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_root() {
|
|
assert_eq!(repo_name_from_cwd("/"), "/");
|
|
}
|
|
|
|
#[test]
|
|
fn repo_name_from_cwd_single_dir() {
|
|
assert_eq!(repo_name_from_cwd("/xai"), "xai");
|
|
}
|
|
|
|
/// Substring-only title matching: the old ordered-chars fallback let
|
|
/// short queries match most titles, drowning real hits in junk rows.
|
|
#[test]
|
|
fn fuzzy_matches_session_is_substring_only() {
|
|
assert!(fuzzy_matches_session("Rust-Check pipeline", "rust-check"));
|
|
assert!(fuzzy_matches_session("Fix session picker", "picker"));
|
|
assert!(fuzzy_matches_session("anything", ""));
|
|
assert!(
|
|
!fuzzy_matches_session("rust-check", "rc"),
|
|
"ordered-chars subsequence must no longer match"
|
|
);
|
|
}
|
|
|
|
/// Entries stamped as server search results for the live query skip the
|
|
/// local title/id fuzzy match: the backend also matches message content,
|
|
/// so a hit titled nothing like the query must survive filtering.
|
|
#[test]
|
|
fn effective_filter_query_skips_fuzzy_for_server_search_results() {
|
|
let mut hit = make_entry("conv-content-1", "r");
|
|
// Content-only match: title has nothing in common with "hit".
|
|
hit.summary = "Quarterly roadmap notes".into();
|
|
let entries = vec![hit];
|
|
|
|
// Without the stamp (plain fetch / stale stamp): fuzzy filter hides it.
|
|
assert!(
|
|
filter_session_entries(
|
|
Some(&entries),
|
|
effective_filter_query("hit", None),
|
|
SourceFilter::All,
|
|
)
|
|
.is_empty(),
|
|
"unstamped entries keep the local fuzzy filter"
|
|
);
|
|
assert!(
|
|
filter_session_entries(
|
|
Some(&entries),
|
|
effective_filter_query("hit", Some("older query")),
|
|
SourceFilter::All,
|
|
)
|
|
.is_empty(),
|
|
"a stale stamp (newer fetch in flight) keeps the local filter"
|
|
);
|
|
|
|
// Stamped with the live query: server already filtered — row visible.
|
|
assert_eq!(
|
|
filter_session_entries(
|
|
Some(&entries),
|
|
effective_filter_query("hit", Some("hit")),
|
|
SourceFilter::All,
|
|
),
|
|
vec![0],
|
|
"server search results must render even with an unrelated title"
|
|
);
|
|
// Trim-insensitive: the picker trims before fetching.
|
|
assert_eq!(effective_filter_query(" hit ", Some("hit")), "");
|
|
// Unfiltered fetch stamp (None) + empty live query: no-op.
|
|
assert_eq!(effective_filter_query("", None), "");
|
|
}
|
|
|
|
fn make_entry(id: &str, repo: &str) -> SessionPickerEntry {
|
|
SessionPickerEntry {
|
|
id: id.into(),
|
|
summary: id.into(),
|
|
updated_at: chrono::Utc::now(),
|
|
created_at: chrono::Utc::now(),
|
|
cwd: format!("/{repo}"),
|
|
hostname: None,
|
|
source: String::new(),
|
|
model_id: None,
|
|
num_messages: 0,
|
|
last_active_at: None,
|
|
branch: None,
|
|
repo_name: repo.into(),
|
|
worktree_label: None,
|
|
card_detail: None,
|
|
}
|
|
}
|
|
|
|
fn make_content_hit(
|
|
session_id: &str,
|
|
) -> kigi_shell::extensions::session_search::SearchSessionHit {
|
|
kigi_shell::extensions::session_search::SearchSessionHit {
|
|
session_id: session_id.into(),
|
|
summary: session_id.into(),
|
|
cwd: "/r".into(),
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
snippet: None,
|
|
score: 1.0,
|
|
matched_fields: vec![],
|
|
}
|
|
}
|
|
|
|
/// Grouped entry map places repo headers and resolves mouse-click
|
|
/// indices to the correct original session index.
|
|
#[test]
|
|
fn grouped_entry_map_resolves_correct_session() {
|
|
let entries = vec![
|
|
make_entry("s0", "repo-b"),
|
|
make_entry("s1", "repo-a"),
|
|
make_entry("s2", "repo-a"),
|
|
];
|
|
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
|
|
// Expected layout (sorted by repo_name):
|
|
// 0: None (header "repo-a")
|
|
// 1: Fuzzy(orig=1) (s1 under repo-a)
|
|
// 2: Fuzzy(orig=2) (s2 under repo-a)
|
|
// 3: None (header "repo-b")
|
|
// 4: Fuzzy(orig=0) (s0 under repo-b)
|
|
assert_eq!(map.len(), 5);
|
|
assert!(map[0].is_none(), "repo-a header");
|
|
assert!(
|
|
matches!(map[1], Some(PickerItem::Fuzzy { original_index: 1 })),
|
|
"first session under repo-a"
|
|
);
|
|
assert!(
|
|
matches!(map[2], Some(PickerItem::Fuzzy { original_index: 2 })),
|
|
"second session under repo-a"
|
|
);
|
|
assert!(map[3].is_none(), "repo-b header");
|
|
assert!(
|
|
matches!(map[4], Some(PickerItem::Fuzzy { original_index: 0 })),
|
|
"session under repo-b"
|
|
);
|
|
|
|
// Mouse-click index 1 (first data row) must resolve to s1, not s0.
|
|
match &map[1] {
|
|
Some(PickerItem::Fuzzy { original_index }) => {
|
|
assert_eq!(*original_index, 1);
|
|
assert_eq!(entries[*original_index].id, "s1");
|
|
}
|
|
other => panic!("expected Fuzzy, got {other:?}"),
|
|
}
|
|
|
|
// Mouse-click index 4 (under repo-b header) resolves to s0.
|
|
match &map[4] {
|
|
Some(PickerItem::Fuzzy { original_index }) => {
|
|
assert_eq!(*original_index, 0);
|
|
assert_eq!(entries[*original_index].id, "s0");
|
|
}
|
|
other => panic!("expected Fuzzy, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Grouped entry map with content search results: deduplicates content
|
|
/// hits that overlap fuzzy results, places content header after all
|
|
/// repo groups, and resolves content indices correctly.
|
|
#[test]
|
|
fn grouped_entry_map_with_content_results() {
|
|
let entries = vec![make_entry("s0", "repo-a"), make_entry("s1", "repo-b")];
|
|
// Two content hits: s0 overlaps fuzzy (should be deduped), s_new is unique.
|
|
let content_hits = vec![make_content_hit("s0"), make_content_hit("s_new")];
|
|
|
|
// query must be non-empty for content results to be included.
|
|
// Use a query that matches all entries so fuzzy list is preserved.
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
Some(&content_hits),
|
|
"s",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
|
|
// Expected:
|
|
// 0: None (header "repo-a")
|
|
// 1: Fuzzy(orig=0) (s0 under repo-a)
|
|
// 2: None (header "repo-b")
|
|
// 3: Fuzzy(orig=1) (s1 under repo-b)
|
|
// 4: None (content header)
|
|
// 5: Content(idx=1) (s_new — s0 deduped)
|
|
assert_eq!(map.len(), 6);
|
|
assert!(map[0].is_none(), "repo-a header");
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
assert!(map[2].is_none(), "repo-b header");
|
|
assert!(matches!(
|
|
map[3],
|
|
Some(PickerItem::Fuzzy { original_index: 1 })
|
|
));
|
|
assert!(map[4].is_none(), "content header");
|
|
assert!(
|
|
matches!(map[5], Some(PickerItem::Content { hit_index: 1 })),
|
|
"s_new at content hit index 1 (s0 deduped)"
|
|
);
|
|
}
|
|
|
|
/// An in-flight search with an EMPTY effective query (stamp==live)
|
|
/// appends no "Searching…" header row — a header only one of input map /
|
|
/// render has would shift row indices.
|
|
#[test]
|
|
fn grouped_entry_map_empty_query_with_loading_has_no_header() {
|
|
let entries = vec![make_entry("s0", "r")];
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
/* content_loading */ true,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
assert_eq!(map.len(), 2, "repo header + row only, no content header");
|
|
assert!(map[0].is_none(), "repo header");
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
}
|
|
|
|
/// Empty query in grouped mode must not include content results,
|
|
/// matching the renderer's guard.
|
|
#[test]
|
|
fn grouped_entry_map_empty_query_excludes_content() {
|
|
let entries = vec![make_entry("s0", "r")];
|
|
let content_hits = vec![make_content_hit("s_new")];
|
|
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
Some(&content_hits),
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
|
|
// Only the repo header + fuzzy entry; no content header/items.
|
|
assert_eq!(map.len(), 2);
|
|
assert!(map[0].is_none());
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
}
|
|
|
|
/// Flat entry map inserts content header at the correct position
|
|
/// and resolution matches the old `to_virtual` semantics.
|
|
#[test]
|
|
fn flat_entry_map_matches_to_virtual_semantics() {
|
|
let entries = vec![make_entry("s0", "r"), make_entry("s1", "r")];
|
|
let content_hits = vec![make_content_hit("s_content")];
|
|
|
|
// Empty query: content results excluded (matches renderer guard).
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
Some(&content_hits),
|
|
"",
|
|
false,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
assert_eq!(map.len(), 2);
|
|
assert!(matches!(
|
|
map[0],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 1 })
|
|
));
|
|
|
|
// Non-empty query: content results included with header.
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
Some(&content_hits),
|
|
"s",
|
|
false,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
assert_eq!(map.len(), 4);
|
|
assert!(matches!(
|
|
map[0],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 1 })
|
|
));
|
|
assert!(map[2].is_none(), "content header");
|
|
assert!(matches!(map[3], Some(PickerItem::Content { hit_index: 0 })));
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_id_does_not_suppress_native_content_result() {
|
|
let mut foreign = make_entry("shared", "repo");
|
|
foreign.source = "codex".into();
|
|
let entries = vec![foreign];
|
|
let hits = vec![make_content_hit("shared")];
|
|
|
|
let flat = build_entry_map(
|
|
Some(&entries),
|
|
Some(&hits),
|
|
"shared",
|
|
false,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
assert!(matches!(
|
|
flat.as_slice(),
|
|
[
|
|
Some(PickerItem::Fuzzy { original_index: 0 }),
|
|
None,
|
|
Some(PickerItem::Content { hit_index: 0 }),
|
|
]
|
|
));
|
|
|
|
let grouped = build_entry_map(
|
|
Some(&entries),
|
|
Some(&hits),
|
|
"shared",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
None,
|
|
);
|
|
assert!(matches!(
|
|
grouped.as_slice(),
|
|
[
|
|
None,
|
|
Some(PickerItem::Fuzzy { original_index: 0 }),
|
|
None,
|
|
Some(PickerItem::Content { hit_index: 0 }),
|
|
]
|
|
));
|
|
|
|
let state = PickerState::default();
|
|
let content = build_content_entry_data(&hits, &entries, &[0], &state, 2);
|
|
assert_eq!(content.len(), 1);
|
|
assert_eq!(content[0].summary, "shared");
|
|
}
|
|
|
|
#[test]
|
|
fn content_header_label_loading() {
|
|
let label = build_content_header_label(true, false, 0);
|
|
assert!(label.contains("Searching"));
|
|
}
|
|
|
|
#[test]
|
|
fn content_header_label_has_rows() {
|
|
let label = build_content_header_label(false, true, 0);
|
|
assert_eq!(label, "Extended search results (remote and local sessions)");
|
|
}
|
|
|
|
#[test]
|
|
fn content_header_label_empty() {
|
|
let label = build_content_header_label(false, false, 0);
|
|
assert!(label.is_empty());
|
|
}
|
|
|
|
/// Empty entries list produces empty map.
|
|
#[test]
|
|
fn empty_entries_produces_empty_map() {
|
|
let map = build_entry_map(None, None, "", true, false, SourceFilter::All, None);
|
|
assert!(map.is_empty());
|
|
|
|
let map = build_entry_map(Some(&[]), None, "", false, false, SourceFilter::All, None);
|
|
assert!(map.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_matches() {
|
|
assert!(SourceFilter::All.matches("local"));
|
|
assert!(SourceFilter::All.matches("remote"));
|
|
assert!(SourceFilter::All.matches("both"));
|
|
assert!(SourceFilter::All.matches("claude"));
|
|
assert!(SourceFilter::All.matches("codex"));
|
|
assert!(SourceFilter::All.matches("cursor"));
|
|
|
|
assert!(SourceFilter::Local.matches("local"));
|
|
assert!(SourceFilter::Local.matches("both"));
|
|
assert!(!SourceFilter::Local.matches("remote"));
|
|
assert!(!SourceFilter::Local.matches("claude"));
|
|
|
|
assert!(SourceFilter::Remote.matches("remote"));
|
|
assert!(SourceFilter::Remote.matches("both"));
|
|
assert!(!SourceFilter::Remote.matches("local"));
|
|
assert!(!SourceFilter::Remote.matches("cursor"));
|
|
|
|
// kimi.com conversations are remote: visible under All + Remote, not Local.
|
|
assert!(SourceFilter::All.matches("conversation"));
|
|
assert!(SourceFilter::Remote.matches("conversation"));
|
|
assert!(!SourceFilter::Local.matches("conversation"));
|
|
|
|
assert!(SourceFilter::External.matches("claude"));
|
|
assert!(SourceFilter::External.matches("codex"));
|
|
assert!(SourceFilter::External.matches("cursor"));
|
|
assert!(!SourceFilter::External.matches("local"));
|
|
assert!(!SourceFilter::External.matches("remote"));
|
|
assert!(!SourceFilter::External.matches("both"));
|
|
assert!(!SourceFilter::External.matches("conversation"));
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_cycles() {
|
|
assert_eq!(SourceFilter::All.next(), SourceFilter::Local);
|
|
assert_eq!(SourceFilter::Local.next(), SourceFilter::Remote);
|
|
assert_eq!(SourceFilter::Remote.next(), SourceFilter::External);
|
|
assert_eq!(SourceFilter::External.next(), SourceFilter::All);
|
|
assert_eq!(SourceFilter::External.label(), "External");
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_filters_entries() {
|
|
fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry {
|
|
let mut e = make_entry(id, "r");
|
|
e.source = source.into();
|
|
e
|
|
}
|
|
let entries = vec![
|
|
entry_with_source("s0", "local"),
|
|
entry_with_source("s1", "remote"),
|
|
entry_with_source("s2", "both"),
|
|
entry_with_source("s3", "claude"),
|
|
entry_with_source("s4", "codex"),
|
|
entry_with_source("s5", "cursor"),
|
|
];
|
|
|
|
let all = filter_session_entries(Some(&entries), "", SourceFilter::All);
|
|
assert_eq!(all, vec![0, 1, 2, 3, 4, 5]);
|
|
|
|
let local = filter_session_entries(Some(&entries), "", SourceFilter::Local);
|
|
assert_eq!(local, vec![0, 2]); // local + both
|
|
|
|
let remote = filter_session_entries(Some(&entries), "", SourceFilter::Remote);
|
|
assert_eq!(remote, vec![1, 2]); // remote + both
|
|
|
|
let external = filter_session_entries(Some(&entries), "", SourceFilter::External);
|
|
assert_eq!(external, vec![3, 4, 5]);
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_empty_and_unknown_source() {
|
|
// Empty source string (e.g. from old data or test fixtures) should
|
|
// only pass the All filter, never Local or Remote.
|
|
assert!(SourceFilter::All.matches(""));
|
|
assert!(!SourceFilter::Local.matches(""));
|
|
assert!(!SourceFilter::Remote.matches(""));
|
|
|
|
// Unknown source values are also rejected by Local/Remote.
|
|
assert!(SourceFilter::All.matches("unknown"));
|
|
assert!(!SourceFilter::Local.matches("unknown"));
|
|
assert!(!SourceFilter::Remote.matches("unknown"));
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_is_active() {
|
|
assert!(!SourceFilter::All.is_active());
|
|
assert!(SourceFilter::Local.is_active());
|
|
assert!(SourceFilter::Remote.is_active());
|
|
assert!(SourceFilter::External.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_entry_uses_source_badge_and_has_no_detail_expansion() {
|
|
let mut entry = make_entry("foreign", "repo");
|
|
entry.source = "codex".into();
|
|
let mut state = PickerState::default();
|
|
state.expanded.insert(0);
|
|
|
|
let built = build_session_entry_data(&[entry], &[0], &state, 80);
|
|
|
|
assert_eq!(built[0].badge, "codex");
|
|
assert!(!built[0].collapsible);
|
|
assert!(!built[0].is_expanded);
|
|
assert!(built[0].field_data.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn source_filter_combined_with_text_query() {
|
|
fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry {
|
|
let mut e = make_entry(id, "r");
|
|
e.source = source.into();
|
|
e
|
|
}
|
|
let entries = vec![
|
|
entry_with_source("alpha", "local"),
|
|
entry_with_source("beta", "remote"),
|
|
entry_with_source("gamma", "both"),
|
|
];
|
|
|
|
// Text query "alpha" + Local filter: only alpha matches both criteria.
|
|
let result = filter_session_entries(Some(&entries), "alpha", SourceFilter::Local);
|
|
assert_eq!(result, vec![0]);
|
|
|
|
// Text query "alpha" + Remote filter: alpha is local-only, so no matches.
|
|
let result = filter_session_entries(Some(&entries), "alpha", SourceFilter::Remote);
|
|
assert!(result.is_empty());
|
|
|
|
// Text query matching all + Local filter: local + both pass.
|
|
let result = filter_session_entries(Some(&entries), "", SourceFilter::Local);
|
|
assert_eq!(result, vec![0, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn grouped_entry_map_with_source_filter() {
|
|
fn entry_with_source(id: &str, repo: &str, source: &str) -> SessionPickerEntry {
|
|
let mut e = make_entry(id, repo);
|
|
e.source = source.into();
|
|
e
|
|
}
|
|
let entries = vec![
|
|
entry_with_source("s0", "repo-a", "local"),
|
|
entry_with_source("s1", "repo-a", "remote"),
|
|
entry_with_source("s2", "repo-b", "both"),
|
|
];
|
|
|
|
// Local filter: s0 (local) + s2 (both), grouped by repo.
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::Local,
|
|
None,
|
|
);
|
|
// repo-a header + s0 + repo-b header + s2 = 4
|
|
assert_eq!(map.len(), 4);
|
|
assert!(map[0].is_none()); // repo-a header
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
assert!(map[2].is_none()); // repo-b header
|
|
assert!(matches!(
|
|
map[3],
|
|
Some(PickerItem::Fuzzy { original_index: 2 })
|
|
));
|
|
|
|
// Remote filter: s1 (remote) + s2 (both).
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::Remote,
|
|
None,
|
|
);
|
|
assert_eq!(map.len(), 4);
|
|
assert!(map[0].is_none()); // repo-a header
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 1 })
|
|
));
|
|
assert!(map[2].is_none()); // repo-b header
|
|
assert!(matches!(
|
|
map[3],
|
|
Some(PickerItem::Fuzzy { original_index: 2 })
|
|
));
|
|
}
|
|
|
|
/// `current_repo` pins the matching group to the top; remaining groups
|
|
/// stay alphabetical. Without it, groups are purely alphabetical.
|
|
#[test]
|
|
fn grouped_entry_map_pins_current_repo_first() {
|
|
let entries = vec![
|
|
make_entry("s0", "repo-a"),
|
|
make_entry("s1", "repo-b"),
|
|
make_entry("s2", "repo-c"),
|
|
];
|
|
|
|
// Pin repo-c: its group leads, then repo-a, repo-b alphabetically.
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
Some("repo-c"),
|
|
);
|
|
// [repo-c hdr, s2, repo-a hdr, s0, repo-b hdr, s1]
|
|
assert_eq!(map.len(), 6);
|
|
assert!(map[0].is_none(), "repo-c header pinned first");
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 2 })
|
|
));
|
|
assert!(map[2].is_none(), "repo-a header");
|
|
assert!(matches!(
|
|
map[3],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
assert!(map[4].is_none(), "repo-b header");
|
|
assert!(matches!(
|
|
map[5],
|
|
Some(PickerItem::Fuzzy { original_index: 1 })
|
|
));
|
|
|
|
// A current_repo with no matching group is a no-op (pure alphabetical).
|
|
let map = build_entry_map(
|
|
Some(&entries),
|
|
None,
|
|
"",
|
|
true,
|
|
false,
|
|
SourceFilter::All,
|
|
Some("repo-zzz"),
|
|
);
|
|
assert!(map[0].is_none(), "repo-a header");
|
|
assert!(matches!(
|
|
map[1],
|
|
Some(PickerItem::Fuzzy { original_index: 0 })
|
|
));
|
|
}
|
|
}
|