From 32662daf05c15b8a4f5b1bf47008397abcfe4c97 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:32:51 +0300 Subject: [PATCH] Add configurable HISTORY project catalog --- src/app/mod.rs | 357 +++++++++- src/main.rs | 78 ++- src/read/catalog.rs | 1586 +++++++++++++++++++++++++++++++++++++++++++ src/read/mod.rs | 3 +- src/read/scan.rs | 31 +- src/read/tui.rs | 753 ++++++++++++++++++-- src/ui/mod.rs | 145 +++- 7 files changed, 2858 insertions(+), 95 deletions(-) create mode 100644 src/read/catalog.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 290f19c..9e9764e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,7 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::Terminal; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io::Stdout; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -31,6 +31,11 @@ pub struct Config { pub usage_scan_limits: crate::usage::ScanLimits, pub rebuild_cache_on_start: bool, pub(crate) system_locale: SystemLocale, + pub(crate) history_project_roots: Vec, + pub(crate) history_deep_depth: u8, + pub(crate) history_deep_max_depth: u8, + pub(crate) history_catalog_max_candidates: usize, + pub(crate) history_catalog_scan_budget_ms: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -91,7 +96,16 @@ enum AppEvent { UsageUpdated(Result), LimitsUpdated(Result), AccountUsageUpdated(Result), - AccountApiUnavailable { message: String, is_error: bool }, + AccountApiUnavailable { + message: String, + is_error: bool, + }, + HistoryCatalogProgress(crate::read::catalog::CatalogProgress), + HistoryCatalogUpdated { + strict: crate::read::scan::Catalog, + snapshot: crate::read::catalog::CatalogSnapshot, + }, + HistoryCatalogFailed(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +121,10 @@ pub(crate) enum ActiveScreen { pub(crate) enum UiClickAction { SetScreen(ActiveScreen), SetDisplayStyle(DisplayStyle), + SetHistoryProjectMode(crate::read::catalog::ProjectViewMode), + DecreaseHistoryDepth, + IncreaseHistoryDepth, + HistoryDepthWheel, SetMetric(UsageMetric), SetRange(ChartRange), SetUsageZone(UsageZone), @@ -263,11 +281,10 @@ pub(crate) struct AppState { pub(crate) account_usage_error: Option, pub(crate) account_usage_notice: Option, pub(crate) account_usage_enabled: bool, - pub(crate) read_sessions_dir: PathBuf, pub(crate) read_browser: crate::read::tui::BrowserState, } -const STATE_STORE_SCHEMA_VERSION: u32 = 1; +const STATE_STORE_SCHEMA_VERSION: u32 = 2; const STATE_STORE_FILE_NAME: &str = "state.json"; const STATE_SAVE_DEBOUNCE: Duration = Duration::from_millis(400); pub(crate) const DEFAULT_ACTIVITY_PROJECT_LIMIT: usize = 5; @@ -294,6 +311,11 @@ struct PersistedUiState { no_sessions_confirm_dismissed: bool, display_style: DisplayStyle, skip_quit_confirmation: bool, + history_project_view_mode: crate::read::catalog::ProjectViewMode, + history_deep_depth: u8, + history_selected_projects: BTreeSet, + history_explicitly_excluded_projects: BTreeSet, + history_expanded_remote_groups: BTreeSet, } impl PersistedUiState { @@ -311,6 +333,11 @@ impl PersistedUiState { no_sessions_confirm_dismissed: false, display_style: DisplayStyle::Classic, skip_quit_confirmation: false, + history_project_view_mode: crate::read::catalog::ProjectViewMode::Strict, + history_deep_depth: crate::read::catalog::DEFAULT_DEEP_DEPTH, + history_selected_projects: BTreeSet::new(), + history_explicitly_excluded_projects: BTreeSet::new(), + history_expanded_remote_groups: BTreeSet::new(), } } @@ -328,6 +355,14 @@ impl PersistedUiState { no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed, display_style: state.display_style, skip_quit_confirmation: state.skip_quit_confirmation, + history_project_view_mode: state.read_browser.project_mode(), + history_deep_depth: state.read_browser.deep_depth(), + history_selected_projects: state.read_browser.selected_projects().clone(), + history_explicitly_excluded_projects: state + .read_browser + .explicitly_excluded_projects() + .clone(), + history_expanded_remote_groups: state.read_browser.expanded_remote_groups().clone(), } } } @@ -365,6 +400,14 @@ struct StoredGlobalState { #[serde(default)] skip_quit_confirmation: bool, last_workspace_path: Option, + history_project_view_mode: Option, + history_deep_depth: Option, + #[serde(default)] + history_selected_projects: Vec, + #[serde(default)] + history_explicitly_excluded_projects: Vec, + #[serde(default)] + history_expanded_remote_groups: Vec, updated_at: i64, } @@ -389,12 +432,18 @@ async fn run_inner( let (evt_tx, mut evt_rx) = mpsc::channel::(64); let (usage_refresh_tx, usage_refresh_rx) = mpsc::channel::<()>(1); let (limits_refresh_tx, limits_refresh_rx) = mpsc::channel::<()>(1); + let (catalog_refresh_tx, catalog_refresh_rx) = mpsc::channel::<()>(1); let (shutdown_tx, shutdown_rx) = watch::channel(false); - let restored_ui_state = - load_persisted_ui_state(&config.comon_home, config.workspace_path.as_deref()) - .unwrap_or_else(|_| { - PersistedUiState::default_for_workspace(config.workspace_path.clone()) - }); + let restored_ui_state = load_persisted_ui_state_with_history_depth( + &config.comon_home, + config.workspace_path.as_deref(), + config.history_deep_depth, + ) + .unwrap_or_else(|_| { + let mut defaults = PersistedUiState::default_for_workspace(config.workspace_path.clone()); + defaults.history_deep_depth = config.history_deep_depth; + defaults + }); let scan_cache_db_path = config.comon_home.join("comon.db"); if config.rebuild_cache_on_start { @@ -403,7 +452,143 @@ async fn run_inner( let read_config = read::Config { sessions_dir: config.read_sessions_dir.clone(), }; - let read_browser = read::build_browser(&read_config)?; + let mut read_browser = read::build_browser(&read_config)?; + read_browser.restore_project_state( + restored_ui_state.history_project_view_mode, + restored_ui_state.history_deep_depth, + restored_ui_state.history_selected_projects.clone(), + restored_ui_state + .history_explicitly_excluded_projects + .clone(), + restored_ui_state.history_expanded_remote_groups.clone(), + ); + + // Spawn HISTORY project-catalog worker. STRICT remains available immediately; + // cached and freshly discovered projects arrive asynchronously. + { + let evt_tx = evt_tx.clone(); + let sessions_dir = config.read_sessions_dir.clone(); + let search_roots = config.history_project_roots.clone(); + let excluded_roots = vec![ + config.codex_home.join("sessions"), + config.comon_home.clone(), + ]; + let max_depth = config + .history_deep_max_depth + .max(config.history_deep_depth) + .clamp(1, crate::read::catalog::MAX_DEEP_DEPTH); + let max_candidates = config.history_catalog_max_candidates; + let progress_interval_ms = config.history_catalog_scan_budget_ms; + let cache_db_path = scan_cache_db_path.clone(); + let catalog_cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + { + let cancelled = catalog_cancelled.clone(); + let mut cancel_shutdown_rx = shutdown_rx.clone(); + tokio::spawn(async move { + if !*cancel_shutdown_rx.borrow() { + let _ = cancel_shutdown_rx.changed().await; + } + cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + }); + } + let mut catalog_refresh_rx = catalog_refresh_rx; + let mut shutdown_rx = shutdown_rx.clone(); + let initial_strict = read_browser.strict_catalog_clone(); + tokio::spawn(async move { + let initial_cache_path = cache_db_path.clone(); + let initial_cache = tokio::task::spawn_blocking(move || { + crate::read::catalog::load_catalog_cache(&initial_cache_path) + }) + .await; + if let Ok(Ok(Some(snapshot))) = initial_cache { + let _ = evt_tx + .send(AppEvent::HistoryCatalogUpdated { + strict: initial_strict, + snapshot, + }) + .await; + } + let scan_config = crate::read::catalog::CatalogScanConfig { + sessions_dir: sessions_dir.clone(), + search_roots, + excluded_roots, + max_depth, + max_candidates, + progress_interval_ms, + cache_db_path, + cancelled: catalog_cancelled, + }; + let mut first_run = true; + let mut catch_up = false; + let mut reuse_cached_repositories = false; + loop { + if !first_run && !catch_up { + tokio::select! { + _ = shutdown_rx.changed() => break, + received = catalog_refresh_rx.recv() => { + if received.is_none() { break; } + } + } + reuse_cached_repositories = false; + } + first_run = false; + let progress_tx = evt_tx.clone(); + let scan_config = scan_config.clone(); + let scan_cancelled = scan_config.cancelled.clone(); + let reuse_repositories = reuse_cached_repositories; + let result = tokio::task::spawn_blocking(move || { + let strict = crate::read::scan::build_catalog(&scan_config.sessions_dir)?; + let report_progress = |progress| { + let _ = + progress_tx.blocking_send(AppEvent::HistoryCatalogProgress(progress)); + }; + let snapshot = if reuse_repositories { + crate::read::catalog::continue_project_catalog( + &scan_config, + &strict, + report_progress, + )? + } else { + crate::read::catalog::scan_project_catalog( + &scan_config, + &strict, + report_progress, + )? + }; + Ok::<_, anyhow::Error>((strict, snapshot)) + }) + .await + .unwrap_or_else(|error| Err(anyhow!("project catalog task failed: {error}"))); + match result { + Ok((strict, snapshot)) => { + catch_up = snapshot.sessions_scanned < snapshot.sessions_total; + reuse_cached_repositories = catch_up; + if evt_tx + .send(AppEvent::HistoryCatalogUpdated { strict, snapshot }) + .await + .is_err() + { + break; + } + } + Err(error) => { + if scan_cancelled.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + catch_up = false; + reuse_cached_repositories = false; + if evt_tx + .send(AppEvent::HistoryCatalogFailed(error.to_string())) + .await + .is_err() + { + break; + } + } + } + } + }); + } // Spawn usage worker. { @@ -674,7 +859,6 @@ async fn run_inner( account_usage_error: None, account_usage_notice: None, account_usage_enabled: true, - read_sessions_dir: config.read_sessions_dir.clone(), read_browser, }; @@ -698,6 +882,7 @@ async fn run_inner( input, &usage_refresh_tx, &limits_refresh_tx, + &catalog_refresh_tx, )? { InputOutcome::Continue(should_redraw) => { dirty |= should_redraw; @@ -796,6 +981,7 @@ fn handle_input_event( event: Event, usage_refresh_tx: &mpsc::Sender<()>, limits_refresh_tx: &mpsc::Sender<()>, + catalog_refresh_tx: &mpsc::Sender<()>, ) -> Result { if let Some(desired_skip_confirmation) = state.quit_preference_prompt { return match event { @@ -908,6 +1094,15 @@ fn handle_input_event( _ => None, }; if let Some(older) = wheel_direction { + if state.active_screen == ActiveScreen::Read + && ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) + == Some(UiClickAction::HistoryDepthWheel) + { + let changed = state + .read_browser + .change_deep_depth(if older { 1 } else { -1 }); + return Ok(InputOutcome::Continue(changed)); + } let changed = match state.active_screen { ActiveScreen::Usage if state @@ -998,10 +1193,7 @@ fn handle_input_event( (KeyCode::Char('r'), _) | (KeyCode::F(5), _) if state.active_screen == ActiveScreen::Read => { - let read_config = read::Config { - sessions_dir: state.read_sessions_dir.clone(), - }; - state.read_browser = read::build_browser(&read_config)?; + let _ = catalog_refresh_tx.try_send(()); return Ok(InputOutcome::Continue(true)); } _ => {} @@ -1101,6 +1293,10 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { state.display_style = style; changed } + UiClickAction::SetHistoryProjectMode(mode) => state.read_browser.set_project_mode(mode), + UiClickAction::DecreaseHistoryDepth => state.read_browser.change_deep_depth(-1), + UiClickAction::IncreaseHistoryDepth => state.read_browser.change_deep_depth(1), + UiClickAction::HistoryDepthWheel => false, UiClickAction::SetMetric(metric) => { let changed = state.metric != metric; state.metric = metric; @@ -1701,6 +1897,18 @@ fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool { } true } + AppEvent::HistoryCatalogProgress(progress) => { + state.read_browser.set_catalog_progress(progress); + true + } + AppEvent::HistoryCatalogUpdated { strict, snapshot } => { + state.read_browser.apply_catalog_snapshot(strict, snapshot); + true + } + AppEvent::HistoryCatalogFailed(error) => { + state.read_browser.set_catalog_error(error); + true + } } } @@ -1708,13 +1916,27 @@ fn missing_app_server_message() -> &'static str { "Codex App Server not found; usage/history still work. Install Codex CLI or pass --codex-bin/--app-server-bin." } +#[cfg(test)] fn load_persisted_ui_state( comon_home: &Path, workspace_hint: Option<&Path>, +) -> Result { + load_persisted_ui_state_with_history_depth( + comon_home, + workspace_hint, + crate::read::catalog::DEFAULT_DEEP_DEPTH, + ) +} + +fn load_persisted_ui_state_with_history_depth( + comon_home: &Path, + workspace_hint: Option<&Path>, + history_deep_depth: u8, ) -> Result { let store = load_or_bootstrap_state_store(comon_home)?; let mut state = PersistedUiState::default_for_workspace(workspace_hint.map(|path| path.to_path_buf())); + state.history_deep_depth = history_deep_depth.clamp(1, crate::read::catalog::MAX_DEEP_DEPTH); if let Some(metric_text) = store.global.metric.as_deref() { if let Some(metric) = usage_metric_from_store(metric_text) { @@ -1761,6 +1983,32 @@ fn load_persisted_ui_state( } } state.skip_quit_confirmation = store.global.skip_quit_confirmation; + if let Some(mode_text) = store.global.history_project_view_mode.as_deref() { + if let Some(mode) = crate::read::catalog::ProjectViewMode::from_store(mode_text) { + state.history_project_view_mode = mode; + } + } + if let Some(depth) = store.global.history_deep_depth { + state.history_deep_depth = depth.clamp(1, crate::read::catalog::MAX_DEEP_DEPTH); + } + state.history_selected_projects = store + .global + .history_selected_projects + .iter() + .cloned() + .collect(); + state.history_explicitly_excluded_projects = store + .global + .history_explicitly_excluded_projects + .iter() + .cloned() + .collect(); + state.history_expanded_remote_groups = store + .global + .history_expanded_remote_groups + .iter() + .cloned() + .collect(); if let Some(workspace_path) = state.workspace_path.as_ref() { let workspace_key = workspace_path.to_string_lossy(); @@ -1796,6 +2044,25 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul ); store.global.display_style = Some(state.display_style.store_value().to_string()); store.global.skip_quit_confirmation = state.skip_quit_confirmation; + store.global.history_project_view_mode = + Some(state.history_project_view_mode.store_value().to_string()); + store.global.history_deep_depth = Some( + state + .history_deep_depth + .clamp(1, crate::read::catalog::MAX_DEEP_DEPTH), + ); + store.global.history_selected_projects = + state.history_selected_projects.iter().cloned().collect(); + store.global.history_explicitly_excluded_projects = state + .history_explicitly_excluded_projects + .iter() + .cloned() + .collect(); + store.global.history_expanded_remote_groups = state + .history_expanded_remote_groups + .iter() + .cloned() + .collect(); store.global.last_workspace_path = workspace_path_text.clone(); store.global.updated_at = now; @@ -1822,13 +2089,15 @@ fn load_or_bootstrap_state_store(comon_home: &Path) -> Result { .with_context(|| format!("Unable to read state store {}", store_path.display()))?; let store = serde_json::from_slice::(&bytes) .with_context(|| format!("Unable to parse state store {}", store_path.display()))?; - if store.schema_version != STATE_STORE_SCHEMA_VERSION { + if store.schema_version > STATE_STORE_SCHEMA_VERSION { anyhow::bail!( - "Unsupported comon state schema version: {} (expected {})", + "Unsupported comon state schema version: {} (maximum {})", store.schema_version, STATE_STORE_SCHEMA_VERSION ); } + let mut store = store; + store.schema_version = STATE_STORE_SCHEMA_VERSION; Ok(store) } @@ -2186,6 +2455,60 @@ mod tests { let _ = std::fs::remove_dir_all(comon_home); } + #[test] + fn history_project_controls_round_trip_through_state_store() { + let comon_home = make_temp_dir("history-project-controls"); + let mut state = PersistedUiState::default_for_workspace(None); + state.history_project_view_mode = crate::read::catalog::ProjectViewMode::Custom; + state.history_deep_depth = 5; + state + .history_selected_projects + .insert("remote:example.com/team/project".to_string()); + state + .history_explicitly_excluded_projects + .insert("path:/home/example/hidden".to_string()); + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!( + loaded.history_project_view_mode, + crate::read::catalog::ProjectViewMode::Custom + ); + assert_eq!(loaded.history_deep_depth, 5); + assert_eq!( + loaded.history_selected_projects, + state.history_selected_projects + ); + assert_eq!( + loaded.history_explicitly_excluded_projects, + state.history_explicitly_excluded_projects + ); + + let _ = std::fs::remove_dir_all(comon_home); + } + + #[test] + fn state_schema_one_migrates_by_applying_catalog_defaults() { + let comon_home = make_temp_dir("state-schema-one"); + let mut store = StateStore { + schema_version: 1, + ..StateStore::default() + }; + store.global.display_style = Some(DisplayStyle::SystemFull.store_value().to_string()); + write_state_store(&comon_home, &store).expect("write legacy state"); + + let loaded = load_persisted_ui_state_with_history_depth(&comon_home, None, 4) + .expect("load legacy state"); + assert_eq!(loaded.display_style, DisplayStyle::SystemFull); + assert_eq!( + loaded.history_project_view_mode, + crate::read::catalog::ProjectViewMode::Strict + ); + assert_eq!(loaded.history_deep_depth, 4); + + let _ = std::fs::remove_dir_all(comon_home); + } + #[test] fn legacy_system_style_restores_as_system_compact() { let comon_home = make_temp_dir("legacy-system-display-style"); diff --git a/src/main.rs b/src/main.rs index c085202..a5d6257 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::path::PathBuf; -const USER_CONFIG_SCHEMA_VERSION: u32 = 1; +const USER_CONFIG_SCHEMA_VERSION: u32 = 2; const USER_CONFIG_FILE_NAME: &str = "config.json"; const DEFAULT_USAGE_DAYS: u32 = 30; const DEFAULT_REFRESH_USAGE_SECS: u64 = 300; @@ -22,6 +22,8 @@ const DEFAULT_MAX_SESSION_TOTAL_MIB: u64 = 256; const DEFAULT_MAX_SESSION_FILES: usize = 10_000; const DEFAULT_MAX_JSONL_LINE_KIB: u64 = 512; const DEFAULT_SCAN_TIME_BUDGET_MS: u64 = 1500; +const DEFAULT_HISTORY_CATALOG_MAX_CANDIDATES: usize = 10_000; +const DEFAULT_HISTORY_CATALOG_SCAN_BUDGET_MS: u64 = 100; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -37,6 +39,11 @@ struct UserConfig { scan_time_budget_ms: u64, full_scan: bool, scan_cache_max_entries: usize, + history_project_roots: Vec, + history_deep_depth: u8, + history_deep_max_depth: u8, + history_catalog_max_candidates: usize, + history_catalog_scan_budget_ms: u64, } impl Default for UserConfig { @@ -53,10 +60,23 @@ impl Default for UserConfig { scan_time_budget_ms: DEFAULT_SCAN_TIME_BUDGET_MS, full_scan: false, scan_cache_max_entries: usage::DEFAULT_SCAN_CACHE_MAX_ENTRIES, + history_project_roots: default_history_project_roots(), + history_deep_depth: read::catalog::DEFAULT_DEEP_DEPTH, + history_deep_max_depth: read::catalog::MAX_DEEP_DEPTH, + history_catalog_max_candidates: DEFAULT_HISTORY_CATALOG_MAX_CANDIDATES, + history_catalog_scan_budget_ms: DEFAULT_HISTORY_CATALOG_SCAN_BUDGET_MS, } } } +fn default_history_project_roots() -> Vec { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .into_iter() + .collect() +} + fn validate_dir(path: &std::path::Path, label: &str) -> Result { let meta = std::fs::metadata(path).with_context(|| format!("{label} does not exist"))?; if !meta.is_dir() { @@ -122,15 +142,21 @@ fn load_or_bootstrap_user_config(comon_home: &Path) -> Result { crate::storage::enforce_private_file_if_exists(&path)?; let bytes = std::fs::read(&path) .with_context(|| format!("Unable to read user config {}", path.display()))?; - let config = serde_json::from_slice::(&bytes) + let mut config = serde_json::from_slice::(&bytes) .with_context(|| format!("Unable to parse user config {}", path.display()))?; - if config.schema_version != USER_CONFIG_SCHEMA_VERSION { + if config.schema_version > USER_CONFIG_SCHEMA_VERSION { anyhow::bail!( - "Unsupported comon config schema version: {} (expected {})", + "Unsupported comon config schema version: {} (maximum {})", config.schema_version, USER_CONFIG_SCHEMA_VERSION ); } + if config.schema_version < USER_CONFIG_SCHEMA_VERSION { + config.schema_version = USER_CONFIG_SCHEMA_VERSION; + let encoded = serde_json::to_vec_pretty(&config) + .with_context(|| format!("Unable to migrate user config {}", path.display()))?; + crate::storage::write_private_file(&path, &encoded)?; + } Ok(config) } @@ -374,6 +400,18 @@ async fn main() -> Result<()> { usage_scan_limits, rebuild_cache_on_start: args.rebuild_cache_on_start, system_locale, + history_project_roots: user_config.history_project_roots, + history_deep_depth: user_config.history_deep_depth.clamp( + 1, + user_config + .history_deep_max_depth + .clamp(1, read::catalog::MAX_DEEP_DEPTH), + ), + history_deep_max_depth: user_config + .history_deep_max_depth + .clamp(1, read::catalog::MAX_DEEP_DEPTH), + history_catalog_max_candidates: user_config.history_catalog_max_candidates.max(1), + history_catalog_scan_budget_ms: user_config.history_catalog_scan_budget_ms.max(25), }; app::run(config).await @@ -450,4 +488,36 @@ mod tests { assert_eq!(filtered, Some(expected)); let _ = std::fs::remove_dir_all(root); } + + #[test] + fn user_config_schema_one_migrates_without_losing_scan_settings() { + let comon_home = make_temp_dir("config-migration"); + let path = comon_home.join(USER_CONFIG_FILE_NAME); + let legacy = serde_json::json!({ + "schema_version": 1, + "usage_days": 45, + "refresh_usage_secs": 600 + }); + crate::storage::write_private_file( + &path, + &serde_json::to_vec_pretty(&legacy).expect("encode legacy config"), + ) + .expect("write legacy config"); + + let migrated = load_or_bootstrap_user_config(&comon_home).expect("migrate config"); + assert_eq!(migrated.schema_version, USER_CONFIG_SCHEMA_VERSION); + assert_eq!(migrated.usage_days, 45); + assert_eq!(migrated.refresh_usage_secs, 600); + assert_eq!( + migrated.history_deep_depth, + read::catalog::DEFAULT_DEEP_DEPTH + ); + assert!(!migrated.history_project_roots.is_empty()); + + let persisted: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).expect("read migrated config")) + .expect("parse migrated config"); + assert_eq!(persisted["schema_version"], USER_CONFIG_SCHEMA_VERSION); + let _ = std::fs::remove_dir_all(comon_home); + } } diff --git a/src/read/catalog.rs b/src/read/catalog.rs new file mode 100644 index 0000000..b0be744 --- /dev/null +++ b/src/read/catalog.rs @@ -0,0 +1,1586 @@ +use crate::read::scan::{Catalog, SessionSummary}; +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub(crate) const DEFAULT_DEEP_DEPTH: u8 = 2; +pub(crate) const MAX_DEEP_DEPTH: u8 = 8; +const CATALOG_SCHEMA_VERSION: i64 = 1; +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMMAND_BYTES: usize = 64 * 1024; +const MAX_RELATED_PROJECTS_PER_SESSION: usize = 64; +const EVIDENCE_PASS_BYTES: u64 = 256 * 1024 * 1024; + +pub(crate) const SOURCE_OWNER: u32 = 1 << 0; +pub(crate) const SOURCE_REPOSITORY: u32 = 1 << 1; +pub(crate) const SOURCE_WORKDIR: u32 = 1 << 2; +pub(crate) const SOURCE_FILE_TARGET: u32 = 1 << 3; +pub(crate) const SOURCE_COMMAND_PATH: u32 = 1 << 4; +pub(crate) const SOURCE_USER_SELECTED: u32 = 1 << 5; +pub(crate) const SOURCE_NOISY_TREE: u32 = 1 << 6; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ProjectViewMode { + #[default] + Strict, + Deep, + Full, + Custom, +} + +impl ProjectViewMode { + pub(crate) fn toggled(self) -> Self { + match self { + Self::Strict => Self::Deep, + Self::Deep => Self::Full, + Self::Full => Self::Custom, + Self::Custom => Self::Strict, + } + } + + pub(crate) fn store_value(self) -> &'static str { + match self { + Self::Strict => "strict", + Self::Deep => "deep", + Self::Full => "full", + Self::Custom => "custom", + } + } + + pub(crate) fn from_store(value: &str) -> Option { + match value { + "strict" => Some(Self::Strict), + "deep" => Some(Self::Deep), + "full" => Some(Self::Full), + "custom" => Some(Self::Custom), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CatalogScanConfig { + pub(crate) sessions_dir: PathBuf, + pub(crate) search_roots: Vec, + pub(crate) excluded_roots: Vec, + pub(crate) max_depth: u8, + pub(crate) max_candidates: usize, + pub(crate) progress_interval_ms: u64, + pub(crate) cache_db_path: PathBuf, + pub(crate) cancelled: Arc, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(crate) struct CatalogSnapshot { + pub(crate) checkouts: Vec, + pub(crate) links: Vec, + pub(crate) directories_scanned: usize, + pub(crate) sessions_scanned: usize, + pub(crate) sessions_total: usize, + pub(crate) truncated: bool, + pub(crate) updated_at: i64, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct CatalogProgress { + pub(crate) phase: CatalogScanPhase, + pub(crate) completed: usize, + pub(crate) total: usize, + pub(crate) projects: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum CatalogScanPhase { + #[default] + Repositories, + Sessions, + Saving, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ProjectCheckout { + pub(crate) stable_id: String, + pub(crate) checkout_key: String, + pub(crate) display_path: String, + pub(crate) remote_key: Option, + pub(crate) logical_name: String, + pub(crate) discovery_depth: u8, + pub(crate) source_flags: u32, + pub(crate) confidence: u8, + pub(crate) deep_eligible: bool, + pub(crate) first_seen: i64, + pub(crate) last_seen: i64, + pub(crate) missing: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SessionProjectLink { + pub(crate) session_path: String, + pub(crate) checkout_key: String, + pub(crate) evidence_mask: u32, + pub(crate) evidence_count: usize, + pub(crate) confidence: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PathEvidence { + path: String, + source_flags: u32, + confidence: u8, +} + +#[derive(Debug, Clone)] +struct DiscoveredCheckout { + checkout_key: String, + display_path: String, + remote_key: Option, + logical_name: String, + discovery_depth: u8, + noisy: bool, +} + +#[derive(Debug, Clone)] +struct CachedEvidence { + file_size: u64, + file_mtime_ms: i64, + evidence: Vec, +} + +pub(crate) fn scan_project_catalog( + config: &CatalogScanConfig, + strict: &Catalog, + mut progress: F, +) -> Result +where + F: FnMut(CatalogProgress), +{ + let interval = Duration::from_millis(config.progress_interval_ms.max(25)); + let mut last_progress = Instant::now(); + let mut directory_count = 0usize; + let mut truncated = false; + let mut discovered = Vec::new(); + + for root in normalized_unique_roots(&config.search_roots) { + if config.cancelled.load(Ordering::Relaxed) { + anyhow::bail!("project catalog scan cancelled"); + } + let excluded = normalized_unique_roots(&config.excluded_roots); + discover_repositories( + &root, + config.max_depth.clamp(1, MAX_DEEP_DEPTH), + config.max_candidates.max(1), + &excluded, + &mut directory_count, + &mut truncated, + &mut discovered, + config.cancelled.as_ref(), + |scanned, projects| { + if last_progress.elapsed() >= interval { + progress(CatalogProgress { + phase: CatalogScanPhase::Repositories, + completed: scanned, + total: 0, + projects, + }); + last_progress = Instant::now(); + } + }, + )?; + if truncated { + break; + } + } + + discovered.sort_by(|left, right| left.checkout_key.cmp(&right.checkout_key)); + discovered.dedup_by(|left, right| left.checkout_key == right.checkout_key); + + scan_catalog_evidence( + config, + strict, + discovered, + directory_count, + truncated, + progress, + ) +} + +pub(crate) fn continue_project_catalog( + config: &CatalogScanConfig, + strict: &Catalog, + progress: F, +) -> Result +where + F: FnMut(CatalogProgress), +{ + let Some(snapshot) = load_catalog_cache(&config.cache_db_path)? else { + return scan_project_catalog(config, strict, progress); + }; + let discovered = snapshot + .checkouts + .into_iter() + .filter(|checkout| !checkout.missing) + .map(|checkout| DiscoveredCheckout { + checkout_key: checkout.checkout_key, + display_path: checkout.display_path, + remote_key: checkout.remote_key, + logical_name: checkout.logical_name, + discovery_depth: checkout.discovery_depth, + noisy: checkout.source_flags & SOURCE_NOISY_TREE != 0, + }) + .collect(); + scan_catalog_evidence(config, strict, discovered, 0, snapshot.truncated, progress) +} + +fn scan_catalog_evidence( + config: &CatalogScanConfig, + strict: &Catalog, + discovered: Vec, + directory_count: usize, + truncated: bool, + mut progress: F, +) -> Result +where + F: FnMut(CatalogProgress), +{ + let now = unix_time_seconds(); + let interval = Duration::from_millis(config.progress_interval_ms.max(25)); + let mut last_progress = Instant::now(); + + let mut connection = open_catalog_db(&config.cache_db_path)?; + let cached_evidence = load_evidence_cache(&connection)?; + let mut sessions = strict_sessions(strict); + sessions.sort_by(|left, right| { + right + .started_at_sort_key_ms + .cmp(&left.started_at_sort_key_ms) + .then_with(|| left.file_path.cmp(&right.file_path)) + }); + let mut evidence_by_session = BTreeMap::new(); + let mut evidence_updates = BTreeMap::new(); + let mut bytes_scheduled = 0u64; + let mut sessions_scanned = 0usize; + for (index, session) in sessions.iter().enumerate() { + if config.cancelled.load(Ordering::Relaxed) { + anyhow::bail!("project catalog scan cancelled"); + } + let path = &session.file_path; + let (size, mtime_ms) = regular_file_fingerprint(path).unwrap_or((0, 0)); + let cache_key = path.to_string_lossy().into_owned(); + let cached = cached_evidence.get(&cache_key); + let exact = + cached.is_some_and(|item| item.file_size == size && item.file_mtime_ms == mtime_ms); + let parse_bytes = if exact { + 0 + } else if let Some(cached) = cached.filter(|item| size > item.file_size) { + size.saturating_sub(cached.file_size) + } else { + size + }; + let within_budget = parse_bytes == 0 + || bytes_scheduled == 0 + || bytes_scheduled.saturating_add(parse_bytes) <= EVIDENCE_PASS_BYTES; + if !within_budget { + if let Some(cached) = cached { + evidence_by_session.insert( + cache_key, + ( + cached.file_size, + cached.file_mtime_ms, + cached.evidence.clone(), + ), + ); + } + continue; + } + bytes_scheduled = bytes_scheduled.saturating_add(parse_bytes); + let evidence = match cached { + Some(cached) if cached.file_size == size && cached.file_mtime_ms == mtime_ms => { + cached.evidence.clone() + } + Some(cached) if size > cached.file_size => { + let mut evidence = cached.evidence.clone(); + evidence.extend(extract_evidence_bounded( + path, + cached.file_size, + config.cancelled.as_ref(), + )?); + evidence + } + _ => extract_evidence_bounded(path, 0, config.cancelled.as_ref())?, + }; + sessions_scanned = sessions_scanned.saturating_add(1); + evidence_by_session.insert(cache_key.clone(), (size, mtime_ms, evidence.clone())); + if !exact { + evidence_updates.insert(cache_key, (size, mtime_ms, evidence)); + } + if last_progress.elapsed() >= interval || index + 1 == sessions.len() { + progress(CatalogProgress { + phase: CatalogScanPhase::Sessions, + completed: sessions_scanned, + total: sessions.len(), + projects: discovered.len(), + }); + last_progress = Instant::now(); + } + } + + let mut links = build_links(&discovered, &evidence_by_session); + links.sort_by(|left, right| { + left.session_path + .cmp(&right.session_path) + .then_with(|| left.checkout_key.cmp(&right.checkout_key)) + }); + + let mut link_rollup: BTreeMap = BTreeMap::new(); + for link in &links { + let entry = link_rollup + .entry(link.checkout_key.clone()) + .or_insert((0, 0, 0)); + entry.0 |= link.evidence_mask; + entry.1 = entry.1.saturating_add(link.evidence_count); + entry.2 = entry.2.max(link.confidence); + } + + let checkouts = discovered + .into_iter() + .map(|checkout| { + let (link_flags, evidence_count, link_confidence) = link_rollup + .get(&checkout.checkout_key) + .copied() + .unwrap_or((0, 0, 0)); + let source_flags = + SOURCE_REPOSITORY | link_flags | if checkout.noisy { SOURCE_NOISY_TREE } else { 0 }; + let deep_eligible = + link_confidence >= 70 || (link_confidence >= 40 && evidence_count >= 2); + let confidence = link_confidence.max(10); + let stable_id = checkout + .remote_key + .as_ref() + .map(|remote| format!("remote:{remote}")) + .unwrap_or_else(|| format!("path:{}", checkout.checkout_key)); + ProjectCheckout { + stable_id, + checkout_key: checkout.checkout_key, + display_path: checkout.display_path, + remote_key: checkout.remote_key, + logical_name: checkout.logical_name, + discovery_depth: checkout.discovery_depth, + source_flags, + confidence, + deep_eligible, + first_seen: now, + last_seen: now, + missing: false, + } + }) + .collect::>(); + + progress(CatalogProgress { + phase: CatalogScanPhase::Saving, + completed: sessions_scanned, + total: sessions.len(), + projects: checkouts.len(), + }); + if config.cancelled.load(Ordering::Relaxed) { + anyhow::bail!("project catalog scan cancelled"); + } + save_catalog_cache( + &mut connection, + config, + &checkouts, + &links, + &evidence_updates, + now, + truncated, + )?; + + load_catalog_cache_from_connection(&connection).map(|mut snapshot| { + snapshot.directories_scanned = directory_count; + snapshot.sessions_scanned = sessions_scanned; + snapshot.sessions_total = sessions.len(); + snapshot.truncated = truncated; + snapshot.updated_at = now; + snapshot + }) +} + +pub(crate) fn load_catalog_cache(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let connection = open_catalog_db(path)?; + let version = connection + .query_row( + "SELECT value FROM catalog_meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .and_then(|value| value.parse::().ok()); + if version != Some(CATALOG_SCHEMA_VERSION) { + return Ok(None); + } + Ok(Some(load_catalog_cache_from_connection(&connection)?)) +} + +#[allow(clippy::too_many_arguments)] +fn discover_repositories( + root: &Path, + max_depth: u8, + max_candidates: usize, + excluded_roots: &[PathBuf], + directory_count: &mut usize, + truncated: &mut bool, + out: &mut Vec, + cancelled: &AtomicBool, + mut on_progress: F, +) -> Result<()> +where + F: FnMut(usize, usize), +{ + let root_meta = match std::fs::symlink_metadata(root) { + Ok(meta) if meta.file_type().is_dir() && !meta.file_type().is_symlink() => meta, + Ok(_) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error).with_context(|| format!("Unable to inspect {}", root.display())) + } + }; + let _ = root_meta; + let mut queue = VecDeque::from([(root.to_path_buf(), 0u8)]); + let max_directories = max_candidates.saturating_mul(5).clamp(10_000, 50_000); + while let Some((dir, depth)) = queue.pop_front() { + if cancelled.load(Ordering::Relaxed) { + anyhow::bail!("project catalog scan cancelled"); + } + if *directory_count >= max_directories || out.len() >= max_candidates { + *truncated = true; + break; + } + *directory_count = directory_count.saturating_add(1); + on_progress(*directory_count, out.len()); + + if depth > 0 && repository_marker(&dir).is_some() { + if let Some(checkout) = inspect_checkout(&dir, depth, root) { + out.push(checkout); + } + } + if depth > 0 && should_prune_subtree(&dir) { + continue; + } + if depth >= max_depth { + continue; + } + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => continue, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|name| name.to_str()) == Some(".git") { + continue; + } + if excluded_roots + .iter() + .any(|excluded| path_starts_with(&path, excluded)) + { + continue; + } + let Ok(meta) = std::fs::symlink_metadata(&path) else { + continue; + }; + let file_type = meta.file_type(); + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + queue.push_back((path, depth.saturating_add(1))); + } + } + Ok(()) +} + +fn inspect_checkout(path: &Path, depth: u8, root: &Path) -> Option { + let display_path = path.to_string_lossy().into_owned(); + if display_path.len() > MAX_PATH_BYTES { + return None; + } + let checkout_key = normalize_local_path(path); + let raw_remote = read_origin_remote(path); + let remote_key = raw_remote.as_deref().and_then(normalize_remote_url); + let logical_name = remote_key + .as_deref() + .map(remote_display_name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&display_path) + .to_string() + }); + Some(DiscoveredCheckout { + checkout_key, + display_path, + remote_key, + logical_name, + discovery_depth: depth.max(1), + noisy: is_noisy_relative_path(path.strip_prefix(root).unwrap_or(path)), + }) +} + +fn repository_marker(path: &Path) -> Option { + let marker = path.join(".git"); + let meta = std::fs::symlink_metadata(&marker).ok()?; + let file_type = meta.file_type(); + if file_type.is_symlink() { + return None; + } + if file_type.is_dir() || file_type.is_file() { + Some(marker) + } else { + None + } +} + +fn read_origin_remote(checkout: &Path) -> Option { + let marker = repository_marker(checkout)?; + let meta = std::fs::symlink_metadata(&marker).ok()?; + let config_path = if meta.file_type().is_dir() { + marker.join("config") + } else { + if meta.len() > 64 * 1024 { + return None; + } + let content = std::fs::read_to_string(&marker).ok()?; + let gitdir = content.trim().strip_prefix("gitdir:")?.trim(); + if gitdir.len() > MAX_PATH_BYTES { + return None; + } + let gitdir = PathBuf::from(gitdir); + let gitdir = if gitdir.is_absolute() { + gitdir + } else { + checkout.join(gitdir) + }; + let direct = gitdir.join("config"); + if direct.is_file() { + direct + } else { + let common_file = gitdir.join("commondir"); + let common_meta = std::fs::symlink_metadata(&common_file).ok()?; + if common_meta.file_type().is_symlink() + || !common_meta.file_type().is_file() + || common_meta.len() > 4096 + { + return None; + } + let common = std::fs::read_to_string(&common_file).ok()?; + let common = common.trim(); + if common.is_empty() || common.len() > MAX_PATH_BYTES { + return None; + } + let common = PathBuf::from(common); + let common = if common.is_absolute() { + common + } else { + gitdir.join(common) + }; + normalize_path(&common).join("config") + } + }; + let meta = std::fs::symlink_metadata(&config_path).ok()?; + if meta.file_type().is_symlink() || !meta.file_type().is_file() || meta.len() > 1024 * 1024 { + return None; + } + let content = std::fs::read_to_string(config_path).ok()?; + let mut in_origin = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_origin = trimmed.eq_ignore_ascii_case("[remote \"origin\"]"); + continue; + } + if in_origin { + if let Some((key, value)) = trimmed.split_once('=') { + if key.trim().eq_ignore_ascii_case("url") { + return Some(value.trim().to_string()); + } + } + } + } + None +} + +pub(crate) fn normalize_remote_url(raw: &str) -> Option { + let mut value = raw.trim(); + if value.is_empty() || value.len() > MAX_PATH_BYTES { + return None; + } + value = value.split(['?', '#']).next().unwrap_or(value); + let normalized = if let Some((_, rest)) = value.split_once("://") { + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + let host_port = authority.rsplit('@').next().unwrap_or(authority); + let host = host_port + .trim_matches(['[', ']']) + .split(':') + .next() + .unwrap_or(host_port) + .to_ascii_lowercase(); + if host.is_empty() || path.is_empty() { + return None; + } + format!("{host}/{}", path.trim_start_matches('/')) + } else if let Some((left, path)) = value.rsplit_once(':') { + let host = left.rsplit('@').next().unwrap_or(left).to_ascii_lowercase(); + if host.is_empty() || path.is_empty() || host.contains('/') { + return None; + } + format!("{host}/{}", path.trim_start_matches('/')) + } else { + return None; + }; + let normalized = normalized + .trim_end_matches('/') + .strip_suffix(".git") + .unwrap_or(normalized.trim_end_matches('/')) + .trim_end_matches('/') + .to_string(); + (!normalized.is_empty()).then_some(normalized) +} + +fn remote_display_name(remote: &str) -> String { + let parts = remote.split('/').collect::>(); + if parts.len() >= 3 { + format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1]) + } else { + parts.last().copied().unwrap_or(remote).to_string() + } +} + +#[cfg(test)] +fn extract_structured_evidence(path: &Path) -> Result> { + extract_structured_evidence_from(path, 0) +} + +#[cfg(test)] +fn extract_structured_evidence_from(path: &Path, offset: u64) -> Result> { + extract_structured_evidence_from_cancellable(path, offset, None) +} + +fn extract_structured_evidence_from_cancellable( + path: &Path, + offset: u64, + cancelled: Option<&AtomicBool>, +) -> Result> { + let meta = std::fs::symlink_metadata(path) + .with_context(|| format!("Unable to inspect {}", path.display()))?; + if meta.file_type().is_symlink() || !meta.file_type().is_file() { + return Ok(Vec::new()); + } + let mut file = + File::open(path).with_context(|| format!("Unable to open {}", path.display()))?; + if offset > 0 { + file.seek(SeekFrom::Start(offset)) + .with_context(|| format!("Unable to seek {}", path.display()))?; + } + let reader = BufReader::new(file); + let mut evidence = Vec::new(); + for line in reader.lines() { + if cancelled.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + anyhow::bail!("project catalog scan cancelled"); + } + let Ok(line) = line else { continue }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value.get("type").and_then(Value::as_str) != Some("response_item") { + continue; + } + let Some(payload) = value.get("payload").and_then(Value::as_object) else { + continue; + }; + if payload.get("type").and_then(Value::as_str) != Some("function_call") { + continue; + } + let name = payload.get("name").and_then(Value::as_str).unwrap_or(""); + let Some(arguments_raw) = payload.get("arguments").and_then(Value::as_str) else { + continue; + }; + if arguments_raw.len() > MAX_COMMAND_BYTES.saturating_mul(2) { + continue; + } + let Ok(arguments) = serde_json::from_str::(arguments_raw) else { + continue; + }; + extract_argument_evidence(name, &arguments, &mut evidence); + if evidence.len() >= MAX_RELATED_PROJECTS_PER_SESSION.saturating_mul(8) { + break; + } + } + Ok(evidence) +} + +fn extract_evidence_bounded( + path: &Path, + offset: u64, + cancelled: &AtomicBool, +) -> Result> { + match extract_structured_evidence_from_cancellable(path, offset, Some(cancelled)) { + Ok(evidence) => Ok(evidence), + Err(error) if cancelled.load(Ordering::Relaxed) => Err(error), + Err(_) => Ok(Vec::new()), + } +} + +fn extract_argument_evidence(name: &str, arguments: &Value, out: &mut Vec) { + if !is_evidence_tool(name) { + return; + } + let Some(object) = arguments.as_object() else { + return; + }; + let trusted_workdir = ["workdir", "cwd", "working_directory"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)) + .filter(|path| valid_absolute_path(path)); + if let Some(workdir) = trusted_workdir { + push_evidence(out, workdir, SOURCE_WORKDIR, 80); + } + + let modifying = name.contains("patch") + || name.contains("write") + || name.contains("edit") + || name.contains("create"); + for key in ["path", "file_path", "target_path"] { + if let Some(raw) = object.get(key).and_then(Value::as_str) { + if let Some(path) = resolve_evidence_path(raw, trusted_workdir) { + push_evidence( + out, + &path, + if modifying { + SOURCE_FILE_TARGET + } else { + SOURCE_COMMAND_PATH + }, + if modifying { 70 } else { 40 }, + ); + } + } + } + for key in ["paths", "files"] { + if let Some(values) = object.get(key).and_then(Value::as_array) { + for raw in values.iter().filter_map(Value::as_str).take(64) { + if let Some(path) = resolve_evidence_path(raw, trusted_workdir) { + push_evidence( + out, + &path, + if modifying { + SOURCE_FILE_TARGET + } else { + SOURCE_COMMAND_PATH + }, + if modifying { 70 } else { 40 }, + ); + } + } + } + } + for key in ["cmd", "command"] { + let Some(command) = object.get(key).and_then(Value::as_str) else { + continue; + }; + if command.len() > MAX_COMMAND_BYTES { + continue; + } + for token in command.split_whitespace().take(2048) { + let token = token.trim_matches(|ch: char| { + matches!(ch, '\'' | '"' | '`' | ',' | ';' | '(' | ')' | '[' | ']') + }); + if !looks_like_command_path(token) { + continue; + } + if let Some(path) = resolve_evidence_path(token, trusted_workdir) { + push_evidence(out, &path, SOURCE_COMMAND_PATH, 40); + } + } + } + if name.to_ascii_lowercase().contains("patch") { + for key in ["patch", "input"] { + let Some(patch) = object.get(key).and_then(Value::as_str) else { + continue; + }; + if patch.len() > MAX_COMMAND_BYTES { + continue; + } + for line in patch.lines() { + let raw = ["*** Add File: ", "*** Update File: ", "*** Delete File: "] + .iter() + .find_map(|prefix| line.strip_prefix(prefix)); + let Some(raw) = raw else { continue }; + if let Some(path) = resolve_evidence_path(raw.trim(), trusted_workdir) { + push_evidence(out, &path, SOURCE_FILE_TARGET, 70); + } + } + } + } +} + +fn looks_like_command_path(token: &str) -> bool { + if token.is_empty() || token.starts_with('-') || token.len() > MAX_PATH_BYTES { + return false; + } + token.starts_with('/') + || token.starts_with("./") + || token.starts_with("../") + || token.starts_with("~/") + || token.contains('/') + || token.contains('\\') + || Path::new(token).extension().is_some() +} + +fn is_evidence_tool(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + [ + "exec_command", + "shell_command", + "apply_patch", + "write_file", + "edit_file", + "create_file", + "read_file", + "read_text_file", + "view_image", + ] + .iter() + .any(|allowed| name == *allowed || name.ends_with(&format!("__{allowed}"))) +} + +fn resolve_evidence_path(raw: &str, workdir: Option<&str>) -> Option { + if raw.is_empty() || raw.len() > MAX_PATH_BYTES || raw.contains('\0') { + return None; + } + let path = Path::new(raw); + if path.is_absolute() { + return Some(normalize_local_path(path)); + } + let workdir = workdir?; + Some(normalize_local_path(&Path::new(workdir).join(path))) +} + +fn valid_absolute_path(raw: &str) -> bool { + !raw.is_empty() + && raw.len() <= MAX_PATH_BYTES + && !raw.contains('\0') + && Path::new(raw).is_absolute() +} + +fn push_evidence(out: &mut Vec, path: &str, source_flags: u32, confidence: u8) { + if path.len() <= MAX_PATH_BYTES { + out.push(PathEvidence { + path: path.to_string(), + source_flags, + confidence, + }); + } +} + +fn build_links( + checkouts: &[DiscoveredCheckout], + evidence_by_session: &BTreeMap)>, +) -> Vec { + let mut links = Vec::new(); + for (session_path, (_, _, evidence)) in evidence_by_session { + let mut grouped: BTreeMap = BTreeMap::new(); + for item in evidence { + let Some(checkout) = nearest_checkout(&item.path, checkouts) else { + continue; + }; + let entry = grouped + .entry(checkout.checkout_key.clone()) + .or_insert((0, 0, 0)); + entry.0 |= item.source_flags; + entry.1 = entry.1.saturating_add(1); + entry.2 = entry.2.max(item.confidence); + } + for (checkout_key, (evidence_mask, evidence_count, confidence)) in + grouped.into_iter().take(MAX_RELATED_PROJECTS_PER_SESSION) + { + links.push(SessionProjectLink { + session_path: session_path.clone(), + checkout_key, + evidence_mask, + evidence_count, + confidence, + }); + } + } + links +} + +fn nearest_checkout<'a>( + evidence_path: &str, + checkouts: &'a [DiscoveredCheckout], +) -> Option<&'a DiscoveredCheckout> { + checkouts + .iter() + .filter(|checkout| string_path_starts_with(evidence_path, &checkout.checkout_key)) + .max_by_key(|checkout| checkout.checkout_key.len()) +} + +fn open_catalog_db(path: &Path) -> Result { + if let Some(parent) = path.parent() { + crate::storage::ensure_private_dir(parent)?; + } + crate::storage::enforce_private_file_if_exists(path)?; + let connection = Connection::open(path) + .with_context(|| format!("Unable to open catalog cache {}", path.display()))?; + connection.busy_timeout(Duration::from_secs(5))?; + connection.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS catalog_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS project_checkout ( + checkout_key TEXT PRIMARY KEY, + stable_id TEXT NOT NULL, + display_path TEXT NOT NULL, + remote_key TEXT, + logical_name TEXT NOT NULL, + discovery_depth INTEGER NOT NULL, + source_flags INTEGER NOT NULL, + confidence INTEGER NOT NULL, + deep_eligible INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + missing INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS session_project_link ( + session_path TEXT NOT NULL, + checkout_key TEXT NOT NULL, + evidence_mask INTEGER NOT NULL, + evidence_count INTEGER NOT NULL, + confidence INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY(session_path, checkout_key) + ); + CREATE TABLE IF NOT EXISTS project_evidence_file_cache ( + session_path TEXT PRIMARY KEY, + file_size INTEGER NOT NULL, + file_mtime_ms INTEGER NOT NULL, + file_offset INTEGER NOT NULL, + fully_parsed INTEGER NOT NULL, + evidence_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS catalog_scan_root ( + root_path TEXT PRIMARY KEY, + max_depth INTEGER NOT NULL, + last_scan INTEGER NOT NULL + );", + )?; + let stored_version = connection + .query_row( + "SELECT value FROM catalog_meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + match stored_version { + None => { + connection.execute( + "INSERT INTO catalog_meta(key, value) VALUES('schema_version', ?1)", + [CATALOG_SCHEMA_VERSION.to_string()], + )?; + } + Some(value) if value == CATALOG_SCHEMA_VERSION.to_string() => {} + Some(value) => anyhow::bail!( + "Unsupported project catalog schema version: {value} (expected {CATALOG_SCHEMA_VERSION})" + ), + } + crate::storage::enforce_private_file_if_exists(path)?; + Ok(connection) +} + +fn save_catalog_cache( + connection: &mut Connection, + config: &CatalogScanConfig, + checkouts: &[ProjectCheckout], + links: &[SessionProjectLink], + evidence_by_session: &BTreeMap)>, + now: i64, + truncated: bool, +) -> Result<()> { + let tx = connection.transaction()?; + for checkout in checkouts { + tx.execute( + "INSERT INTO project_checkout( + checkout_key, stable_id, display_path, remote_key, logical_name, + discovery_depth, source_flags, confidence, deep_eligible, + first_seen, last_seen, missing + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 0) + ON CONFLICT(checkout_key) DO UPDATE SET + stable_id = excluded.stable_id, + display_path = excluded.display_path, + remote_key = excluded.remote_key, + logical_name = excluded.logical_name, + discovery_depth = excluded.discovery_depth, + source_flags = excluded.source_flags, + confidence = excluded.confidence, + deep_eligible = excluded.deep_eligible, + last_seen = excluded.last_seen, + missing = 0", + params![ + checkout.checkout_key, + checkout.stable_id, + checkout.display_path, + checkout.remote_key, + checkout.logical_name, + i64::from(checkout.discovery_depth), + i64::from(checkout.source_flags), + i64::from(checkout.confidence), + i64::from(checkout.deep_eligible), + checkout.first_seen, + checkout.last_seen, + ], + )?; + } + tx.execute("DELETE FROM session_project_link", [])?; + for link in links { + tx.execute( + "INSERT INTO session_project_link( + session_path, checkout_key, evidence_mask, evidence_count, confidence, last_seen + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)", + params![ + link.session_path, + link.checkout_key, + i64::from(link.evidence_mask), + i64::try_from(link.evidence_count).unwrap_or(i64::MAX), + i64::from(link.confidence), + now, + ], + )?; + } + for (session_path, (file_size, file_mtime_ms, evidence)) in evidence_by_session { + let encoded = serde_json::to_string(evidence)?; + tx.execute( + "INSERT INTO project_evidence_file_cache( + session_path, file_size, file_mtime_ms, file_offset, + fully_parsed, evidence_json, updated_at + ) VALUES(?1, ?2, ?3, ?2, 1, ?4, ?5) + ON CONFLICT(session_path) DO UPDATE SET + file_size = excluded.file_size, + file_mtime_ms = excluded.file_mtime_ms, + file_offset = excluded.file_offset, + fully_parsed = 1, + evidence_json = excluded.evidence_json, + updated_at = excluded.updated_at", + params![ + session_path, + i64::try_from(*file_size).unwrap_or(i64::MAX), + file_mtime_ms, + encoded, + now, + ], + )?; + } + for root in normalized_unique_roots(&config.search_roots) { + tx.execute( + "INSERT INTO catalog_scan_root(root_path, max_depth, last_scan) + VALUES(?1, ?2, ?3) + ON CONFLICT(root_path) DO UPDATE SET + max_depth = excluded.max_depth, + last_scan = excluded.last_scan", + params![root.to_string_lossy(), i64::from(config.max_depth), now], + )?; + } + tx.execute( + "INSERT INTO catalog_meta(key, value) VALUES('updated_at', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [now.to_string()], + )?; + tx.execute( + "INSERT INTO catalog_meta(key, value) VALUES('truncated', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [if truncated { "1" } else { "0" }], + )?; + tx.commit()?; + Ok(()) +} + +fn load_catalog_cache_from_connection(connection: &Connection) -> Result { + let mut checkout_statement = connection.prepare( + "SELECT stable_id, checkout_key, display_path, remote_key, logical_name, + discovery_depth, source_flags, confidence, deep_eligible, + first_seen, last_seen, missing + FROM project_checkout + ORDER BY logical_name, display_path", + )?; + let checkouts = checkout_statement + .query_map([], |row| { + Ok(ProjectCheckout { + stable_id: row.get(0)?, + checkout_key: row.get(1)?, + display_path: row.get(2)?, + remote_key: row.get(3)?, + logical_name: row.get(4)?, + discovery_depth: row.get::<_, i64>(5)?.clamp(0, 255) as u8, + source_flags: row.get::<_, i64>(6)?.max(0) as u32, + confidence: row.get::<_, i64>(7)?.clamp(0, 255) as u8, + deep_eligible: row.get::<_, i64>(8)? != 0, + first_seen: row.get(9)?, + last_seen: row.get(10)?, + missing: row.get::<_, i64>(11)? != 0, + }) + })? + .collect::>>()?; + + let mut link_statement = connection.prepare( + "SELECT session_path, checkout_key, evidence_mask, evidence_count, confidence + FROM session_project_link + ORDER BY session_path, checkout_key", + )?; + let links = link_statement + .query_map([], |row| { + Ok(SessionProjectLink { + session_path: row.get(0)?, + checkout_key: row.get(1)?, + evidence_mask: row.get::<_, i64>(2)?.max(0) as u32, + evidence_count: row.get::<_, i64>(3)?.max(0) as usize, + confidence: row.get::<_, i64>(4)?.clamp(0, 255) as u8, + }) + })? + .collect::>>()?; + let updated_at = connection + .query_row( + "SELECT value FROM catalog_meta WHERE key = 'updated_at'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let truncated = connection + .query_row( + "SELECT value FROM catalog_meta WHERE key = 'truncated'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .is_some_and(|value| value == "1"); + let checkouts = checkouts + .into_iter() + .map(|mut checkout| { + checkout.missing |= repository_marker(Path::new(&checkout.display_path)).is_none(); + checkout + }) + .collect(); + Ok(CatalogSnapshot { + checkouts, + links, + updated_at, + truncated, + ..CatalogSnapshot::default() + }) +} + +fn load_evidence_cache(connection: &Connection) -> Result> { + let mut statement = connection.prepare( + "SELECT session_path, file_size, file_mtime_ms, evidence_json + FROM project_evidence_file_cache + WHERE fully_parsed = 1", + )?; + let rows = statement.query_map([], |row| { + let session_path = row.get::<_, String>(0)?; + let file_size = row.get::<_, i64>(1)?.max(0) as u64; + let file_mtime_ms = row.get::<_, i64>(2)?; + let evidence_json = row.get::<_, String>(3)?; + Ok((session_path, file_size, file_mtime_ms, evidence_json)) + })?; + let mut cache = BTreeMap::new(); + for row in rows { + let (session_path, file_size, file_mtime_ms, evidence_json) = row?; + let evidence = serde_json::from_str(&evidence_json).unwrap_or_default(); + cache.insert( + session_path, + CachedEvidence { + file_size, + file_mtime_ms, + evidence, + }, + ); + } + Ok(cache) +} + +fn strict_sessions(strict: &Catalog) -> Vec<&SessionSummary> { + strict + .projects + .iter() + .flat_map(|project| project.sessions.iter()) + .collect() +} + +fn regular_file_fingerprint(path: &Path) -> Option<(u64, i64)> { + let meta = std::fs::symlink_metadata(path).ok()?; + if meta.file_type().is_symlink() || !meta.file_type().is_file() { + return None; + } + let mtime = meta + .modified() + .ok() + .and_then(system_time_millis) + .unwrap_or(0); + Some((meta.len(), mtime)) +} + +fn system_time_millis(value: SystemTime) -> Option { + let duration = value.duration_since(UNIX_EPOCH).ok()?; + i64::try_from(duration.as_millis()).ok() +} + +fn unix_time_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(0) +} + +fn normalized_unique_roots(roots: &[PathBuf]) -> Vec { + let mut seen = BTreeSet::new(); + let mut output = Vec::new(); + for root in roots { + let normalized = std::fs::canonicalize(root).unwrap_or_else(|_| normalize_path(root)); + let key = normalize_local_path(&normalized); + if seen.insert(key) { + output.push(normalized); + } + } + output +} + +fn normalize_local_path(path: &Path) -> String { + normalize_path(path).to_string_lossy().into_owned() +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut output = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + output.pop(); + } + other => output.push(other.as_os_str()), + } + } + output +} + +fn path_starts_with(path: &Path, base: &Path) -> bool { + normalize_path(path).starts_with(normalize_path(base)) +} + +fn string_path_starts_with(path: &str, base: &str) -> bool { + if path == base { + return true; + } + path.strip_prefix(base) + .is_some_and(|rest| rest.starts_with(std::path::MAIN_SEPARATOR)) +} + +fn is_noisy_relative_path(path: &Path) -> bool { + const NOISY: &[&str] = &[ + ".cache", + ".cargo", + ".rustup", + "build", + "dist", + "dl", + "feeds", + "node_modules", + "openwrt-sdk", + "sdk", + "target", + "vendor", + ]; + path.components().any(|component| { + let value = component.as_os_str().to_string_lossy().to_ascii_lowercase(); + NOISY.iter().any(|candidate| { + value == *candidate || (candidate.ends_with("sdk") && value.contains("sdk")) + }) + }) +} + +fn should_prune_subtree(path: &Path) -> bool { + const PRUNED: &[&str] = &[ + ".cache", + ".cargo", + ".rustup", + ".venv", + "__pycache__", + "bin", + "build", + "build_dir", + "dl", + "node_modules", + "staging_dir", + "target", + "tmp", + ]; + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase()) + .is_some_and(|name| PRUNED.contains(&name.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_ID: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "comon-catalog-{label}-{}-{}", + std::process::id(), + TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("create temp directory"); + path + } + + #[test] + fn project_mode_round_trips_and_cycles() { + let mut mode = ProjectViewMode::Strict; + for expected in [ + ProjectViewMode::Deep, + ProjectViewMode::Full, + ProjectViewMode::Custom, + ProjectViewMode::Strict, + ] { + mode = mode.toggled(); + assert_eq!(mode, expected); + assert_eq!(ProjectViewMode::from_store(mode.store_value()), Some(mode)); + } + } + + #[test] + fn remote_normalization_groups_ssh_and_https_without_credentials() { + assert_eq!( + normalize_remote_url("git@GitHub.com:ssh4net/CoMon.git"), + Some("github.com/ssh4net/CoMon".to_string()) + ); + assert_eq!( + normalize_remote_url("https://token@example.com/ssh4net/CoMon.git?x=1"), + Some("example.com/ssh4net/CoMon".to_string()) + ); + } + + #[test] + fn discovery_honors_depth_and_worktree_git_files() { + let root = temp_dir("depth"); + let direct = root.join("direct"); + let nested = root.join("group/nested"); + std::fs::create_dir_all(direct.join(".git")).expect("direct git"); + std::fs::create_dir_all(nested.parent().expect("parent")).expect("nested parent"); + std::fs::write(nested.parent().expect("parent").join("unused"), "x").expect("write"); + std::fs::create_dir_all(&nested).expect("nested"); + std::fs::write(nested.join(".git"), "gitdir: ../meta/worktrees/nested\n") + .expect("worktree marker"); + + let mut found = Vec::new(); + let mut dirs = 0; + let mut truncated = false; + discover_repositories( + &root, + 1, + 100, + &[], + &mut dirs, + &mut truncated, + &mut found, + &AtomicBool::new(false), + |_, _| {}, + ) + .expect("depth one scan"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].display_path, direct.display().to_string()); + + found.clear(); + dirs = 0; + discover_repositories( + &root, + 2, + 100, + &[], + &mut dirs, + &mut truncated, + &mut found, + &AtomicBool::new(false), + |_, _| {}, + ) + .expect("depth two scan"); + assert_eq!(found.len(), 2); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn discovery_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + let root = temp_dir("symlink"); + let outside = temp_dir("outside"); + std::fs::create_dir_all(outside.join("repo/.git")).expect("outside repo"); + symlink(&outside, root.join("linked")).expect("create symlink"); + let mut found = Vec::new(); + let mut dirs = 0; + let mut truncated = false; + discover_repositories( + &root, + 3, + 100, + &[], + &mut dirs, + &mut truncated, + &mut found, + &AtomicBool::new(false), + |_, _| {}, + ) + .expect("scan"); + assert!(found.is_empty()); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(outside); + } + + #[test] + fn structured_evidence_ignores_prose_and_outputs() { + let root = temp_dir("evidence"); + let session = root.join("session.jsonl"); + let body = format!( + "{}\n{}\n{}\n", + serde_json::json!({ + "type": "response_item", + "payload": {"type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "/secret/prose/project" + }]} + }), + serde_json::json!({ + "type": "response_item", + "payload": {"type": "function_call_output", "output": "/secret/output/project"} + }), + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({ + "workdir": "/safe/project", + "cmd": "sed -n 1,10p src/main.rs" + }).to_string() + } + }) + ); + std::fs::write(&session, body).expect("write session"); + let evidence = extract_structured_evidence(&session).expect("evidence"); + assert!(evidence.iter().any(|item| item.path == "/safe/project")); + assert!(evidence + .iter() + .any(|item| item.path == "/safe/project/src/main.rs")); + assert!(!evidence.iter().any(|item| item.path == "/safe/project/sed")); + assert!(!evidence.iter().any(|item| item.path.contains("secret"))); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn command_path_filter_rejects_plain_words_but_keeps_common_paths() { + assert!(!looks_like_command_path("cargo")); + assert!(!looks_like_command_path("test")); + assert!(!looks_like_command_path("--manifest-path")); + assert!(looks_like_command_path("src/main.rs")); + assert!(looks_like_command_path("Cargo.toml")); + assert!(looks_like_command_path("../shared")); + assert!(looks_like_command_path("C:\\src\\main.rs")); + } + + #[test] + fn catalog_scan_links_structured_workdir_and_round_trips_sqlite() { + let root = temp_dir("round-trip"); + let project = root.join("project"); + let sessions = root.join("sessions/2026/07/29"); + std::fs::create_dir_all(project.join(".git")).expect("project git dir"); + std::fs::write( + project.join(".git/config"), + "[remote \"origin\"]\n\turl = git@example.com:team/project.git\n", + ) + .expect("git config"); + std::fs::create_dir_all(&sessions).expect("sessions"); + let session_path = sessions.join("session.jsonl"); + let body = format!( + "{}\n{}\n", + serde_json::json!({ + "type": "session_meta", + "payload": { + "id": "session", + "timestamp": "2026-07-29T10:00:00Z", + "cwd": root.join("launcher") + } + }), + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({ + "workdir": project, + "cmd": "cargo test" + }).to_string() + } + }) + ); + std::fs::write(&session_path, body).expect("session file"); + let sessions_root = root.join("sessions"); + let strict = crate::read::scan::build_catalog(&sessions_root).expect("strict catalog"); + let config = CatalogScanConfig { + sessions_dir: sessions_root.clone(), + search_roots: vec![root.clone()], + excluded_roots: vec![sessions_root], + max_depth: 2, + max_candidates: 100, + progress_interval_ms: 25, + cache_db_path: root.join("cache/comon.db"), + cancelled: Arc::new(AtomicBool::new(false)), + }; + let snapshot = scan_project_catalog(&config, &strict, |_| {}).expect("catalog scan"); + assert_eq!(snapshot.checkouts.len(), 1); + assert_eq!( + snapshot.checkouts[0].stable_id, + "remote:example.com/team/project" + ); + assert!(snapshot.checkouts[0].deep_eligible); + assert_eq!(snapshot.links.len(), 1); + assert_eq!(snapshot.links[0].confidence, 80); + + let cached = load_catalog_cache(&config.cache_db_path) + .expect("load cache") + .expect("cached snapshot"); + assert_eq!(cached.checkouts.len(), 1); + assert_eq!(cached.links.len(), 1); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src/read/mod.rs b/src/read/mod.rs index 46f545b..4174b01 100644 --- a/src/read/mod.rs +++ b/src/read/mod.rs @@ -1,4 +1,5 @@ -mod scan; +pub(crate) mod catalog; +pub(crate) mod scan; pub(crate) mod tui; use anyhow::{Context, Result}; diff --git a/src/read/scan.rs b/src/read/scan.rs index 2597e90..78e0cf6 100644 --- a/src/read/scan.rs +++ b/src/read/scan.rs @@ -24,8 +24,15 @@ pub(crate) struct Catalog { #[derive(Debug, Clone)] pub(crate) struct ProjectRecord { + pub(crate) stable_id: String, + pub(crate) logical_name: String, pub(crate) display_path: String, + pub(crate) checkouts: Vec, pub(crate) sessions: Vec, + pub(crate) owner_session_count: usize, + pub(crate) confidence: u8, + pub(crate) source_flags: u32, + pub(crate) missing: bool, } #[derive(Debug, Clone)] @@ -133,9 +140,24 @@ fn finish_project_builders(grouped: BTreeMap) -> Vec".to_string()); + let stable_id = format!("path:{}", normalize_project_key(&display_path)); + let logical_name = Path::new(&display_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(&display_path) + .to_string(); + let owner_session_count = builder.sessions.len(); projects.push(ProjectRecord { + stable_id, + logical_name, + checkouts: vec![display_path.clone()], display_path, sessions: builder.sessions, + owner_session_count, + confidence: 100, + source_flags: crate::read::catalog::SOURCE_OWNER, + missing: false, }); } @@ -200,14 +222,13 @@ pub(crate) fn load_session_detail(path: &Path) -> Result { "function_call_output" => { detail.tool_outputs += 1; } - "reasoning" => { + "reasoning" if payload .get("encrypted_content") .and_then(Value::as_str) - .is_some() - { - detail.reasoning_encrypted = true; - } + .is_some() => + { + detail.reasoning_encrypted = true; } _ => {} } diff --git a/src/read/tui.rs b/src/read/tui.rs index 73fd574..7d04b13 100644 --- a/src/read/tui.rs +++ b/src/read/tui.rs @@ -1,9 +1,14 @@ use crate::locale::{DisplayFormatter, DisplayStyle}; +use crate::read::catalog::{ + CatalogProgress, CatalogScanPhase, CatalogSnapshot, ProjectViewMode, SOURCE_NOISY_TREE, +}; use crate::read::scan::{ load_session_detail, truncate_single_line, Catalog, ProjectRecord, SessionDetail, SessionSummary, }; -use crate::usage::{format_compact_kmb, format_duration, LocalUsageSnapshot}; +use crate::usage::{ + format_compact_kmb, format_duration, normalize_project_key, LocalUsageSnapshot, +}; use anyhow::Result; use chrono::{DateTime, Local}; use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; @@ -14,7 +19,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthStr; @@ -42,7 +47,16 @@ struct UiLayout { #[derive(Debug)] pub(crate) struct BrowserState { + strict_catalog: Catalog, catalog: Catalog, + project_mode: ProjectViewMode, + deep_depth: u8, + selected_projects: BTreeSet, + explicitly_excluded_projects: BTreeSet, + expanded_remote_groups: BTreeSet, + discovery: Option, + scan_progress: Option, + scan_error: Option, view: ViewMode, project_state: ListState, session_state: ListState, @@ -76,7 +90,16 @@ impl BrowserState { )); Self { + strict_catalog: catalog.clone(), catalog, + project_mode: ProjectViewMode::Strict, + deep_depth: crate::read::catalog::DEFAULT_DEEP_DEPTH, + selected_projects: BTreeSet::new(), + explicitly_excluded_projects: BTreeSet::new(), + expanded_remote_groups: BTreeSet::new(), + discovery: None, + scan_progress: None, + scan_error: None, view: ViewMode::Projects, project_state, session_state, @@ -87,6 +110,159 @@ impl BrowserState { } } + pub(crate) fn restore_project_state( + &mut self, + mode: ProjectViewMode, + depth: u8, + selected: BTreeSet, + excluded: BTreeSet, + expanded: BTreeSet, + ) { + self.project_mode = mode; + self.deep_depth = depth.clamp(1, crate::read::catalog::MAX_DEEP_DEPTH); + self.selected_projects = selected; + self.explicitly_excluded_projects = excluded; + self.expanded_remote_groups = expanded; + self.seed_custom_from_strict(); + self.rebuild_visible_catalog(); + } + + pub(crate) fn project_mode(&self) -> ProjectViewMode { + self.project_mode + } + + pub(crate) fn deep_depth(&self) -> u8 { + self.deep_depth + } + + pub(crate) fn selected_projects(&self) -> &BTreeSet { + &self.selected_projects + } + + pub(crate) fn explicitly_excluded_projects(&self) -> &BTreeSet { + &self.explicitly_excluded_projects + } + + pub(crate) fn expanded_remote_groups(&self) -> &BTreeSet { + &self.expanded_remote_groups + } + + pub(crate) fn strict_catalog_clone(&self) -> Catalog { + self.strict_catalog.clone() + } + + pub(crate) fn set_project_mode(&mut self, mode: ProjectViewMode) -> bool { + if self.project_mode == mode { + return false; + } + self.project_mode = mode; + self.rebuild_visible_catalog(); + true + } + + pub(crate) fn cycle_project_mode(&mut self) -> bool { + self.set_project_mode(self.project_mode.toggled()) + } + + pub(crate) fn change_deep_depth(&mut self, delta: i8) -> bool { + let current = i16::from(self.deep_depth); + let next = (current + i16::from(delta)) + .clamp(1, i16::from(crate::read::catalog::MAX_DEEP_DEPTH)) as u8; + if next == self.deep_depth { + return false; + } + self.deep_depth = next; + if self.project_mode == ProjectViewMode::Deep { + self.rebuild_visible_catalog(); + } + true + } + + pub(crate) fn apply_catalog_snapshot(&mut self, strict: Catalog, snapshot: CatalogSnapshot) { + self.strict_catalog = strict; + self.discovery = Some(snapshot); + self.scan_progress = None; + self.scan_error = None; + self.seed_custom_from_strict(); + self.rebuild_visible_catalog(); + } + + pub(crate) fn set_catalog_progress(&mut self, progress: CatalogProgress) { + self.scan_progress = Some(progress); + self.scan_error = None; + } + + pub(crate) fn set_catalog_error(&mut self, error: String) { + self.scan_progress = None; + self.scan_error = Some(error); + } + + fn seed_custom_from_strict(&mut self) { + for project in &self.strict_catalog.projects { + let stable_id = self + .discovery + .as_ref() + .and_then(|snapshot| { + snapshot.checkouts.iter().find(|checkout| { + checkout.checkout_key == normalize_project_key(&project.display_path) + }) + }) + .map(|checkout| checkout.stable_id.as_str()) + .unwrap_or(&project.stable_id); + if !self.explicitly_excluded_projects.contains(stable_id) { + self.selected_projects.insert(stable_id.to_string()); + } + } + } + + fn toggle_selected_project(&mut self, index: usize) -> bool { + if self.project_mode != ProjectViewMode::Full { + return false; + } + let Some(stable_id) = self + .catalog + .projects + .get(index) + .map(|project| project.stable_id.clone()) + else { + return false; + }; + if self.selected_projects.remove(&stable_id) { + self.explicitly_excluded_projects.insert(stable_id); + } else { + self.explicitly_excluded_projects.remove(&stable_id); + self.selected_projects.insert(stable_id); + } + true + } + + fn rebuild_visible_catalog(&mut self) { + let selected_id = self + .selected_project() + .map(|project| project.stable_id.clone()); + self.catalog = match self.project_mode { + ProjectViewMode::Strict => self.strict_catalog.clone(), + mode => build_discovery_catalog( + &self.strict_catalog, + self.discovery.as_ref(), + mode, + self.deep_depth, + &self.selected_projects, + ), + }; + let next_index = selected_id + .as_deref() + .and_then(|stable_id| { + self.catalog + .projects + .iter() + .position(|project| project.stable_id == stable_id) + }) + .or_else(|| (!self.catalog.projects.is_empty()).then_some(0)); + self.project_state.select(next_index); + sync_session_selection(self); + } + fn selected_project_index(&self) -> Option { self.project_state.selected() } @@ -138,6 +314,188 @@ impl BrowserState { } } +#[derive(Default)] +struct LogicalProjectBuilder { + stable_id: String, + logical_name: String, + checkouts: Vec, + checkout_keys: BTreeSet, + owner_sessions: Vec, + related_sessions: Vec, + confidence: u8, + source_flags: u32, + missing: bool, +} + +fn build_discovery_catalog( + strict: &Catalog, + discovery: Option<&CatalogSnapshot>, + mode: ProjectViewMode, + deep_depth: u8, + selected_projects: &BTreeSet, +) -> Catalog { + let Some(discovery) = discovery else { + return strict.clone(); + }; + let mut sessions_by_path = BTreeMap::new(); + let mut strict_by_checkout = BTreeMap::new(); + for (project_index, project) in strict.projects.iter().enumerate() { + strict_by_checkout.insert(normalize_project_key(&project.display_path), project_index); + for session in &project.sessions { + sessions_by_path.insert( + session.file_path.to_string_lossy().into_owned(), + session.clone(), + ); + } + } + + let mut groups: BTreeMap = BTreeMap::new(); + let mut checkout_to_group = BTreeMap::new(); + for checkout in &discovery.checkouts { + let include = match mode { + ProjectViewMode::Strict => false, + ProjectViewMode::Deep => { + !checkout.missing + && checkout.discovery_depth <= deep_depth + && (checkout.deep_eligible || selected_projects.contains(&checkout.stable_id)) + } + ProjectViewMode::Full => true, + ProjectViewMode::Custom => selected_projects.contains(&checkout.stable_id), + }; + if !include { + continue; + } + let group = + groups + .entry(checkout.stable_id.clone()) + .or_insert_with(|| LogicalProjectBuilder { + stable_id: checkout.stable_id.clone(), + logical_name: checkout.logical_name.clone(), + missing: true, + ..LogicalProjectBuilder::default() + }); + group.checkouts.push(checkout.display_path.clone()); + group.checkout_keys.insert(checkout.checkout_key.clone()); + group.confidence = group.confidence.max(checkout.confidence); + group.source_flags |= checkout.source_flags; + group.missing &= checkout.missing; + checkout_to_group.insert(checkout.checkout_key.clone(), checkout.stable_id.clone()); + } + + let mut consumed_strict = BTreeSet::new(); + for (stable_id, group) in groups.iter_mut() { + for checkout_key in &group.checkout_keys { + let Some(index) = strict_by_checkout.get(checkout_key).copied() else { + continue; + }; + consumed_strict.insert(index); + group + .owner_sessions + .extend(strict.projects[index].sessions.iter().cloned()); + group.source_flags |= crate::read::catalog::SOURCE_OWNER; + group.confidence = 100; + } + if selected_projects.contains(stable_id) { + group.source_flags |= crate::read::catalog::SOURCE_USER_SELECTED; + } + } + + for link in &discovery.links { + let Some(stable_id) = checkout_to_group.get(&link.checkout_key) else { + continue; + }; + let Some(session) = sessions_by_path.get(&link.session_path) else { + continue; + }; + let Some(group) = groups.get_mut(stable_id) else { + continue; + }; + if group + .owner_sessions + .iter() + .any(|owner| owner.file_path == session.file_path) + || group + .related_sessions + .iter() + .any(|related| related.file_path == session.file_path) + { + continue; + } + group.related_sessions.push(session.clone()); + } + + let mut projects = groups + .into_values() + .map(|mut group| { + group.checkouts.sort(); + group.checkouts.dedup(); + group.owner_sessions.sort_by(session_order); + group.related_sessions.sort_by(session_order); + let owner_session_count = group.owner_sessions.len(); + let mut sessions = group.owner_sessions; + sessions.extend(group.related_sessions); + let display_path = group + .checkouts + .first() + .cloned() + .unwrap_or_else(|| group.logical_name.clone()); + ProjectRecord { + stable_id: group.stable_id, + logical_name: group.logical_name, + display_path, + checkouts: group.checkouts, + sessions, + owner_session_count, + confidence: group.confidence, + source_flags: group.source_flags, + missing: group.missing, + } + }) + .collect::>(); + + for (index, project) in strict.projects.iter().enumerate() { + if consumed_strict.contains(&index) { + continue; + } + let include = match mode { + ProjectViewMode::Strict => true, + ProjectViewMode::Deep | ProjectViewMode::Full => true, + ProjectViewMode::Custom => { + let checkout_key = normalize_project_key(&project.display_path); + let effective_id = discovery + .checkouts + .iter() + .find(|checkout| checkout.checkout_key == checkout_key) + .map(|checkout| checkout.stable_id.as_str()) + .unwrap_or(&project.stable_id); + selected_projects.contains(effective_id) + } + }; + if include { + projects.push(project.clone()); + } + } + projects.sort_by(|left, right| { + left.logical_name + .to_ascii_lowercase() + .cmp(&right.logical_name.to_ascii_lowercase()) + .then_with(|| left.display_path.cmp(&right.display_path)) + }); + Catalog { + sessions_dir: strict.sessions_dir.clone(), + projects, + files_scanned: strict.files_scanned, + files_skipped: strict.files_skipped, + } +} + +fn session_order(left: &SessionSummary, right: &SessionSummary) -> std::cmp::Ordering { + right + .started_at_sort_key_ms + .cmp(&left.started_at_sort_key_ms) + .then_with(|| left.file_path.cmp(&right.file_path)) +} + pub(crate) fn handle_event(state: &mut BrowserState, event: Event) -> Result { match event { Event::Key(key) => { @@ -154,6 +512,20 @@ pub(crate) fn handle_event(state: &mut BrowserState, event: Event) -> Result bool { match code { + KeyCode::Char('p') | KeyCode::Char('P') if state.view == ViewMode::Projects => { + return state.cycle_project_mode(); + } + KeyCode::Char(' ') if state.view == ViewMode::Projects => { + return state + .selected_project_index() + .is_some_and(|index| state.toggle_selected_project(index)); + } + KeyCode::Char('+') | KeyCode::Char('=') if state.view == ViewMode::Projects => { + return state.change_deep_depth(1); + } + KeyCode::Char('-') if state.view == ViewMode::Projects => { + return state.change_deep_depth(-1); + } KeyCode::Esc | KeyCode::Backspace | KeyCode::Left if state.view == ViewMode::Sessions => { close_selected_project(state); return true; @@ -216,6 +588,14 @@ fn handle_mouse_event(state: &mut BrowserState, mouse: MouseEvent) -> bool { let now = Instant::now(); if rect_contains(state.layout.project_list_area, mouse.column, mouse.row) { if let Some(index) = click_project_row(state, mouse.row) { + let content = inner_rect(state.layout.project_list_area); + if state.project_mode == ProjectViewMode::Full + && mouse.column >= content.x.saturating_add(3) + && mouse.column < content.x.saturating_add(7) + { + state.last_click = None; + return state.toggle_selected_project(index); + } if state.register_click(BrowserClickTarget::Project(index), now) { open_selected_project(state); } @@ -416,8 +796,12 @@ fn render_header( ) { let line_area = header_line_area(area); let controls = history_style_controls_area(area); + let controls_gap = if controls.width > 0 { 2 } else { 0 }; let content_area = Rect { - width: line_area.width.saturating_sub(controls.width), + width: line_area + .width + .saturating_sub(controls.width) + .saturating_sub(controls_gap), ..line_area }; let summary = format!( @@ -470,15 +854,34 @@ fn header_line_area(area: Rect) -> Rect { } pub(crate) fn history_style_controls_area(area: Rect) -> Rect { - const WIDTH: u16 = 30; let line = header_line_area(area); - if line.width < WIDTH { + let width = if line.width >= 68 { + 68 + } else if line.width >= 30 { + 30 + } else { + 0 + }; + if width == 0 { return Rect::default(); } Rect { - x: line.x.saturating_add(line.width - WIDTH), + x: line.x.saturating_add(line.width - width), y: line.y, - width: WIDTH, + width, + height: 1, + } +} + +pub(crate) fn history_depth_controls_area(area: Rect) -> Rect { + let controls = history_style_controls_area(area); + if controls.width < 68 { + return Rect::default(); + } + Rect { + x: controls.x.saturating_add(18), + y: controls.y.saturating_add(1), + width: 16, height: 1, } } @@ -500,7 +903,33 @@ fn render_projects_view( .catalog .projects .iter() - .map(|project| ListItem::new(Line::from(project.display_path.clone()))) + .map(|project| { + let checkbox = if state.project_mode == ProjectViewMode::Full { + if state.selected_projects.contains(&project.stable_id) { + "[x] " + } else { + "[ ] " + } + } else { + "" + }; + let checkout_count = if project.checkouts.len() > 1 { + format!(" ({})", formatter.format_usize(project.checkouts.len())) + } else { + String::new() + }; + let marker = if project.missing { + " MISSING" + } else if project.source_flags & SOURCE_NOISY_TREE != 0 && project.confidence < 70 { + " LOW" + } else { + "" + }; + ListItem::new(Line::from(format!( + "{checkbox}{}{checkout_count}{marker}", + project.logical_name + ))) + }) .collect::>(); let list = List::new(items) @@ -553,9 +982,14 @@ fn render_sessions_view( let items = project .map(|project| { std::iter::once(ListItem::new(Line::from(".."))) - .chain(project.sessions.iter().map(|session| { + .chain(project.sessions.iter().enumerate().map(|(index, session)| { + let relation = if index < project.owner_session_count { + "O" + } else { + "R" + }; let label = format!( - "{} {}", + "{relation} {} {}", session_started_label(session, formatter), truncate_single_line(&session.title, 64) ); @@ -618,16 +1052,31 @@ fn render_project_detail( .map(|session| session_started_label(session, formatter)) .unwrap_or_else(|| "--".to_string()); - let usage_summary = - usage.and_then(|snapshot| snapshot.project_usage_for_path(&project.display_path)); - let expected_files = project.sessions.len(); - let indexed_files = usage_summary - .map(|summary| summary.indexed_files) - .unwrap_or(0); + let mut usage_totals = (0i64, 0i64, 0i64, 0i64, 0usize); + let mut usage_paths = BTreeSet::new(); + if let Some(snapshot) = usage { + for path in &project.checkouts { + let key = normalize_project_key(path); + if !usage_paths.insert(key) { + continue; + } + if let Some(summary) = snapshot.project_usage_for_path(path) { + usage_totals.0 = usage_totals.0.saturating_add(summary.total_tokens); + usage_totals.1 = usage_totals.1.saturating_add(summary.cached_input_tokens); + usage_totals.2 = usage_totals.2.saturating_add(summary.agent_time_ms); + usage_totals.3 = usage_totals.3.saturating_add(summary.agent_runs); + usage_totals.4 = usage_totals.4.saturating_add(summary.indexed_files); + } + } + } + let expected_files = project.owner_session_count; + let indexed_files = usage_totals.4; let scan_complete = usage.is_some_and(|snapshot| snapshot.scan_pending_files == 0); let usage_ready = - usage_error.is_none() && usage_summary.is_some() && indexed_files >= expected_files; - let project_scan = if usage_error.is_some() { + usage_error.is_none() && expected_files > 0 && indexed_files >= expected_files; + let project_scan = if expected_files == 0 { + "NO OWNER DATA".to_string() + } else if usage_error.is_some() { "ERROR".to_string() } else if usage_ready { "READY".to_string() @@ -642,40 +1091,62 @@ fn render_project_detail( }; let (consumed_tokens, activity) = if usage_ready { - let summary = usage_summary.expect("usage summary checked above"); - let non_cached = summary - .total_tokens - .saturating_sub(summary.cached_input_tokens) - .max(0); + let non_cached = usage_totals.0.saturating_sub(usage_totals.1).max(0); ( format!( "{} total / {} non-cached", - format_project_count(summary.total_tokens, formatter), + format_project_count(usage_totals.0, formatter), format_project_count(non_cached, formatter) ), format!( "{} runs / {}", - formatter.format_count(summary.agent_runs), - format_duration(summary.agent_time_ms) + formatter.format_count(usage_totals.3), + format_duration(usage_totals.2) ), ) } else { ("--".to_string(), "--".to_string()) }; + let discovery_label = if project.missing { + "MISSING".to_string() + } else if project.source_flags & crate::read::catalog::SOURCE_OWNER != 0 { + format!("OWNER | confidence {}", project.confidence) + } else if project.confidence >= 70 { + format!("STRONG | confidence {}", project.confidence) + } else { + format!("LOW | confidence {}", project.confidence) + }; let mut lines = vec![ Line::from(vec![ - Span::styled("PATH", Style::default().fg(Color::Gray)), + Span::styled("PROJECT", Style::default().fg(Color::Gray)), Span::raw(" "), Span::styled( - project.display_path.clone(), + project.logical_name.clone(), Style::default().add_modifier(Modifier::BOLD), ), ]), Line::from(vec![ - Span::styled("SESSIONS", Style::default().fg(Color::Gray)), + Span::styled("OWNER_SESSIONS", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(formatter.format_usize(project.sessions.len())), + Span::raw(formatter.format_usize(project.owner_session_count)), + ]), + Line::from(vec![ + Span::styled("RELATED_SESSIONS", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::raw( + formatter.format_usize( + project + .sessions + .len() + .saturating_sub(project.owner_session_count), + ), + ), + ]), + Line::from(vec![ + Span::styled("DISCOVERY", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::raw(discovery_label), ]), Line::from(vec![ Span::styled("LATEST", Style::default().fg(Color::Gray)), @@ -703,13 +1174,27 @@ fn render_project_detail( ]), Line::from(""), Line::from(Span::styled( - "RECENT", + "CHECKOUTS", Style::default() .fg(Color::Gray) .add_modifier(Modifier::BOLD), )), ]; + for checkout in &project.checkouts { + lines.push(Line::from(format!("- {checkout}"))); + } + + lines.extend([ + Line::from(""), + Line::from(Span::styled( + "RECENT", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + )), + ]); + for session in project.sessions.iter().take(10) { lines.push(Line::from(format!( "{} {}", @@ -903,7 +1388,7 @@ fn render_footer( ) { let base = match state.view { ViewMode::Projects => { - "Projects: up/down or wheel, double-click/enter open, s/F2 switch, r/F5 rescan, q quit" + "Projects: [p] mode, [+/-] depth, [space] select in FULL, wheel, double-click/enter open, r/F5 rescan" } ViewMode::Sessions => { "Sessions: up/down or wheel, double-click .. / backspace / left / esc back, q quit" @@ -915,13 +1400,54 @@ fn render_footer( .map(|text| truncate_single_line(text, 100)) .unwrap_or_default(); let style = format!("style [n]: {}", formatter.style_label()); - let line = if error.is_empty() { + let catalog_status = if let Some(progress) = &state.scan_progress { + let phase = match progress.phase { + CatalogScanPhase::Repositories => "REPO_SCAN", + CatalogScanPhase::Sessions => "PROJECT_SCAN", + CatalogScanPhase::Saving => "CATALOG_SAVE", + }; + if progress.total > 0 { + format!( + "{phase} {}/{} ({} projects)", + progress.completed, progress.total, progress.projects + ) + } else { + format!( + "{phase} {} dirs ({} projects)", + progress.completed, progress.projects + ) + } + } else if let Some(scan_error) = &state.scan_error { + format!( + "PROJECT_SCAN ERROR: {}", + truncate_single_line(scan_error, 80) + ) + } else if state.discovery.as_ref().is_some_and(|snapshot| { + snapshot.sessions_total > 0 && snapshot.sessions_scanned < snapshot.sessions_total + }) { + let snapshot = state.discovery.as_ref().expect("snapshot checked above"); + format!( + "PROJECT_INDEX {}/{}", + snapshot.sessions_scanned, snapshot.sessions_total + ) + } else if state + .discovery + .as_ref() + .is_some_and(|snapshot| snapshot.truncated) + { + "FULL SCAN REACHED DIRECTORY LIMIT".to_string() + } else if state.project_mode == ProjectViewMode::Deep { + format!("DEEP depth {}", state.deep_depth) + } else { + String::new() + }; + let line = if error.is_empty() && catalog_status.is_empty() { Line::from(vec![ Span::styled(base, Style::default().fg(Color::Gray)), Span::raw(" "), Span::styled(style, Style::default().fg(Color::Gray)), ]) - } else { + } else if !error.is_empty() { Line::from(vec![ Span::styled(base, Style::default().fg(Color::Gray)), Span::raw(" "), @@ -929,6 +1455,14 @@ fn render_footer( Span::raw(" "), Span::styled(error, Style::default().fg(Color::Red)), ]) + } else { + Line::from(vec![ + Span::styled(base, Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled(style, Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled(catalog_status, Style::default().fg(Color::Cyan)), + ]) }; frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), area); } @@ -960,14 +1494,54 @@ fn rect_contains(area: Rect, column: u16, row: u16) -> bool { #[cfg(test)] mod tests { use super::{ - header_line_area, history_style_controls_area, BrowserClickTarget, BrowserState, - DOUBLE_CLICK_WINDOW, + build_discovery_catalog, header_line_area, history_depth_controls_area, + history_style_controls_area, BrowserClickTarget, BrowserState, DOUBLE_CLICK_WINDOW, }; - use crate::read::scan::Catalog; + use crate::read::catalog::{ + CatalogSnapshot, ProjectCheckout, ProjectViewMode, SessionProjectLink, SOURCE_REPOSITORY, + SOURCE_WORKDIR, + }; + use crate::read::scan::{Catalog, ProjectRecord, SessionSummary}; use ratatui::layout::Rect; + use std::collections::BTreeSet; use std::path::PathBuf; use std::time::{Duration, Instant}; + fn session(path: &str, cwd: &str) -> SessionSummary { + SessionSummary { + file_path: PathBuf::from(path), + session_id: path.to_string(), + cwd: cwd.to_string(), + title: path.to_string(), + started_at_raw: None, + started_at_label: "--".to_string(), + started_at_sort_key_ms: 0, + git_branch: None, + git_commit: None, + repo_url: None, + model_provider: None, + model: None, + } + } + + fn strict_project(path: &str, session: SessionSummary) -> ProjectRecord { + ProjectRecord { + stable_id: format!("path:{path}"), + logical_name: PathBuf::from(path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(path) + .to_string(), + display_path: path.to_string(), + checkouts: vec![path.to_string()], + sessions: vec![session], + owner_session_count: 1, + confidence: 100, + source_flags: crate::read::catalog::SOURCE_OWNER, + missing: false, + } + } + #[test] fn header_line_has_one_blank_row_above_and_below() { assert_eq!( @@ -980,7 +1554,11 @@ mod tests { fn history_style_controls_use_right_side_of_header_line() { assert_eq!( history_style_controls_area(Rect::new(2, 1, 100, 3)), - Rect::new(72, 2, 30, 1) + Rect::new(34, 2, 68, 1) + ); + assert_eq!( + history_depth_controls_area(Rect::new(2, 1, 100, 3)), + Rect::new(52, 3, 16, 1) ); assert_eq!( history_style_controls_area(Rect::new(2, 1, 20, 3)), @@ -1005,4 +1583,103 @@ mod tests { now + DOUBLE_CLICK_WINDOW + Duration::from_millis(1) )); } + + #[test] + fn discovery_modes_preserve_exactly_one_owner_per_strict_session() { + let strict = Catalog { + sessions_dir: PathBuf::from("/sessions"), + projects: vec![ + strict_project("/repo/a", session("/sessions/a.jsonl", "/repo/a")), + strict_project("/launcher", session("/sessions/b.jsonl", "/launcher")), + ], + files_scanned: 2, + files_skipped: 0, + }; + let snapshot = CatalogSnapshot { + checkouts: vec![ProjectCheckout { + stable_id: "remote:example.com/team/a".to_string(), + checkout_key: "/repo/a".to_string(), + display_path: "/repo/a".to_string(), + remote_key: Some("example.com/team/a".to_string()), + logical_name: "team/a".to_string(), + discovery_depth: 1, + source_flags: SOURCE_REPOSITORY | SOURCE_WORKDIR, + confidence: 80, + deep_eligible: true, + first_seen: 1, + last_seen: 1, + missing: false, + }], + links: vec![SessionProjectLink { + session_path: "/sessions/b.jsonl".to_string(), + checkout_key: "/repo/a".to_string(), + evidence_mask: SOURCE_WORKDIR, + evidence_count: 1, + confidence: 80, + }], + ..CatalogSnapshot::default() + }; + for mode in [ProjectViewMode::Deep, ProjectViewMode::Full] { + let catalog = + build_discovery_catalog(&strict, Some(&snapshot), mode, 2, &BTreeSet::new()); + assert_eq!( + catalog + .projects + .iter() + .map(|project| project.owner_session_count) + .sum::(), + 2 + ); + let logical = catalog + .projects + .iter() + .find(|project| project.stable_id == "remote:example.com/team/a") + .expect("logical project"); + assert_eq!(logical.owner_session_count, 1); + assert_eq!(logical.sessions.len(), 2); + } + } + + #[test] + fn custom_selection_respects_explicit_remote_exclusion() { + let strict = Catalog { + sessions_dir: PathBuf::from("/sessions"), + projects: vec![strict_project( + "/repo/a", + session("/sessions/a.jsonl", "/repo/a"), + )], + files_scanned: 1, + files_skipped: 0, + }; + let snapshot = CatalogSnapshot { + checkouts: vec![ProjectCheckout { + stable_id: "remote:example.com/team/a".to_string(), + checkout_key: "/repo/a".to_string(), + display_path: "/repo/a".to_string(), + remote_key: Some("example.com/team/a".to_string()), + logical_name: "team/a".to_string(), + discovery_depth: 1, + source_flags: SOURCE_REPOSITORY | SOURCE_WORKDIR, + confidence: 80, + deep_eligible: true, + first_seen: 1, + last_seen: 1, + missing: false, + }], + ..CatalogSnapshot::default() + }; + let mut browser = BrowserState::new(strict.clone()); + browser.restore_project_state( + ProjectViewMode::Custom, + 2, + BTreeSet::new(), + BTreeSet::from(["remote:example.com/team/a".to_string()]), + BTreeSet::new(), + ); + browser.apply_catalog_snapshot(strict, snapshot); + assert!(browser.catalog.projects.is_empty()); + assert!(!browser + .selected_projects + .contains("remote:example.com/team/a")); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3c5e164..104de83 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2750,37 +2750,122 @@ fn control_group_label(label: &'static str) -> Span<'static> { } fn render_history_style_controls(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { - let area = crate::read::tui::history_style_controls_area(area); - if area.width == 0 { + let controls_area = crate::read::tui::history_style_controls_area(area); + if controls_area.width == 0 { return; } - let segments = [ - (" ", None), - (" STYLE ", None), - ( - " CLASS ", - Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), - ), - ( - " SCOMP ", - Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), - ), - ( - " SFULL ", - Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), - ), - ]; - let spans = vec![ - Span::raw(" "), - control_group_label("STYLE"), - pill("CLASS", state.display_style == DisplayStyle::Classic), - pill("SCOMP", state.display_style == DisplayStyle::SystemCompact), - pill("SFULL", state.display_style == DisplayStyle::SystemFull), - ]; - frame.render_widget(Paragraph::new(Line::from(spans)), area); - state - .ui_hit_targets - .extend(right_aligned_targets(area, &segments)); + use crate::read::catalog::ProjectViewMode; + let mode = state.read_browser.project_mode(); + if controls_area.width >= 68 { + let segments = [ + (" PROJECTS ", None), + ( + " STRICT ", + Some(UiClickAction::SetHistoryProjectMode( + ProjectViewMode::Strict, + )), + ), + ( + " DEEP ", + Some(UiClickAction::SetHistoryProjectMode(ProjectViewMode::Deep)), + ), + ( + " FULL ", + Some(UiClickAction::SetHistoryProjectMode(ProjectViewMode::Full)), + ), + ( + " CUSTOM ", + Some(UiClickAction::SetHistoryProjectMode( + ProjectViewMode::Custom, + )), + ), + (" ", None), + (" STYLE ", None), + ( + " CLASS ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), + ), + ( + " SCOMP ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), + ), + ( + " SFULL ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), + ), + ]; + let spans = vec![ + control_group_label("PROJECTS"), + pill("STRICT", mode == ProjectViewMode::Strict), + pill("DEEP ", mode == ProjectViewMode::Deep), + pill("FULL", mode == ProjectViewMode::Full), + pill("CUSTOM", mode == ProjectViewMode::Custom), + Span::raw(" "), + control_group_label("STYLE"), + pill("CLASS", state.display_style == DisplayStyle::Classic), + pill("SCOMP", state.display_style == DisplayStyle::SystemCompact), + pill("SFULL", state.display_style == DisplayStyle::SystemFull), + ]; + frame.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Right), + controls_area, + ); + state + .ui_hit_targets + .extend(right_aligned_targets(controls_area, &segments)); + + if mode == ProjectViewMode::Deep { + let depth_area = crate::read::tui::history_depth_controls_area(area); + let depth = format!(" {} ", state.read_browser.deep_depth()); + let depth_segments = [ + (" DEPTH ", Some(UiClickAction::HistoryDepthWheel)), + (" - ", Some(UiClickAction::DecreaseHistoryDepth)), + (depth.as_str(), Some(UiClickAction::HistoryDepthWheel)), + (" + ", Some(UiClickAction::IncreaseHistoryDepth)), + ]; + let depth_spans = vec![ + control_group_label("DEPTH"), + pill("-", false), + pill(&state.read_browser.deep_depth().to_string(), true), + pill("+", false), + ]; + frame.render_widget(Paragraph::new(Line::from(depth_spans)), depth_area); + state + .ui_hit_targets + .extend(right_aligned_targets(depth_area, &depth_segments)); + } + } else { + let segments = [ + (" ", None), + (" STYLE ", None), + ( + " CLASS ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), + ), + ( + " SCOMP ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), + ), + ( + " SFULL ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), + ), + ]; + let spans = vec![ + Span::raw(" "), + control_group_label("STYLE"), + pill("CLASS", state.display_style == DisplayStyle::Classic), + pill("SCOMP", state.display_style == DisplayStyle::SystemCompact), + pill("SFULL", state.display_style == DisplayStyle::SystemFull), + ]; + frame.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Right), + controls_area, + ); + state + .ui_hit_targets + .extend(right_aligned_targets(controls_area, &segments)); + } } #[derive(Debug)] @@ -5387,7 +5472,7 @@ fn render_limit_reset_details(frame: &mut Frame<'_>, area: Rect, state: &AppStat Style::default().fg(Color::Gray), )), ]), - Some(credits) if credits.is_empty() => Text::from(Line::from(Span::styled( + Some([]) => Text::from(Line::from(Span::styled( "No reset credits are currently available.", Style::default().fg(Color::Gray), ))),