diff --git a/CLAUDE.md b/CLAUDE.md index f2d18f1..0ba99e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,15 @@ ag -C 3 "pattern" ag -i "pattern" ``` +**CRITICAL: Always put ag options BEFORE the pattern, never after files:** +```bash +# ✅ CORRECT - options before pattern +ag -C 3 "pattern" src/ + +# ❌ WRONG - options after files will fail +ag "pattern" src/ -C 3 +``` + **Use `fd` for finding files:** ```bash # Find files by name pattern @@ -410,6 +419,18 @@ Display visual indicators for file/folder states following `` **UI Layout:** System bar → Main content (folders + breadcrumb panels with smart sizing) → Hotkey legend → Status bar +**Folder List (focus_level == 0):** +- Card-based rendering with inline stats (3 lines per folder) +- Shows folder name, state icon, type, size, file count, and status message +- Out-of-sync folders show detailed breakdown (remote needed, local changes) +- Dynamic title shows counts (total, synced, syncing, paused) + +**Status Bar (context-aware):** +- **Folder view (focus_level == 0)**: Activity feed + device count + - Shows last sync activity with timestamp ("SYNCED file 'example.txt' • 5 sec ago") + - Shows connected device count ("3 devices connected") +- **Breadcrumb view (focus_level > 0)**: Folder/file details + sort mode + filter status + **Key UI features:** - Context-aware hotkey legend (folder view vs breadcrumb view) - Three-state file info toggle (Off/TimestampOnly/TimestampAndSize) @@ -452,8 +473,8 @@ CLI flags: `--debug`, `--vim`, `--config ` - `src/handlers/` - Event handlers: keyboard, api, events - `src/services/` - Background: api (async queue), events (long-polling) - `src/model/` - Pure state (Elm): syncthing, navigation, ui, performance, types -- `src/logic/` - Pure business logic (15 modules): file, folder, formatting, ignore, layout, navigation, path, performance, platform, search, sorting, sync_states, ui, errors -- `src/ui/` - Rendering (13 modules): render, folder_list, breadcrumb, dialogs, icons, legend, search, status_bar, system_bar, out_of_sync_summary, toast, layout +- `src/logic/` - Pure business logic (16 modules): file, folder, folder_card, formatting, ignore, layout, navigation, path, performance, platform, search, sorting, sync_states, ui, errors +- `src/ui/` - Rendering (13 modules): render, folder_list (card-based), breadcrumb, dialogs, icons, legend, search, status_bar (activity feed), system_bar, out_of_sync_summary (filter modal), toast, layout - `src/api.rs`, `src/cache.rs`, `src/config.rs`, `src/utils.rs` - Core utilities **Key patterns:** App initialization loads folders, spawns services. Main event loop (~line 909) processes API responses, keyboard, cache events. Keyboard handler has confirmation dialogs first. @@ -491,6 +512,8 @@ CLI flags: `--debug`, `--vim`, `--config ` ### Event-Driven Cache Invalidation Long-polling `/rest/events` for real-time updates. Granular invalidation (file/dir/folder). Handles LocalIndexUpdated, ItemStarted, ItemFinished. Persistent event ID, auto-recovery. +**Activity Event Deduplication:** Activity events from ItemFinished are deduplicated by timestamp. Only events newer than existing activity are stored, preventing event replay from overwriting fresh data during event stream reconnection. + ### Performance Optimizations Async API service with priority queue, cache-first rendering, sequence-based validation, request deduplication, 300ms idle threshold, 250ms poll timeout (~1-2% CPU idle). @@ -505,7 +528,7 @@ Auto-detects ANSI codes (ESC[ sequences), CP437 encoding, 80-column wrapping, li ## Current State -**569 tests passing**, zero warnings, clean Model/Runtime separation. Full ANSI/CP437 support. Version 0.9.1. +**603 tests passing**, zero warnings, clean Model/Runtime separation. Full ANSI/CP437 support. Version 0.10.0. ## Development Guidelines @@ -543,7 +566,7 @@ Auto-detects ANSI codes (ESC[ sequences), CP437 encoding, 80-column wrapping, li - TDD Approach: Wrote 10 tests first exposing exact bug - Test `test_state_already_connected_before_system_status` revealed root cause - Solution: Simple 1-line fix guided by tests - - Result: All 184 tests pass, bug fixed perfectly on first try + - Result: All tests pass, bug fixed perfectly on first try - **Lesson: TDD saves time and money** - **When Claude forgets to write tests:** - User should immediately call it out @@ -553,7 +576,7 @@ Auto-detects ANSI codes (ESC[ sequences), CP437 encoding, 80-column wrapping, li - Test with real Syncthing Docker instances with large datasets - Pure business logic in `src/logic/` should have comprehensive test coverage - Model state transitions should have tests in corresponding test modules - - Run `cargo test` before committing to ensure all 184+ tests pass + - Run `cargo test` before committing to ensure all 603+ tests pass - Aim for zero compiler warnings (`cargo build` should be clean) - **Test Organization Standards:** - **Keep tests inline** using `#[cfg(test)] mod tests` at the bottom of each module diff --git a/Cargo.toml b/Cargo.toml index 245cd6f..bfa9fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stui" version = "0.10.0" -edition = "2021" +edition = "2024" [dependencies] ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } diff --git a/src/api.rs b/src/api.rs index 86d4900..6a85fdd 100644 --- a/src/api.rs +++ b/src/api.rs @@ -15,6 +15,12 @@ fn log_debug(msg: &str) { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FolderDevice { + #[serde(rename = "deviceID")] + pub device_id: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Folder { pub id: String, @@ -24,6 +30,8 @@ pub struct Folder { pub paused: bool, #[serde(rename = "type")] pub folder_type: String, // "sendonly", "sendreceive", "receiveonly" + #[serde(default)] + pub devices: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -199,6 +207,8 @@ pub struct FolderStatus { #[allow(dead_code)] pub receive_only_changed_symlinks: u64, pub receive_only_total_items: u64, + #[serde(default)] + pub errors: u64, } #[derive(Debug, Clone, Deserialize)] @@ -226,6 +236,25 @@ pub struct ConnectionStats { pub total: ConnectionTotal, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionInfo { + pub connected: bool, + #[allow(dead_code)] + pub address: String, + #[allow(dead_code)] + pub in_bytes_total: u64, + #[allow(dead_code)] + pub out_bytes_total: u64, + #[allow(dead_code)] + pub paused: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ConnectionsResponse { + pub connections: std::collections::HashMap, +} + #[derive(Debug, Clone, Deserialize)] pub struct LastFileInfo { pub at: String, @@ -620,6 +649,25 @@ impl SyncthingClient { Ok(stats) } + /// Get system connections (device connectivity and transfer stats) + pub async fn get_system_connections(&self) -> Result { + let url = format!("{}/rest/system/connections", self.base_url); + let response = self + .client + .get(&url) + .header("X-API-Key", &self.api_key) + .send() + .await + .context("Failed to send connections request")?; + + let connections: ConnectionsResponse = response + .json() + .await + .context("Failed to parse connections response")?; + + Ok(connections) + } + /// Fetch folder statistics to get last updated file per folder /// /// Returns HashMap of folder_id -> (timestamp, filename) @@ -1024,7 +1072,7 @@ mod tests { // Test that get_folder_events method exists with correct signature // We can't easily test async functions in unit tests without tokio runtime, // but we can verify the method exists by calling it in a type-checked way - let client = SyncthingClient { + let _client = SyncthingClient { base_url: "http://localhost:8384".to_string(), api_key: "test-key".to_string(), client: reqwest::Client::new(), @@ -1038,4 +1086,38 @@ mod tests { // This would need tokio runtime to actually call: // let _ = client.get_folder_events(_since, _limit).await; } + + #[test] + fn test_connections_response_parsing() { + let json = r#"{ + "connections": { + "DEVICE123-ABC": { + "connected": true, + "address": "192.168.1.10:22000", + "inBytesTotal": 1234567890, + "outBytesTotal": 9876543210, + "paused": false + }, + "DEVICE456-DEF": { + "connected": false, + "address": "", + "inBytesTotal": 0, + "outBytesTotal": 0, + "paused": false + } + } + }"#; + + let parsed: serde_json::Value = serde_json::from_str(json).unwrap(); + let connections = parsed.get("connections").unwrap(); + let device1 = connections.get("DEVICE123-ABC").unwrap(); + assert_eq!(device1.get("connected").unwrap().as_bool().unwrap(), true); + assert_eq!( + device1.get("address").unwrap().as_str().unwrap(), + "192.168.1.10:22000" + ); + + let device2 = connections.get("DEVICE456-DEF").unwrap(); + assert_eq!(device2.get("connected").unwrap().as_bool().unwrap(), false); + } } diff --git a/src/app/file_ops.rs b/src/app/file_ops.rs index 604e877..2b0afea 100644 --- a/src/app/file_ops.rs +++ b/src/app/file_ops.rs @@ -7,7 +7,7 @@ //! - Open files/directories with external commands //! - Copy paths to clipboard -use crate::{log_debug, logic, services, App}; +use crate::{App, log_debug, logic, services}; use anyhow::Result; use std::time::Instant; @@ -106,7 +106,9 @@ impl App { log_debug(&format!( "DEBUG [invalidate_and_refresh_folder]: Level {}: prefix={:?} loading_browse.contains={}", - idx, level.prefix, self.model.performance.loading_browse.contains(&browse_key) + idx, + level.prefix, + self.model.performance.loading_browse.contains(&browse_key) )); if !self.model.performance.loading_browse.contains(&browse_key) { @@ -190,12 +192,16 @@ impl App { // Get selected item (respects filtered view if active) let selected_idx = match level.selected_index { Some(idx) => idx, - None => return Ok(()), + None => { + return Ok(()); + } }; let item = match level.display_items().get(selected_idx) { Some(item) => item, - None => return Ok(()), + None => { + return Ok(()); + } }; // Build the full host path @@ -252,12 +258,16 @@ impl App { // Get selected item (respects filtered view if active) let selected_idx = match level.selected_index { Some(idx) => idx, - None => return Ok(()), + None => { + return Ok(()); + } }; let item = match level.display_items().get(selected_idx) { Some(item) => item, - None => return Ok(()), + None => { + return Ok(()); + } }; // Build the full host path @@ -449,7 +459,10 @@ impl App { .append(true) .open(log_file) .and_then(|mut f| { - writeln!(f, "No clipboard_command configured - set clipboard_command in config.yaml") + writeln!( + f, + "No clipboard_command configured - set clipboard_command in config.yaml" + ) }); // Show error toast self.model.ui.toast_message = Some(( diff --git a/src/app/filters.rs b/src/app/filters.rs index 82d07e7..1db9bf0 100644 --- a/src/app/filters.rs +++ b/src/app/filters.rs @@ -9,7 +9,7 @@ //! - Preserve selection when switching //! - Mutual exclusion (activating one clears the other) -use crate::{api, log_debug, logic, model, services, App}; +use crate::{App, api, log_debug, logic, model, services}; impl App { // ============================================================================ diff --git a/src/app/folder_history.rs b/src/app/folder_history.rs index 0263dfd..7669cfa 100644 --- a/src/app/folder_history.rs +++ b/src/app/folder_history.rs @@ -2,7 +2,7 @@ //! //! Orchestrates fetching file modification times from API and building history modal state. -use crate::{log_debug, logic, model, App}; +use crate::{App, log_debug, logic, model}; use std::time::SystemTime; type FileList = Vec<(String, SystemTime, u64)>; @@ -228,14 +228,14 @@ impl App { match dir_index { Some(index) => { // Verify it's actually a directory - if let Some(item) = current_level.items.get(index) { - if item.item_type != "FILE_INFO_TYPE_DIRECTORY" { - self.model.ui.show_toast(format!( - "Could not navigate to {}: '{}' is not a directory", - file_path, dir_name - )); - return Ok(()); - } + if let Some(item) = current_level.items.get(index) + && item.item_type != "FILE_INFO_TYPE_DIRECTORY" + { + self.model.ui.show_toast(format!( + "Could not navigate to {}: '{}' is not a directory", + file_path, dir_name + )); + return Ok(()); } // Set selection to this directory @@ -426,8 +426,6 @@ mod tests { #[test] fn test_empty_folder_history() { - use std::time::SystemTime; - let modal = crate::model::types::FolderHistoryModal { folder_id: "test".to_string(), folder_label: "Empty Folder".to_string(), diff --git a/src/app/ignore.rs b/src/app/ignore.rs index 4a708da..77ef8ea 100644 --- a/src/app/ignore.rs +++ b/src/app/ignore.rs @@ -4,7 +4,7 @@ //! - Toggle ignore state (add/remove patterns) //! - Ignore and delete (immediate action) -use crate::{log_debug, logic, model, services, App, SyncState}; +use crate::{App, SyncState, log_debug, logic, model, services}; use anyhow::Result; use std::path::PathBuf; use std::time::Instant; diff --git a/src/app/navigation.rs b/src/app/navigation.rs index c973b31..101eb6b 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -5,149 +5,146 @@ //! - Going back to parent directories //! - Moving selection up/down/page/jump -use crate::{log_debug, logic, model, services, App, SyncState}; +use crate::{App, SyncState, log_debug, logic, model, services}; use anyhow::Result; use std::time::Instant; impl App { pub(crate) async fn load_root_level(&mut self, preview_only: bool) -> Result<()> { - if let Some(selected) = self.model.navigation.folders_state_selection { - if let Some(folder) = self.model.syncthing.folders.get(selected).cloned() { - // Don't try to browse paused folders - if folder.paused { - // Stay on folder list, don't enter the folder - return Ok(()); - } + if let Some(selected) = self.model.navigation.folders_state_selection + && let Some(folder) = self.model.syncthing.folders.get(selected).cloned() + { + // Don't try to browse paused folders + if folder.paused { + // Stay on folder list, don't enter the folder + return Ok(()); + } - // Start timing - let start = Instant::now(); + // Start timing + let start = Instant::now(); - // Get folder sequence for cache validation - let folder_sequence = self - .model - .syncthing - .folder_statuses - .get(&folder.id) - .map(|s| s.sequence) - .unwrap_or(0); + // Get folder sequence for cache validation + let folder_sequence = self + .model + .syncthing + .folder_statuses + .get(&folder.id) + .map(|s| s.sequence) + .unwrap_or(0); - log_debug(&format!( - "DEBUG [load_root_level]: folder={} using sequence={}", - folder.id, folder_sequence - )); + log_debug(&format!( + "DEBUG [load_root_level]: folder={} using sequence={}", + folder.id, folder_sequence + )); - // Create key for tracking in-flight operations - let browse_key = format!("{}:", folder.id); // Empty prefix for root + // Create key for tracking in-flight operations + let browse_key = format!("{}:", folder.id); // Empty prefix for root - // Remove from loading_browse set if it's there (cleanup from previous attempts) - self.model.performance.loading_browse.remove(&browse_key); + // Remove from loading_browse set if it's there (cleanup from previous attempts) + self.model.performance.loading_browse.remove(&browse_key); - // Try cache first - let (items, local_items) = if let Ok(Some(cached_items)) = self - .cache + // Try cache first + let (items, local_items) = if let Ok(Some(cached_items)) = + self.cache .get_browse_items(&folder.id, None, folder_sequence) - { - self.model.performance.cache_hit = Some(true); - let mut items = cached_items; - // Merge local files even from cache - let local_items = self - .merge_local_only_files(&folder.id, &mut items, None) - .await; - (items, local_items) - } else { - // Mark as loading - self.model - .performance - .loading_browse - .insert(browse_key.clone()); - - // Cache miss - fetch from API - self.model.performance.cache_hit = Some(false); - match self.client.browse_folder(&folder.id, None).await { - Ok(mut items) => { - // Merge local-only files from receive-only folders - let local_items = self - .merge_local_only_files(&folder.id, &mut items, None) - .await; - - if let Err(e) = self.cache.save_browse_items( - &folder.id, - None, - &items, - folder_sequence, - ) { - log_debug(&format!("ERROR saving root cache: {}", e)); - } - - // Done loading - self.model.performance.loading_browse.remove(&browse_key); - - (items, local_items) - } - Err(e) => { - self.model.performance.loading_browse.remove(&browse_key); - log_debug(&format!( - "Failed to browse folder root: {}", - crate::logic::errors::format_error_message(&e) - )); - return Ok(()); + { + self.model.performance.cache_hit = Some(true); + let mut items = cached_items; + // Merge local files even from cache + let local_items = self + .merge_local_only_files(&folder.id, &mut items, None) + .await; + (items, local_items) + } else { + // Mark as loading + self.model + .performance + .loading_browse + .insert(browse_key.clone()); + + // Cache miss - fetch from API + self.model.performance.cache_hit = Some(false); + match self.client.browse_folder(&folder.id, None).await { + Ok(mut items) => { + // Merge local-only files from receive-only folders + let local_items = self + .merge_local_only_files(&folder.id, &mut items, None) + .await; + + if let Err(e) = + self.cache + .save_browse_items(&folder.id, None, &items, folder_sequence) + { + log_debug(&format!("ERROR saving root cache: {}", e)); } + + // Done loading + self.model.performance.loading_browse.remove(&browse_key); + + (items, local_items) + } + Err(e) => { + self.model.performance.loading_browse.remove(&browse_key); + log_debug(&format!( + "Failed to browse folder root: {}", + crate::logic::errors::format_error_message(&e) + )); + return Ok(()); } - }; - - // Record load time - self.model.performance.last_load_time_ms = Some(start.elapsed().as_millis() as u64); - - // Compute translated base path once - let translated_base_path = - logic::path::translate_path(&folder.path, "", &self.path_map); - - // Load cached sync states for items - let mut file_sync_states = - self.load_sync_states_from_cache(&folder.id, &items, None); - - // Mark local-only items with LocalOnly sync state and save to cache - for local_item_name in &local_items { - file_sync_states.insert(local_item_name.clone(), SyncState::LocalOnly); - // Save to cache so it persists - let _ = self.cache.save_sync_state( - &folder.id, - local_item_name, - SyncState::LocalOnly, - 0, - ); } + }; - // Check which ignored files exist on disk (one-time check, not per-frame) - // Root level: no parent to check - let ignored_exists = logic::sync_states::check_ignored_existence( - &items, - &file_sync_states, - &translated_base_path, - None, + // Record load time + self.model.performance.last_load_time_ms = Some(start.elapsed().as_millis() as u64); + + // Compute translated base path once + let translated_base_path = + logic::path::translate_path(&folder.path, "", &self.path_map); + + // Load cached sync states for items + let mut file_sync_states = self.load_sync_states_from_cache(&folder.id, &items, None); + + // Mark local-only items with LocalOnly sync state and save to cache + for local_item_name in &local_items { + file_sync_states.insert(local_item_name.clone(), SyncState::LocalOnly); + // Save to cache so it persists + let _ = self.cache.save_sync_state( + &folder.id, + local_item_name, + SyncState::LocalOnly, + 0, ); + } - self.model.navigation.breadcrumb_trail = vec![model::BreadcrumbLevel { - folder_id: folder.id.clone(), - folder_label: folder.label.clone().unwrap_or_else(|| folder.id.clone()), - folder_path: folder.path.clone(), - prefix: None, - items, - selected_index: None, // sort_current_level will set selection - translated_base_path, - file_sync_states, - ignored_exists, - filtered_items: None, - }]; + // Check which ignored files exist on disk (one-time check, not per-frame) + // Root level: no parent to check + let ignored_exists = logic::sync_states::check_ignored_existence( + &items, + &file_sync_states, + &translated_base_path, + None, + ); - // Only change focus if not in preview mode - if !preview_only { - self.model.navigation.focus_level = 1; - } + self.model.navigation.breadcrumb_trail = vec![model::BreadcrumbLevel { + folder_id: folder.id.clone(), + folder_label: folder.label.clone().unwrap_or_else(|| folder.id.clone()), + folder_path: folder.path.clone(), + prefix: None, + items, + selected_index: None, // sort_current_level will set selection + translated_base_path, + file_sync_states, + ignored_exists, + filtered_items: None, + }]; - // Apply initial sorting - self.sort_current_level(); + // Only change focus if not in preview mode + if !preview_only { + self.model.navigation.focus_level = 1; } + + // Apply initial sorting + self.sort_current_level(); } Ok(()) } @@ -168,188 +165,188 @@ impl App { let start = Instant::now(); let current_level = &self.model.navigation.breadcrumb_trail[level_idx]; - if let Some(selected_idx) = current_level.selected_index { - if let Some(item) = current_level.display_items().get(selected_idx) { - // Only enter if it's a directory - if item.item_type != "FILE_INFO_TYPE_DIRECTORY" { - return Ok(()); - } + if let Some(selected_idx) = current_level.selected_index + && let Some(item) = current_level.display_items().get(selected_idx) + { + // Only enter if it's a directory + if item.item_type != "FILE_INFO_TYPE_DIRECTORY" { + return Ok(()); + } - let folder_id = current_level.folder_id.clone(); - let folder_label = current_level.folder_label.clone(); - let folder_path = current_level.folder_path.clone(); - - // Build new prefix - let new_prefix = if let Some(ref prefix) = current_level.prefix { - format!("{}{}/", prefix, item.name) - } else { - format!("{}/", item.name) - }; - - // Get folder sequence for cache validation - let folder_sequence = self - .model - .syncthing - .folder_statuses - .get(&folder_id) - .map(|s| s.sequence) - .unwrap_or(0); - - // Create key for tracking in-flight operations - let browse_key = format!("{}:{}", folder_id, new_prefix); - - // Remove from loading_browse set if it's there (cleanup from previous attempts) - self.model.performance.loading_browse.remove(&browse_key); - - // Try cache first - let (items, local_items) = if let Ok(Some(cached_items)) = self - .cache + let folder_id = current_level.folder_id.clone(); + let folder_label = current_level.folder_label.clone(); + let folder_path = current_level.folder_path.clone(); + + // Build new prefix + let new_prefix = if let Some(ref prefix) = current_level.prefix { + format!("{}{}/", prefix, item.name) + } else { + format!("{}/", item.name) + }; + + // Get folder sequence for cache validation + let folder_sequence = self + .model + .syncthing + .folder_statuses + .get(&folder_id) + .map(|s| s.sequence) + .unwrap_or(0); + + // Create key for tracking in-flight operations + let browse_key = format!("{}:{}", folder_id, new_prefix); + + // Remove from loading_browse set if it's there (cleanup from previous attempts) + self.model.performance.loading_browse.remove(&browse_key); + + // Try cache first + let (items, local_items) = if let Ok(Some(cached_items)) = + self.cache .get_browse_items(&folder_id, Some(&new_prefix), folder_sequence) + { + self.model.performance.cache_hit = Some(true); + let mut items = cached_items; + // Merge local files even from cache + let local_items = self + .merge_local_only_files(&folder_id, &mut items, Some(&new_prefix)) + .await; + (items, local_items) + } else { + // Mark as loading + self.model + .performance + .loading_browse + .insert(browse_key.clone()); + self.model.performance.cache_hit = Some(false); + + // Cache miss - fetch from API (BLOCKING) + match self + .client + .browse_folder(&folder_id, Some(&new_prefix)) + .await { - self.model.performance.cache_hit = Some(true); - let mut items = cached_items; - // Merge local files even from cache - let local_items = self - .merge_local_only_files(&folder_id, &mut items, Some(&new_prefix)) - .await; - (items, local_items) - } else { - // Mark as loading - self.model - .performance - .loading_browse - .insert(browse_key.clone()); - self.model.performance.cache_hit = Some(false); - - // Cache miss - fetch from API (BLOCKING) - match self - .client - .browse_folder(&folder_id, Some(&new_prefix)) - .await - { - Ok(mut items) => { - // Merge local-only files from receive-only folders - let local_items = self - .merge_local_only_files(&folder_id, &mut items, Some(&new_prefix)) - .await; - - let _ = self.cache.save_browse_items( - &folder_id, - Some(&new_prefix), - &items, - folder_sequence, - ); - - // Done loading - self.model.performance.loading_browse.remove(&browse_key); - - (items, local_items) - } - Err(e) => { - self.model.ui.show_toast(format!( - "Unable to browse: {}", - crate::logic::errors::format_error_message(&e) - )); - self.model.performance.loading_browse.remove(&browse_key); - return Ok(()); - } + Ok(mut items) => { + // Merge local-only files from receive-only folders + let local_items = self + .merge_local_only_files(&folder_id, &mut items, Some(&new_prefix)) + .await; + + let _ = self.cache.save_browse_items( + &folder_id, + Some(&new_prefix), + &items, + folder_sequence, + ); + + // Done loading + self.model.performance.loading_browse.remove(&browse_key); + + (items, local_items) } - }; + Err(e) => { + self.model.ui.show_toast(format!( + "Unable to browse: {}", + crate::logic::errors::format_error_message(&e) + )); + self.model.performance.loading_browse.remove(&browse_key); + return Ok(()); + } + } + }; - // Record load time - self.model.performance.last_load_time_ms = Some(start.elapsed().as_millis() as u64); + // Record load time + self.model.performance.last_load_time_ms = Some(start.elapsed().as_millis() as u64); - // Compute translated base path once for this level - let full_relative_path = new_prefix.trim_end_matches('/'); - let container_path = format!( - "{}/{}", - folder_path.trim_end_matches('/'), - full_relative_path - ); + // Compute translated base path once for this level + let full_relative_path = new_prefix.trim_end_matches('/'); + let container_path = format!( + "{}/{}", + folder_path.trim_end_matches('/'), + full_relative_path + ); - // Map to host path - let translated_base_path = self - .path_map - .iter() - .find_map(|(container_prefix, host_prefix)| { - container_path - .strip_prefix(container_prefix.as_str()) - .map(|remainder| { - format!("{}{}", host_prefix.trim_end_matches('/'), remainder) - }) - }) - .unwrap_or(container_path); - - // Truncate breadcrumb trail to current level + 1 - self.model - .navigation - .breadcrumb_trail - .truncate(level_idx + 1); - - // Load cached sync states for items - let mut file_sync_states = - self.load_sync_states_from_cache(&folder_id, &items, Some(&new_prefix)); - log_debug(&format!( - "DEBUG [enter_directory]: Loaded {} cached states for new level with prefix={}", - file_sync_states.len(), - new_prefix - )); - - // Mark local-only items with LocalOnly sync state and save to cache - for local_item_name in &local_items { - file_sync_states.insert(local_item_name.clone(), SyncState::LocalOnly); - // Save to cache so it persists - let file_path = format!("{}{}", new_prefix, local_item_name); - let _ = - self.cache - .save_sync_state(&folder_id, &file_path, SyncState::LocalOnly, 0); - } + // Map to host path + let translated_base_path = self + .path_map + .iter() + .find_map(|(container_prefix, host_prefix)| { + container_path + .strip_prefix(container_prefix.as_str()) + .map(|remainder| { + format!("{}{}", host_prefix.trim_end_matches('/'), remainder) + }) + }) + .unwrap_or(container_path); + + // Truncate breadcrumb trail to current level + 1 + self.model + .navigation + .breadcrumb_trail + .truncate(level_idx + 1); + + // Load cached sync states for items + let mut file_sync_states = + self.load_sync_states_from_cache(&folder_id, &items, Some(&new_prefix)); + log_debug(&format!( + "DEBUG [enter_directory]: Loaded {} cached states for new level with prefix={}", + file_sync_states.len(), + new_prefix + )); + + // Mark local-only items with LocalOnly sync state and save to cache + for local_item_name in &local_items { + file_sync_states.insert(local_item_name.clone(), SyncState::LocalOnly); + // Save to cache so it persists + let file_path = format!("{}{}", new_prefix, local_item_name); + let _ = self + .cache + .save_sync_state(&folder_id, &file_path, SyncState::LocalOnly, 0); + } - // Check if we're inside an ignored directory (check all ancestors) - if so, mark all children as ignored - // This handles the case where you ignore a directory and immediately drill into it - // Ancestor checking removed - FileInfo API will provide correct states - - // Check which ignored files exist on disk (one-time check, not per-frame) - // Determine if parent directory exists (optimization for ignored directories) - let parent_exists = Some(std::path::Path::new(&translated_base_path).exists()); - let ignored_exists = logic::sync_states::check_ignored_existence( - &items, - &file_sync_states, - &translated_base_path, - parent_exists, - ); + // Check if we're inside an ignored directory (check all ancestors) - if so, mark all children as ignored + // This handles the case where you ignore a directory and immediately drill into it + // Ancestor checking removed - FileInfo API will provide correct states + + // Check which ignored files exist on disk (one-time check, not per-frame) + // Determine if parent directory exists (optimization for ignored directories) + let parent_exists = Some(std::path::Path::new(&translated_base_path).exists()); + let ignored_exists = logic::sync_states::check_ignored_existence( + &items, + &file_sync_states, + &translated_base_path, + parent_exists, + ); - // Add new level - self.model - .navigation - .breadcrumb_trail - .push(model::BreadcrumbLevel { - folder_id, - folder_label, - folder_path, - prefix: Some(new_prefix), - items, - selected_index: None, // sort_current_level will set selection - translated_base_path, - file_sync_states, - ignored_exists, - filtered_items: None, - }); + // Add new level + self.model + .navigation + .breadcrumb_trail + .push(model::BreadcrumbLevel { + folder_id, + folder_label, + folder_path, + prefix: Some(new_prefix), + items, + selected_index: None, // sort_current_level will set selection + translated_base_path, + file_sync_states, + ignored_exists, + filtered_items: None, + }); - self.model.navigation.focus_level += 1; + self.model.navigation.focus_level += 1; - // Apply initial sorting - self.sort_current_level(); + // Apply initial sorting + self.sort_current_level(); - // Apply search filter if search is active - if !self.model.ui.search_query.is_empty() { - self.apply_search_filter(); - } + // Apply search filter if search is active + if !self.model.ui.search_query.is_empty() { + self.apply_search_filter(); + } - // Apply out-of-sync filter if active - if self.model.ui.out_of_sync_filter.is_some() { - self.apply_out_of_sync_filter(); - } + // Apply out-of-sync filter if active + if self.model.ui.out_of_sync_filter.is_some() { + self.apply_out_of_sync_filter(); } } @@ -542,17 +539,17 @@ impl App { self.model.navigation.focus_level -= 1; } else if self.model.navigation.focus_level == 1 { // Going back to folder view - refresh root directory if search was cleared - if should_clear_search { - if let Some(root_level) = self.model.navigation.breadcrumb_trail.first() { - let folder_id = root_level.folder_id.clone(); - let prefix = root_level.prefix.clone(); - - let _ = self.api_tx.send(services::api::ApiRequest::BrowseFolder { - folder_id, - prefix, - priority: services::api::Priority::High, - }); - } + if should_clear_search + && let Some(root_level) = self.model.navigation.breadcrumb_trail.first() + { + let folder_id = root_level.folder_id.clone(); + let prefix = root_level.prefix.clone(); + + let _ = self.api_tx.send(services::api::ApiRequest::BrowseFolder { + folder_id, + prefix, + priority: services::api::Priority::High, + }); } // Clear out-of-sync filter when backing out to folder list @@ -564,135 +561,61 @@ impl App { } } - pub(crate) async fn next_item(&mut self) { + /// Navigate with a custom update function + /// + /// This helper unifies all navigation operations (next, prev, jump, page). + /// It handles the focus_level branching and auto-preview loading for folder view. + /// + /// The update_fn receives (current_selection, list_length) and returns new_selection. + async fn navigate_with(&mut self, update_fn: F) + where + F: Fn(Option, usize) -> Option, + { if self.model.navigation.focus_level == 0 { - // Navigate folders - self.model.navigation.folders_state_selection = logic::navigation::next_selection( - self.model.navigation.folders_state_selection, - self.model.syncthing.folders.len(), - ); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; + // Folder view navigation + let len = self.model.syncthing.folders.len(); + if len > 0 { + self.model.navigation.folders_state_selection = + update_fn(self.model.navigation.folders_state_selection, len); + // Auto-load the selected folder's root directory as preview (don't change focus) + let _ = self.load_root_level(true).await; + } } else { - // Navigate current breadcrumb level + // Breadcrumb level navigation let level_idx = self.model.navigation.focus_level - 1; if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - level.selected_index = logic::navigation::next_selection( - level.selected_index, - level.display_items().len(), - ); + let len = level.display_items().len(); + if len > 0 { + level.selected_index = update_fn(level.selected_index, len); + } } } } + pub(crate) async fn next_item(&mut self) { + self.navigate_with(logic::navigation::next_selection).await; + } + pub(crate) async fn previous_item(&mut self) { - if self.model.navigation.focus_level == 0 { - // Navigate folders - self.model.navigation.folders_state_selection = logic::navigation::prev_selection( - self.model.navigation.folders_state_selection, - self.model.syncthing.folders.len(), - ); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; - } else { - // Navigate current breadcrumb level - let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - level.selected_index = logic::navigation::prev_selection( - level.selected_index, - level.display_items().len(), - ); - } - } + self.navigate_with(logic::navigation::prev_selection).await; } pub(crate) async fn jump_to_first(&mut self) { - if self.model.navigation.focus_level == 0 { - if !self.model.syncthing.folders.is_empty() { - self.model.navigation.folders_state_selection = Some(0); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; - } - } else { - let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - if !level.display_items().is_empty() { - level.selected_index = Some(0); - } - } - } + self.navigate_with(|_, _| Some(0)).await; } pub(crate) async fn jump_to_last(&mut self) { - if self.model.navigation.focus_level == 0 { - if !self.model.syncthing.folders.is_empty() { - self.model.navigation.folders_state_selection = - Some(self.model.syncthing.folders.len() - 1); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; - } - } else { - let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - if !level.display_items().is_empty() { - level.selected_index = Some(level.display_items().len() - 1); - } - } - } + self.navigate_with(|_, len| Some(len - 1)).await; } pub(crate) async fn page_down(&mut self, page_size: usize) { - if self.model.navigation.focus_level == 0 { - if self.model.syncthing.folders.is_empty() { - return; - } - let i = match self.model.navigation.folders_state_selection { - Some(i) => (i + page_size).min(self.model.syncthing.folders.len() - 1), - None => 0, - }; - self.model.navigation.folders_state_selection = Some(i); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; - } else { - let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - if level.display_items().is_empty() { - return; - } - let i = match level.selected_index { - Some(i) => (i + page_size).min(level.display_items().len() - 1), - None => 0, - }; - level.selected_index = Some(i); - } - } + self.navigate_with(|sel, len| Some(sel.map_or(0, |i| (i + page_size).min(len - 1)))) + .await; } pub(crate) async fn page_up(&mut self, page_size: usize) { - if self.model.navigation.focus_level == 0 { - if self.model.syncthing.folders.is_empty() { - return; - } - let i = match self.model.navigation.folders_state_selection { - Some(i) => i.saturating_sub(page_size), - None => 0, - }; - self.model.navigation.folders_state_selection = Some(i); - // Auto-load the selected folder's root directory as preview (don't change focus) - let _ = self.load_root_level(true).await; - } else { - let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - if level.display_items().is_empty() { - return; - } - let i = match level.selected_index { - Some(i) => i.saturating_sub(page_size), - None => 0, - }; - level.selected_index = Some(i); - } - } + self.navigate_with(|sel, _| Some(sel.map_or(0, |i| i.saturating_sub(page_size)))) + .await; } pub(crate) async fn half_page_down(&mut self, visible_height: usize) { diff --git a/src/app/preview.rs b/src/app/preview.rs index ec5395f..f18d838 100644 --- a/src/app/preview.rs +++ b/src/app/preview.rs @@ -5,7 +5,7 @@ //! - Image preview with terminal graphics protocols //! - Binary file text extraction -use crate::{log_debug, logic, model, App, BrowseItem, Folder, ImageMetadata, ImagePreviewState}; +use crate::{App, BrowseItem, Folder, ImageMetadata, ImagePreviewState, log_debug, logic, model}; use anyhow::Result; use std::collections::HashMap; diff --git a/src/app/sorting.rs b/src/app/sorting.rs index 11bd72f..8644e72 100644 --- a/src/app/sorting.rs +++ b/src/app/sorting.rs @@ -5,7 +5,7 @@ //! - Reversible sorting //! - Selection preservation across sorts -use crate::{logic, App}; +use crate::{App, logic}; impl App { /// Sort a specific breadcrumb level by its index diff --git a/src/app/sync_states.rs b/src/app/sync_states.rs index b38c3db..9d04c80 100644 --- a/src/app/sync_states.rs +++ b/src/app/sync_states.rs @@ -6,7 +6,7 @@ //! - Prefetching subdirectory states //! - Tracking ignored file existence -use crate::{log_debug, logic, services, App, SyncState}; +use crate::{App, SyncState, log_debug, logic, services}; use std::collections::HashMap; use std::time::Duration; @@ -493,49 +493,48 @@ impl App { } let level_idx = self.model.navigation.focus_level - 1; - if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) { - if let Some(selected_idx) = level.selected_index { - if let Some(item) = level.items.get(selected_idx) { - // Check if we already have the sync state cached - if level.file_sync_states.contains_key(&item.name) { - return; - } - - // Build the file path for the API call - let file_path = if let Some(ref prefix) = level.prefix { - format!("{}{}", prefix, item.name) - } else { - item.name.clone() - }; - - // Create key for tracking in-flight operations - let sync_key = format!("{}:{}", level.folder_id, file_path); - - // Skip if already loading - if self - .model - .performance - .loading_sync_states - .contains(&sync_key) - { - return; - } - - // Mark as loading - self.model - .performance - .loading_sync_states - .insert(sync_key.clone()); - - // Send non-blocking request via API service - // Response will be handled by handle_api_response - let _ = self.api_tx.send(services::api::ApiRequest::GetFileInfo { - folder_id: level.folder_id.clone(), - file_path: file_path.clone(), - priority: services::api::Priority::High, // High priority for selected item - }); - } + if let Some(level) = self.model.navigation.breadcrumb_trail.get_mut(level_idx) + && let Some(selected_idx) = level.selected_index + && let Some(item) = level.items.get(selected_idx) + { + // Check if we already have the sync state cached + if level.file_sync_states.contains_key(&item.name) { + return; + } + + // Build the file path for the API call + let file_path = if let Some(ref prefix) = level.prefix { + format!("{}{}", prefix, item.name) + } else { + item.name.clone() + }; + + // Create key for tracking in-flight operations + let sync_key = format!("{}:{}", level.folder_id, file_path); + + // Skip if already loading + if self + .model + .performance + .loading_sync_states + .contains(&sync_key) + { + return; } + + // Mark as loading + self.model + .performance + .loading_sync_states + .insert(sync_key.clone()); + + // Send non-blocking request via API service + // Response will be handled by handle_api_response + let _ = self.api_tx.send(services::api::ApiRequest::GetFileInfo { + folder_id: level.folder_id.clone(), + file_path: file_path.clone(), + priority: services::api::Priority::High, // High priority for selected item + }); } } @@ -556,8 +555,10 @@ impl App { file_name ); let exists = std::path::Path::new(&host_path).exists(); - log_debug(&format!("DEBUG [update_ignored_exists_for_file]: file_name={} prefix={:?} translated_base_path={} host_path={} exists={}", - file_name, level.prefix, level.translated_base_path, host_path, exists)); + log_debug(&format!( + "DEBUG [update_ignored_exists_for_file]: file_name={} prefix={:?} translated_base_path={} host_path={} exists={}", + file_name, level.prefix, level.translated_base_path, host_path, exists + )); level.ignored_exists.insert(file_name.to_string(), exists); } else { // File is no longer ignored - remove from ignored_exists diff --git a/src/cache.rs b/src/cache.rs index 9299632..15fd201 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; @@ -224,6 +224,7 @@ impl CacheDb { receive_only_changed_directories: 0, receive_only_changed_files: 0, receive_only_changed_symlinks: 0, + errors: 0, }) }); @@ -421,18 +422,18 @@ impl CacheDb { ) .ok(); - if let Some(old_seq) = existing_seq { - if old_seq as u64 != folder_sequence { - // Sequence changed - delete ALL old cached data for this folder - let cleared = tx.execute( - "DELETE FROM browse_cache WHERE folder_id = ?1", - params![folder_id], - )?; - log_debug(&format!( - "DEBUG [save_browse_items]: Sequence changed ({} -> {}), cleared {} entries for entire folder", - old_seq, folder_sequence, cleared - )); - } + if let Some(old_seq) = existing_seq + && old_seq as u64 != folder_sequence + { + // Sequence changed - delete ALL old cached data for this folder + let cleared = tx.execute( + "DELETE FROM browse_cache WHERE folder_id = ?1", + params![folder_id], + )?; + log_debug(&format!( + "DEBUG [save_browse_items]: Sequence changed ({} -> {}), cleared {} entries for entire folder", + old_seq, folder_sequence, cleared + )); } // Delete old entries for this specific folder/prefix (in case of same-sequence update) @@ -468,8 +469,10 @@ impl CacheDb { ]) { Ok(_) => {} Err(e) => { - log_debug(&format!("DEBUG [save_browse_items]: Insert failed at item {}: {} (name={}, type={})", - idx, e, item.name, item.item_type)); + log_debug(&format!( + "DEBUG [save_browse_items]: Insert failed at item {}: {} (name={}, type={})", + idx, e, item.name, item.item_type + )); return Err(e.into()); } } @@ -667,7 +670,10 @@ impl CacheDb { params![folder_id, parent_dir], )?; - log_debug(&format!("DEBUG [invalidate_single_file]: Deleted {} sync state entries, {} browse entries for parent dir '{}'", sync_deleted, browse_deleted, parent_dir)); + log_debug(&format!( + "DEBUG [invalidate_single_file]: Deleted {} sync state entries, {} browse entries for parent dir '{}'", + sync_deleted, browse_deleted, parent_dir + )); Ok(()) } diff --git a/src/handlers/api.rs b/src/handlers/api.rs index cb649ac..73e6067 100644 --- a/src/handlers/api.rs +++ b/src/handlers/api.rs @@ -5,11 +5,11 @@ use std::time::Instant; +use crate::App; use crate::api::SyncState; use crate::model; use crate::model::syncthing::ConnectionState; use crate::services::api::{ApiRequest, ApiResponse, Priority}; -use crate::App; /// Handle API response from background service /// @@ -75,7 +75,10 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { }; if !is_relevant { - crate::log_debug(&format!("DEBUG [BrowseResult]: Skipping irrelevant response for folder={} prefix={:?} (navigated away)", folder_id, prefix)); + crate::log_debug(&format!( + "DEBUG [BrowseResult]: Skipping irrelevant response for folder={} prefix={:?} (navigated away)", + folder_id, prefix + )); return; // Skip saving and UI updates for responses from folders we've left } @@ -135,7 +138,11 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { crate::log_debug(&format!( "DEBUG [BrowseResult]: folder={} prefix={:?} items_count={} focus_level={} breadcrumb_count={}", - folder_id, prefix, items.len(), app.model.navigation.focus_level, app.model.navigation.breadcrumb_trail.len() + folder_id, + prefix, + items.len(), + app.model.navigation.focus_level, + app.model.navigation.breadcrumb_trail.len() )); // Save merged items to cache @@ -418,12 +425,18 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { updated = true; } } else { - crate::log_debug(&format!("DEBUG [FileInfoResult UI update]: NO MATCH - file_path={} doesn't start with level_prefix={}", file_path, level_prefix)); + crate::log_debug(&format!( + "DEBUG [FileInfoResult UI update]: NO MATCH - file_path={} doesn't start with level_prefix={}", + file_path, level_prefix + )); } } } if !updated { - crate::log_debug(&format!("DEBUG [FileInfoResult UI update]: WARNING - No matching level found for folder={} path={}", folder_id, file_path)); + crate::log_debug(&format!( + "DEBUG [FileInfoResult UI update]: WARNING - No matching level found for folder={} path={}", + folder_id, file_path + )); } } @@ -463,7 +476,9 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { // SystemStatus will handle setting needs_folder_refresh if folders are empty if was_disconnected { let start = std::time::Instant::now(); - crate::log_debug("DEBUG [FolderStatusResult]: Just reconnected, requesting immediate system status"); + crate::log_debug( + "DEBUG [FolderStatusResult]: Just reconnected, requesting immediate system status", + ); let _ = app.api_tx.send(ApiRequest::GetSystemStatus); crate::log_debug(&format!( "DEBUG [FolderStatusResult]: GetSystemStatus sent in {:?}", @@ -475,16 +490,16 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { let receive_only_count = status.receive_only_total_items; // Check if sequence changed - if let Some(&last_seq) = app.model.performance.last_known_sequences.get(&folder_id) { - if last_seq != sequence { - crate::log_debug(&format!( - "DEBUG [FolderStatusResult]: Sequence changed from {} to {} for folder={}", - last_seq, sequence, folder_id - )); + if let Some(&last_seq) = app.model.performance.last_known_sequences.get(&folder_id) + && last_seq != sequence + { + crate::log_debug(&format!( + "DEBUG [FolderStatusResult]: Sequence changed from {} to {} for folder={}", + last_seq, sequence, folder_id + )); - // Sequence changed - invalidate cache and refresh - app.invalidate_and_refresh_folder(&folder_id); - } + // Sequence changed - invalidate cache and refresh + app.invalidate_and_refresh_folder(&folder_id); } // Check if receive-only item count changed (indicates local-only files added/removed) @@ -493,32 +508,34 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { .performance .last_known_receive_only_counts .get(&folder_id) + && last_count != receive_only_count { - if last_count != receive_only_count { - crate::log_debug(&format!("DEBUG [FolderStatusResult]: receiveOnlyTotalItems changed from {} to {} for folder={}", last_count, receive_only_count, folder_id)); - - // Trigger refresh for currently viewed directory - if !app.model.navigation.breadcrumb_trail.is_empty() - && app.model.navigation.breadcrumb_trail[0].folder_id == folder_id - { - for level in &mut app.model.navigation.breadcrumb_trail { - if level.folder_id == folder_id { - let browse_key = format!( - "{}:{}", - folder_id, - level.prefix.as_deref().unwrap_or("") - ); - if !app.model.performance.loading_browse.contains(&browse_key) { - app.model.performance.loading_browse.insert(browse_key); + crate::log_debug(&format!( + "DEBUG [FolderStatusResult]: receiveOnlyTotalItems changed from {} to {} for folder={}", + last_count, receive_only_count, folder_id + )); - let _ = app.api_tx.send(ApiRequest::BrowseFolder { - folder_id: folder_id.clone(), - prefix: level.prefix.clone(), - priority: Priority::High, - }); + // Trigger refresh for currently viewed directory + if !app.model.navigation.breadcrumb_trail.is_empty() + && app.model.navigation.breadcrumb_trail[0].folder_id == folder_id + { + for level in &mut app.model.navigation.breadcrumb_trail { + if level.folder_id == folder_id { + let browse_key = + format!("{}:{}", folder_id, level.prefix.as_deref().unwrap_or("")); + if !app.model.performance.loading_browse.contains(&browse_key) { + app.model.performance.loading_browse.insert(browse_key); + + let _ = app.api_tx.send(ApiRequest::BrowseFolder { + folder_id: folder_id.clone(), + prefix: level.prefix.clone(), + priority: Priority::High, + }); - crate::log_debug(&format!("DEBUG [FolderStatusResult]: Triggered browse refresh for prefix={:?}", level.prefix)); - } + crate::log_debug(&format!( + "DEBUG [FolderStatusResult]: Triggered browse refresh for prefix={:?}", + level.prefix + )); } } } @@ -549,7 +566,10 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { error, } => { if success { - crate::log_debug(&format!("DEBUG [RescanResult]: Successfully rescanned folder={}, requesting immediate status update", folder_id)); + crate::log_debug(&format!( + "DEBUG [RescanResult]: Successfully rescanned folder={}, requesting immediate status update", + folder_id + )); // Immediately request folder status to detect sequence changes // This makes the rescan feel more responsive @@ -585,7 +605,9 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { if app.model.syncthing.devices.is_empty() || app.model.syncthing.device_name.is_none() { - crate::log_debug("DEBUG [SystemStatusResult]: Requesting devices list to update device name"); + crate::log_debug( + "DEBUG [SystemStatusResult]: Requesting devices list to update device name", + ); let _ = app.api_tx.send(ApiRequest::GetDevices); } @@ -595,17 +617,16 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { // We also don't check show_setup_help because user might have dismissed it if app.model.syncthing.folders.is_empty() { crate::log_debug(&format!( - "DEBUG [SystemStatusResult]: Connected with no folders, setting fetch flag (was_disconnected={} show_setup_help={})", - was_disconnected, - app.model.ui.show_setup_help - )); + "DEBUG [SystemStatusResult]: Connected with no folders, setting fetch flag (was_disconnected={} show_setup_help={})", + was_disconnected, app.model.ui.show_setup_help + )); app.model.ui.needs_folder_refresh = true; } else { crate::log_debug(&format!( - "DEBUG [SystemStatusResult]: NOT setting flag - folders.len()={} (was_disconnected={})", - app.model.syncthing.folders.len(), - was_disconnected - )); + "DEBUG [SystemStatusResult]: NOT setting flag - folders.len()={} (was_disconnected={})", + app.model.syncthing.folders.len(), + was_disconnected + )); } // Successful API call - mark as connected @@ -633,9 +654,9 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { } else { // Already reconnecting, don't override crate::log_debug(&format!( - "DEBUG [SystemStatusResult ERROR]: {} (reconnection active, state unchanged)", - e - )); + "DEBUG [SystemStatusResult ERROR]: {} (reconnection active, state unchanged)", + e + )); } } } @@ -683,7 +704,10 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { // NOTE: Connection stats failures don't mark the entire connection as Disconnected // This is a non-critical endpoint - other API calls may still be working // Log the error but don't change connection state - crate::log_debug(&format!("DEBUG [ConnectionStatsResult ERROR]: {} (non-critical, connection state unchanged)", e)); + crate::log_debug(&format!( + "DEBUG [ConnectionStatsResult ERROR]: {} (non-critical, connection state unchanged)", + e + )); } }, @@ -716,7 +740,9 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { )); } } else { - crate::log_debug("DEBUG [DevicesResult]: No system status available yet, cannot extract device name"); + crate::log_debug( + "DEBUG [DevicesResult]: No system status available yet, cannot extract device name", + ); } } @@ -762,26 +788,26 @@ pub fn handle_api_response(app: &mut App, response: ApiResponse) { // If in breadcrumb view for this folder, apply/reapply filter now that data is ready if app.model.navigation.focus_level > 0 { let level_idx = app.model.navigation.focus_level - 1; - if let Some(level) = app.model.navigation.breadcrumb_trail.get(level_idx) { - if level.folder_id == folder_id { - if app.model.ui.out_of_sync_filter.is_none() { - // First time: activate filter - app.model.ui.out_of_sync_filter = - Some(model::types::OutOfSyncFilterState { - origin_level: app.model.navigation.focus_level, - last_refresh: std::time::SystemTime::now(), - }); + if let Some(level) = app.model.navigation.breadcrumb_trail.get(level_idx) + && level.folder_id == folder_id + { + if app.model.ui.out_of_sync_filter.is_none() { + // First time: activate filter + app.model.ui.out_of_sync_filter = + Some(model::types::OutOfSyncFilterState { + origin_level: app.model.navigation.focus_level, + last_refresh: std::time::SystemTime::now(), + }); - // Apply filter - app.apply_out_of_sync_filter(); + // Apply filter + app.apply_out_of_sync_filter(); - // Clear loading toast - app.model.ui.toast_message = None; - } else { - // Filter already active: re-apply with fresh cache data - // This handles the case where cache was invalidated and just refreshed - app.apply_out_of_sync_filter(); - } + // Clear loading toast + app.model.ui.toast_message = None; + } else { + // Filter already active: re-apply with fresh cache data + // This handles the case where cache was invalidated and just refreshed + app.apply_out_of_sync_filter(); } } } diff --git a/src/handlers/events.rs b/src/handlers/events.rs index 56a3394..d6d83bd 100644 --- a/src/handlers/events.rs +++ b/src/handlers/events.rs @@ -3,9 +3,9 @@ //! Handles cache invalidation events from the Syncthing event stream. //! These events tell us when files/directories change so we can refresh the UI. +use crate::App; use crate::services::api::{ApiRequest, Priority}; use crate::services::events::CacheInvalidation; -use crate::App; /// Handle cache invalidation messages from event listener /// @@ -28,19 +28,12 @@ pub fn handle_cache_invalidation(app: &mut App, invalidation: CacheInvalidation) "DEBUG [Event]: Invalidating file: folder={} path={}", folder_id, file_path )); - let _ = app.cache.invalidate_single_file(&folder_id, &file_path); - let _ = app.cache.invalidate_folder_status(&folder_id); - // Invalidate out-of-sync cache and refresh summary modal if open - app.invalidate_and_refresh_out_of_sync_summary(&folder_id); - - // Invalidate local changed cache for this folder - let _ = app.cache.invalidate_local_changed(&folder_id); + // Invalidate the specific file + let _ = app.cache.invalidate_single_file(&folder_id, &file_path); - // Request fresh folder status - let _ = app.api_tx.send(ApiRequest::GetFolderStatus { - folder_id: folder_id.clone(), - }); + // Invalidate all folder-level caches (common pattern) + app.invalidate_folder_caches(&folder_id); // Update last change info for this folder (use event timestamp, not current time) app.model @@ -78,7 +71,10 @@ pub fn handle_cache_invalidation(app: &mut App, invalidation: CacheInvalidation) priority: Priority::High, }); - crate::log_debug(&format!("DEBUG [Event]: Triggered refresh for currently viewed directory: {:?}", parent_dir)); + crate::log_debug(&format!( + "DEBUG [Event]: Triggered refresh for currently viewed directory: {:?}", + parent_dir + )); } } } @@ -94,21 +90,12 @@ pub fn handle_cache_invalidation(app: &mut App, invalidation: CacheInvalidation) "DEBUG [Event]: Invalidating directory: folder={} path={}", folder_id, dir_path )); - let _ = app.cache.invalidate_directory(&folder_id, &dir_path); - - // Invalidate folder status cache to refresh receiveOnlyTotalItems count - let _ = app.cache.invalidate_folder_status(&folder_id); - - // Invalidate out-of-sync cache and refresh summary modal if open - app.invalidate_and_refresh_out_of_sync_summary(&folder_id); - // Invalidate local changed cache for this folder - let _ = app.cache.invalidate_local_changed(&folder_id); + // Invalidate the directory + let _ = app.cache.invalidate_directory(&folder_id, &dir_path); - // Request fresh folder status - let _ = app.api_tx.send(ApiRequest::GetFolderStatus { - folder_id: folder_id.clone(), - }); + // Invalidate all folder-level caches (common pattern) + app.invalidate_folder_caches(&folder_id); // Don't update last_folder_updates here - Directory events (RemoteIndexUpdated) // don't have specific file paths. We get accurate file paths from: @@ -172,7 +159,10 @@ pub fn handle_cache_invalidation(app: &mut App, invalidation: CacheInvalidation) priority: Priority::High, }); - crate::log_debug(&format!("DEBUG [Event]: Triggered refresh for directory: {:?} (dir_path={:?})", level_prefix, dir_path)); + crate::log_debug(&format!( + "DEBUG [Event]: Triggered refresh for directory: {:?} (dir_path={:?})", + level_prefix, dir_path + )); } } } @@ -218,5 +208,206 @@ pub fn handle_cache_invalidation(app: &mut App, invalidation: CacheInvalidation) // Don't clear state or fetch FileInfo - causes flicker and API flood // LocalIndexUpdated event will trigger Browse refresh with fresh data } + CacheInvalidation::Activity { + folder_id, + event_message, + timestamp, + } => { + crate::log_debug(&format!( + "DEBUG [Event]: Activity: folder='{}' message='{}' timestamp={:?}", + folder_id, event_message, timestamp + )); + + // Only update if this event is newer than existing activity + let should_update = app + .model + .ui + .folder_activity + .get(&folder_id) + .map(|(_, existing_time)| timestamp > *existing_time) + .unwrap_or(true); // Always insert if no existing entry + + if should_update { + app.model + .ui + .folder_activity + .insert(folder_id.clone(), (event_message, timestamp)); + } else { + crate::log_debug(&format!( + "DEBUG [Event]: Skipping older activity event for folder '{}'", + folder_id + )); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + /// Create a minimal test App instance + /// Only initializes the fields needed for Activity event testing + fn create_test_app() -> App { + use crate::SyncthingClient; + use crate::cache::CacheDb; + use crate::config::Config; + use crate::model::Model; + use crate::ui::icons::{IconMode, IconRenderer, IconTheme}; + use std::collections::HashMap; + use std::time::Duration; + + let config = Config { + api_key: "test-key".to_string(), + base_url: "http://localhost:8384".to_string(), + path_map: HashMap::new(), + vim_mode: false, + icon_mode: "emoji".to_string(), + open_command: None, + clipboard_command: None, + image_preview_enabled: false, + image_protocol: "auto".to_string(), + }; + + let client = SyncthingClient::new(config.api_key.clone(), config.base_url.clone()); + let cache = CacheDb::new_in_memory().expect("Failed to create test cache"); + let (api_tx, _api_rx_temp) = tokio::sync::mpsc::unbounded_channel(); + let (_api_response_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_invalidation_tx, invalidation_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_event_id_tx, event_id_rx) = tokio::sync::mpsc::unbounded_channel(); + let (image_update_tx, image_update_rx) = tokio::sync::mpsc::unbounded_channel(); + + App { + model: Model::new(config.vim_mode), + client, + cache, + api_tx, + api_rx, + invalidation_rx, + event_id_rx, + icon_renderer: IconRenderer::new(IconMode::Emoji, IconTheme::default()), + image_picker: None, + image_update_tx, + image_update_rx, + path_map: config.path_map, + open_command: config.open_command, + clipboard_command: config.clipboard_command, + base_url: config.base_url, + last_status_update: std::time::Instant::now(), + last_system_status_update: std::time::Instant::now(), + last_connection_stats_fetch: std::time::Instant::now(), + last_directory_update: std::time::Instant::now(), + last_db_flush: std::time::Instant::now(), + last_reconnect_attempt: std::time::Instant::now(), + reconnect_delay: Duration::from_secs(1), + pending_sync_state_writes: Vec::new(), + image_state_map: HashMap::new(), + } + } + + #[test] + fn test_activity_only_updates_with_newer_timestamp() { + // Test that older events don't overwrite newer activity + let mut app = create_test_app(); + + let newer_time = SystemTime::now(); + let older_time = newer_time - Duration::from_secs(300); // 5 min ago + + // First, add newer activity + let newer_invalidation = CacheInvalidation::Activity { + folder_id: "test-folder".to_string(), + event_message: "SYNCED file 'new.txt'".to_string(), + timestamp: newer_time, + }; + handle_cache_invalidation(&mut app, newer_invalidation); + + // Verify newer activity is stored + let stored = app.model.ui.folder_activity.get("test-folder"); + assert_eq!(stored.unwrap().0, "SYNCED file 'new.txt'"); + + // Now receive older event (replay scenario) + let older_invalidation = CacheInvalidation::Activity { + folder_id: "test-folder".to_string(), + event_message: "SYNCED file 'old.txt'".to_string(), + timestamp: older_time, + }; + handle_cache_invalidation(&mut app, older_invalidation); + + // Verify newer activity is STILL there (older event ignored) + let stored = app.model.ui.folder_activity.get("test-folder"); + assert_eq!(stored.unwrap().0, "SYNCED file 'new.txt'"); + assert_eq!(stored.unwrap().1, newer_time); + } + + #[test] + fn test_activity_updates_with_equal_timestamp() { + // Equal timestamps should NOT update (keep first) + let mut app = create_test_app(); + let same_time = SystemTime::now(); + + let first = CacheInvalidation::Activity { + folder_id: "test-folder".to_string(), + event_message: "SYNCED file 'first.txt'".to_string(), + timestamp: same_time, + }; + handle_cache_invalidation(&mut app, first); + + let second = CacheInvalidation::Activity { + folder_id: "test-folder".to_string(), + event_message: "SYNCED file 'second.txt'".to_string(), + timestamp: same_time, + }; + handle_cache_invalidation(&mut app, second); + + let stored = app.model.ui.folder_activity.get("test-folder"); + assert_eq!(stored.unwrap().0, "SYNCED file 'first.txt'"); + } + + #[test] + fn test_activity_allows_first_event_for_folder() { + // First event for a folder should always be stored + let mut app = create_test_app(); + + let first_event = CacheInvalidation::Activity { + folder_id: "new-folder".to_string(), + event_message: "SYNCED file 'initial.txt'".to_string(), + timestamp: SystemTime::now(), + }; + handle_cache_invalidation(&mut app, first_event); + + assert!(app.model.ui.folder_activity.contains_key("new-folder")); + } + + #[test] + fn test_activity_independent_per_folder() { + // Each folder tracks timestamps independently + let mut app = create_test_app(); + let time1 = SystemTime::now(); + let time2 = time1 + Duration::from_secs(10); + + let folder1 = CacheInvalidation::Activity { + folder_id: "folder1".to_string(), + event_message: "SYNCED file 'a.txt'".to_string(), + timestamp: time1, + }; + handle_cache_invalidation(&mut app, folder1); + + let folder2 = CacheInvalidation::Activity { + folder_id: "folder2".to_string(), + event_message: "SYNCED file 'b.txt'".to_string(), + timestamp: time2, + }; + handle_cache_invalidation(&mut app, folder2); + + assert_eq!(app.model.ui.folder_activity.len(), 2); + assert_eq!( + app.model.ui.folder_activity.get("folder1").unwrap().1, + time1 + ); + assert_eq!( + app.model.ui.folder_activity.get("folder2").unwrap().1, + time2 + ); } } diff --git a/src/handlers/keyboard.rs b/src/handlers/keyboard.rs index 4d38fae..a9f33b0 100644 --- a/src/handlers/keyboard.rs +++ b/src/handlers/keyboard.rs @@ -7,9 +7,9 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::time::Instant; +use crate::App; use crate::api::SyncState; use crate::model::{self, ConfirmAction}; -use crate::App; /// Check if folder history modal should load more files /// @@ -777,16 +777,16 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Esc => { // Exit search mode and clear query app.clear_search(None); // No toast, user explicitly pressed Esc - // Reload all breadcrumb levels without filter (for fresh data) + // Reload all breadcrumb levels without filter (for fresh data) app.refresh_all_breadcrumbs().await?; return Ok(()); } KeyCode::Enter => { // Accept search and exit input mode (keep filtering active) crate::log_debug(&format!( - "DEBUG [keyboard]: Enter pressed in search mode, query='{}', keeping filter active", - app.model.ui.search_query - )); + "DEBUG [keyboard]: Enter pressed in search mode, query='{}', keeping filter active", + app.model.ui.search_query + )); app.model.ui.search_mode = false; return Ok(()); } @@ -819,28 +819,26 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { app.model.ui.search_query.push(c); // When query reaches 2 characters, trigger recursive prefetch - if app.model.ui.search_query.len() == 2 { - if let Some(level) = app + if app.model.ui.search_query.len() == 2 + && let Some(level) = app .model .navigation .breadcrumb_trail .get(app.model.navigation.focus_level.saturating_sub(1)) - { - let folder_id = level.folder_id.clone(); - let prefix = level.prefix.clone(); + { + let folder_id = level.folder_id.clone(); + let prefix = level.prefix.clone(); - crate::log_debug(&format!( - "DEBUG [keyboard]: Search query reached 2 chars, starting prefetch for folder '{}' prefix '{:?}'", - folder_id, - prefix - )); + crate::log_debug(&format!( + "DEBUG [keyboard]: Search query reached 2 chars, starting prefetch for folder '{}' prefix '{:?}'", + folder_id, prefix + )); - // Clear previous prefetch tracking for new search - app.model.performance.discovered_dirs.clear(); + // Clear previous prefetch tracking for new search + app.model.performance.discovered_dirs.clear(); - // Start prefetch from current location - app.prefetch_subdirectories_for_search(&folder_id, prefix.as_deref()); - } + // Start prefetch from current location + app.prefetch_subdirectories_for_search(&folder_id, prefix.as_deref()); } // Re-filter current breadcrumb in real-time (only applies filter if >= 2 chars) @@ -864,7 +862,7 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { if app.model.navigation.focus_level > 0 && !app.model.ui.search_query.is_empty() { crate::log_debug("DEBUG [keyboard]: Clearing search..."); app.clear_search(None); // No toast, user explicitly pressed Esc - // Reload all breadcrumb levels without filter (for fresh data) + // Reload all breadcrumb levels without filter (for fresh data) app.refresh_all_breadcrumbs().await?; return Ok(()); } @@ -964,10 +962,6 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { // Copy file/directory path (breadcrumbs only) let _ = app.copy_to_clipboard(); } - KeyCode::Char('f') if app.model.navigation.focus_level == 0 => { - // Open out-of-sync summary modal (only in folder view) - app.open_out_of_sync_summary(); - } KeyCode::Char('f') if app.model.navigation.focus_level > 0 => { // Toggle out-of-sync filter (only in breadcrumb view) app.activate_out_of_sync_filter(); @@ -1038,25 +1032,23 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { .navigation .breadcrumb_trail .get(app.model.navigation.focus_level - 1) + && let Some(selected_idx) = level.selected_index + && let Some(item) = level.display_items().get(selected_idx) { - if let Some(selected_idx) = level.selected_index { - if let Some(item) = level.display_items().get(selected_idx) { - // Construct full path - let file_path = if let Some(prefix) = &level.prefix { - format!("{}{}", prefix, item.name) - } else { - item.name.clone() - }; - - // Fetch file info and content (await since it's async) - app.fetch_file_info_and_content( - level.folder_id.clone(), - file_path, - item.clone(), - ) - .await; - } - } + // Construct full path + let file_path = if let Some(prefix) = &level.prefix { + format!("{}{}", prefix, item.name) + } else { + item.name.clone() + }; + + // Fetch file info and content (await since it's async) + app.fetch_file_info_and_content( + level.folder_id.clone(), + file_path, + item.clone(), + ) + .await; } } } @@ -1143,28 +1135,26 @@ pub async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { .navigation .breadcrumb_trail .get(app.model.navigation.focus_level - 1) + && let Some(selected_idx) = level.selected_index + && let Some(item) = level.display_items().get(selected_idx) { - if let Some(selected_idx) = level.selected_index { - if let Some(item) = level.display_items().get(selected_idx) { - if item.item_type != "FILE_INFO_TYPE_DIRECTORY" { - // File - show preview (same logic as '?' key) - let file_path = if let Some(prefix) = &level.prefix { - format!("{}{}", prefix, item.name) - } else { - item.name.clone() - }; + if item.item_type != "FILE_INFO_TYPE_DIRECTORY" { + // File - show preview (same logic as '?' key) + let file_path = if let Some(prefix) = &level.prefix { + format!("{}{}", prefix, item.name) + } else { + item.name.clone() + }; - app.fetch_file_info_and_content( - level.folder_id.clone(), - file_path, - item.clone(), - ) - .await; - } else { - // Directory - navigate into it - app.enter_directory().await?; - } - } + app.fetch_file_info_and_content( + level.folder_id.clone(), + file_path, + item.clone(), + ) + .await; + } else { + // Directory - navigate into it + app.enter_directory().await?; } } } diff --git a/src/logic/errors.rs b/src/logic/errors.rs index ec5a01e..1cca62e 100644 --- a/src/logic/errors.rs +++ b/src/logic/errors.rs @@ -24,15 +24,15 @@ pub fn classify_error(error: &Error) -> ErrorType { } // Check for HTTP status codes (via reqwest error chain) - if let Some(reqwest_err) = error.downcast_ref::() { - if let Some(status) = reqwest_err.status() { - return match status.as_u16() { - 401 => ErrorType::Unauthorized, - 404 => ErrorType::NotFound, - 500..=599 => ErrorType::ServerError, - _ => ErrorType::Other, - }; - } + if let Some(reqwest_err) = error.downcast_ref::() + && let Some(status) = reqwest_err.status() + { + return match status.as_u16() { + 401 => ErrorType::Unauthorized, + 404 => ErrorType::NotFound, + 500..=599 => ErrorType::ServerError, + _ => ErrorType::Other, + }; } // Network-level errors diff --git a/src/logic/folder.rs b/src/logic/folder.rs index 82fd06e..31a5c96 100644 --- a/src/logic/folder.rs +++ b/src/logic/folder.rs @@ -98,6 +98,7 @@ pub fn can_delete_file(focus_level: usize, breadcrumb_trail_empty: bool) -> bool /// receive_only_changed_files: 0, /// receive_only_changed_symlinks: 0, /// receive_only_total_items: 5, // Has local changes +/// errors: 0, /// }))); /// /// // Don't show: in folder list view @@ -184,6 +185,7 @@ mod tests { receive_only_changed_files: 0, receive_only_changed_symlinks: 0, receive_only_total_items: receive_only_items, + errors: 0, } } diff --git a/src/logic/folder_card.rs b/src/logic/folder_card.rs new file mode 100644 index 0000000..64d5af4 --- /dev/null +++ b/src/logic/folder_card.rs @@ -0,0 +1,661 @@ +//! Folder card formatting logic +//! +//! Pure functions for calculating folder card states and formatting card data + +use crate::api::{Folder, FolderStatus}; + +/// Card state enum for visual rendering +#[derive(Debug, Clone, PartialEq)] +pub enum FolderCardState { + /// Fully synced, no pending changes + Synced, + /// Out of sync (remote_needed files, local_changes files) + OutOfSync { + remote_needed: u64, + local_changes: u64, + }, + /// Currently syncing (remote_needed files, local_changes files) + Syncing { + remote_needed: u64, + local_changes: u64, + }, + /// Folder is paused + Paused, + /// Folder has errors + Error, + /// Status not yet loaded + Loading, +} + +/// Calculate the card state for a folder +pub fn calculate_folder_card_state( + folder: &Folder, + status: Option<&FolderStatus>, +) -> FolderCardState { + if folder.paused { + return FolderCardState::Paused; + } + + let Some(status) = status else { + return FolderCardState::Loading; + }; + + if status.errors > 0 { + return FolderCardState::Error; + } + + let remote_needed = status.need_total_items; + let local_changes = status.receive_only_total_items; + + if status.state == "syncing" || status.state == "sync-preparing" { + return FolderCardState::Syncing { + remote_needed, + local_changes, + }; + } + + if remote_needed > 0 || local_changes > 0 { + FolderCardState::OutOfSync { + remote_needed, + local_changes, + } + } else { + FolderCardState::Synced + } +} + +/// Format folder type to user-friendly string +pub fn format_folder_type(folder_type: &str) -> String { + match folder_type { + "sendonly" => "Send Only".to_string(), + "sendreceive" => "Send & Receive".to_string(), + "receiveonly" => "Receive Only".to_string(), + _ => folder_type.to_string(), + } +} + +/// Format byte size to human-readable +pub fn format_size(bytes: u64) -> String { + if bytes == 0 { + return "0 B".to_string(); + } + + let units = ["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < units.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} B", bytes) + } else { + format!("{:.1} {}", size, units[unit_index]) + } +} + +/// Format file count to human-readable +pub fn format_file_count(count: u64) -> String { + if count == 0 { + return "0 files".to_string(); + } + + if count < 1000 { + format!("{} files", count) + } else if count < 1_000_000 { + format!("{:.1}K files", count as f64 / 1000.0) + } else { + format!("{:.1}M files", count as f64 / 1_000_000.0) + } +} + +/// Format status message for the card +pub fn format_status_message(state: &FolderCardState) -> String { + match state { + FolderCardState::Synced => "Up to date".to_string(), + FolderCardState::OutOfSync { .. } => { + // Don't show count here - it's shown in detail on line 3 + "Out of sync".to_string() + } + FolderCardState::Syncing { .. } => { + // Don't show count here - it's shown in detail on line 3 + "Syncing...".to_string() + } + FolderCardState::Paused => "Paused".to_string(), + FolderCardState::Error => "Error".to_string(), + FolderCardState::Loading => "Loading...".to_string(), + } +} + +/// Format out-of-sync details with arrows +/// For receive-only folders, local changes are shown as "modified locally" since they can't be uploaded +pub fn format_out_of_sync_details( + remote_needed: u64, + local_changes: u64, + need_bytes: u64, + folder_type: &str, +) -> Option { + let mut parts = Vec::new(); + + if remote_needed > 0 { + let size_str = format_size(need_bytes); + parts.push(format!("↓ {} files ({})", remote_needed, size_str)); + } + + if local_changes > 0 { + // For receive-only folders, local changes can't be uploaded + // They represent files modified locally that conflict with receive-only mode + if folder_type == "receiveonly" { + let count_str = if local_changes == 1 { + "1 file modified".to_string() + } else { + format!("{} files modified", local_changes) + }; + parts.push(format!("✎ {} locally", count_str)); + } else { + // For sendreceive and sendonly, show as upload + parts.push(format!("↑ {} files", local_changes)); + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join(", ")) + } +} + +/// Calculate card height in lines +#[allow(dead_code)] +pub fn calculate_card_height(state: &FolderCardState) -> u16 { + match state { + FolderCardState::OutOfSync { + remote_needed, + local_changes, + } + | FolderCardState::Syncing { + remote_needed, + local_changes, + } => { + if *remote_needed > 0 || *local_changes > 0 { + 4 // Title + info + details + spacing + } else { + 3 + } + } + _ => 3, // Title + info + spacing + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================== + // CARD STATE CALCULATION + // ======================================== + + #[test] + fn test_calculate_folder_card_state_paused() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: true, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + let state = calculate_folder_card_state(&folder, None); + assert_eq!(state, FolderCardState::Paused); + } + + #[test] + fn test_calculate_folder_card_state_loading() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: false, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + let state = calculate_folder_card_state(&folder, None); + assert_eq!(state, FolderCardState::Loading); + } + + #[test] + fn test_calculate_folder_card_state_synced() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: false, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + let status = FolderStatus { + state: "idle".to_string(), + sequence: 0, + global_bytes: 0, + global_deleted: 0, + global_directories: 0, + global_files: 10, + global_symlinks: 0, + global_total_items: 10, + in_sync_bytes: 0, + in_sync_files: 0, + local_bytes: 0, + local_deleted: 0, + local_directories: 0, + local_files: 10, + local_symlinks: 0, + local_total_items: 10, + need_bytes: 0, + need_deletes: 0, + need_directories: 0, + need_files: 0, + need_symlinks: 0, + need_total_items: 0, + receive_only_changed_bytes: 0, + receive_only_changed_deletes: 0, + receive_only_changed_directories: 0, + receive_only_changed_files: 0, + receive_only_changed_symlinks: 0, + receive_only_total_items: 0, + errors: 0, + }; + + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!(state, FolderCardState::Synced); + } + + #[test] + fn test_calculate_folder_card_state_out_of_sync() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: false, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + let mut status = FolderStatus { + state: "idle".to_string(), + sequence: 0, + global_bytes: 0, + global_deleted: 0, + global_directories: 0, + global_files: 10, + global_symlinks: 0, + global_total_items: 10, + in_sync_bytes: 0, + in_sync_files: 0, + local_bytes: 0, + local_deleted: 0, + local_directories: 0, + local_files: 10, + local_symlinks: 0, + local_total_items: 10, + need_bytes: 1024, + need_deletes: 0, + need_directories: 0, + need_files: 0, + need_symlinks: 0, + need_total_items: 5, + receive_only_changed_bytes: 0, + receive_only_changed_deletes: 0, + receive_only_changed_directories: 0, + receive_only_changed_files: 0, + receive_only_changed_symlinks: 0, + receive_only_total_items: 0, + errors: 0, + }; + + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::OutOfSync { + remote_needed: 5, + local_changes: 0 + } + ); + + // Test with local changes + status.need_total_items = 0; + status.receive_only_total_items = 3; + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::OutOfSync { + remote_needed: 0, + local_changes: 3 + } + ); + } + + #[test] + fn test_calculate_folder_card_state_syncing_with_counts() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: false, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + // Test syncing with remote needed files + let mut status = FolderStatus { + state: "syncing".to_string(), + sequence: 0, + global_bytes: 0, + global_deleted: 0, + global_directories: 0, + global_files: 10, + global_symlinks: 0, + global_total_items: 10, + in_sync_bytes: 0, + in_sync_files: 0, + local_bytes: 0, + local_deleted: 0, + local_directories: 0, + local_files: 10, + local_symlinks: 0, + local_total_items: 10, + need_bytes: 1024, + need_deletes: 0, + need_directories: 0, + need_files: 0, + need_symlinks: 0, + need_total_items: 5, + receive_only_changed_bytes: 0, + receive_only_changed_deletes: 0, + receive_only_changed_directories: 0, + receive_only_changed_files: 0, + receive_only_changed_symlinks: 0, + receive_only_total_items: 0, + errors: 0, + }; + + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::Syncing { + remote_needed: 5, + local_changes: 0 + } + ); + + // Test syncing with local changes + status.need_total_items = 0; + status.receive_only_total_items = 3; + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::Syncing { + remote_needed: 0, + local_changes: 3 + } + ); + + // Test syncing with both + status.need_total_items = 5; + status.receive_only_total_items = 3; + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::Syncing { + remote_needed: 5, + local_changes: 3 + } + ); + } + + #[test] + fn test_calculate_folder_card_state_sync_preparing_with_counts() { + let folder = Folder { + id: "test".to_string(), + label: None, + path: "/test".to_string(), + paused: false, + folder_type: "sendreceive".to_string(), + devices: vec![], + }; + + let status = FolderStatus { + state: "sync-preparing".to_string(), + sequence: 0, + global_bytes: 0, + global_deleted: 0, + global_directories: 0, + global_files: 10, + global_symlinks: 0, + global_total_items: 10, + in_sync_bytes: 0, + in_sync_files: 0, + local_bytes: 0, + local_deleted: 0, + local_directories: 0, + local_files: 10, + local_symlinks: 0, + local_total_items: 10, + need_bytes: 2048, + need_deletes: 0, + need_directories: 0, + need_files: 0, + need_symlinks: 0, + need_total_items: 8, + receive_only_changed_bytes: 0, + receive_only_changed_deletes: 0, + receive_only_changed_directories: 0, + receive_only_changed_files: 0, + receive_only_changed_symlinks: 0, + receive_only_total_items: 2, + errors: 0, + }; + + let state = calculate_folder_card_state(&folder, Some(&status)); + assert_eq!( + state, + FolderCardState::Syncing { + remote_needed: 8, + local_changes: 2 + } + ); + } + + // ======================================== + // FORMATTING FUNCTIONS + // ======================================== + + #[test] + fn test_format_folder_type() { + assert_eq!(format_folder_type("sendonly"), "Send Only"); + assert_eq!(format_folder_type("sendreceive"), "Send & Receive"); + assert_eq!(format_folder_type("receiveonly"), "Receive Only"); + assert_eq!(format_folder_type("unknown"), "unknown"); + } + + #[test] + fn test_format_size() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(1024 * 1024), "1.0 MB"); + assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB"); + } + + #[test] + fn test_format_file_count() { + assert_eq!(format_file_count(0), "0 files"); + assert_eq!(format_file_count(1), "1 files"); + assert_eq!(format_file_count(500), "500 files"); + assert_eq!(format_file_count(1500), "1.5K files"); + assert_eq!(format_file_count(1_500_000), "1.5M files"); + } + + #[test] + fn test_format_status_message() { + assert_eq!( + format_status_message(&FolderCardState::Synced), + "Up to date" + ); + assert_eq!(format_status_message(&FolderCardState::Paused), "Paused"); + assert_eq!(format_status_message(&FolderCardState::Error), "Error"); + assert_eq!( + format_status_message(&FolderCardState::Loading), + "Loading..." + ); + + // Out of sync - no count (shown on line 3) + assert_eq!( + format_status_message(&FolderCardState::OutOfSync { + remote_needed: 1, + local_changes: 0 + }), + "Out of sync" + ); + assert_eq!( + format_status_message(&FolderCardState::OutOfSync { + remote_needed: 5, + local_changes: 3 + }), + "Out of sync" + ); + + // Syncing - no count (shown on line 3) + assert_eq!( + format_status_message(&FolderCardState::Syncing { + remote_needed: 5, + local_changes: 0 + }), + "Syncing..." + ); + assert_eq!( + format_status_message(&FolderCardState::Syncing { + remote_needed: 0, + local_changes: 3 + }), + "Syncing..." + ); + assert_eq!( + format_status_message(&FolderCardState::Syncing { + remote_needed: 5, + local_changes: 3 + }), + "Syncing..." + ); + assert_eq!( + format_status_message(&FolderCardState::Syncing { + remote_needed: 1, + local_changes: 0 + }), + "Syncing..." + ); + } + + #[test] + fn test_format_out_of_sync_details() { + assert_eq!(format_out_of_sync_details(0, 0, 0, "sendreceive"), None); + + // Send & Receive: remote needed + assert_eq!( + format_out_of_sync_details(5, 0, 1024, "sendreceive"), + Some("↓ 5 files (1.0 KB)".to_string()) + ); + + // Send & Receive: local changes (uploading) + assert_eq!( + format_out_of_sync_details(0, 3, 0, "sendreceive"), + Some("↑ 3 files".to_string()) + ); + + // Send & Receive: both + assert_eq!( + format_out_of_sync_details(5, 3, 2048, "sendreceive"), + Some("↓ 5 files (2.0 KB), ↑ 3 files".to_string()) + ); + + // Receive Only: local changes (modified locally, need revert) + assert_eq!( + format_out_of_sync_details(0, 3, 0, "receiveonly"), + Some("✎ 3 files modified locally".to_string()) + ); + + // Receive Only: both remote needed and local changes + assert_eq!( + format_out_of_sync_details(5, 3, 2048, "receiveonly"), + Some("↓ 5 files (2.0 KB), ✎ 3 files modified locally".to_string()) + ); + + // Send Only: should not have local changes (only remote needed) + assert_eq!( + format_out_of_sync_details(5, 0, 1024, "sendonly"), + Some("↓ 5 files (1.0 KB)".to_string()) + ); + } + + #[test] + fn test_calculate_card_height() { + assert_eq!(calculate_card_height(&FolderCardState::Synced), 3); + assert_eq!(calculate_card_height(&FolderCardState::Paused), 3); + assert_eq!( + calculate_card_height(&FolderCardState::OutOfSync { + remote_needed: 5, + local_changes: 0 + }), + 4 + ); + assert_eq!( + calculate_card_height(&FolderCardState::OutOfSync { + remote_needed: 0, + local_changes: 3 + }), + 4 + ); + assert_eq!( + calculate_card_height(&FolderCardState::OutOfSync { + remote_needed: 0, + local_changes: 0 + }), + 3 + ); + + // Test Syncing state with counts + assert_eq!( + calculate_card_height(&FolderCardState::Syncing { + remote_needed: 5, + local_changes: 0 + }), + 4 + ); + assert_eq!( + calculate_card_height(&FolderCardState::Syncing { + remote_needed: 0, + local_changes: 3 + }), + 4 + ); + assert_eq!( + calculate_card_height(&FolderCardState::Syncing { + remote_needed: 5, + local_changes: 3 + }), + 4 + ); + assert_eq!( + calculate_card_height(&FolderCardState::Syncing { + remote_needed: 0, + local_changes: 0 + }), + 3 + ); + } +} diff --git a/src/logic/folder_history.rs b/src/logic/folder_history.rs index f4d63a4..e6eb66b 100644 --- a/src/logic/folder_history.rs +++ b/src/logic/folder_history.rs @@ -178,9 +178,11 @@ mod tests { assert_eq!(result.len(), 3); assert!(result.iter().any(|e| e.file_path == "root.txt")); assert!(result.iter().any(|e| e.file_path == "subdir/nested1.txt")); - assert!(result - .iter() - .any(|e| e.file_path == "level1/level2/level3/deep.txt")); + assert!( + result + .iter() + .any(|e| e.file_path == "level1/level2/level3/deep.txt") + ); } // ======================================== diff --git a/src/logic/formatting.rs b/src/logic/formatting.rs index fd1fb58..d553027 100644 --- a/src/logic/formatting.rs +++ b/src/logic/formatting.rs @@ -140,6 +140,45 @@ pub fn format_datetime(rfc3339: &str) -> String { } } +/// Format time since event for status bar +/// +/// Converts a SystemTime timestamp into a human-readable relative time string. +/// +/// # Arguments +/// * `timestamp` - SystemTime of the event +/// +/// # Returns +/// Formatted string like "5 sec ago", "2 min ago", "3 hr ago", "Yesterday", or "5 days ago" +/// +/// # Examples +/// ```no_run +/// use std::time::{SystemTime, Duration}; +/// use stui::logic::formatting::format_time_since; +/// +/// let now = SystemTime::now(); +/// let thirty_secs_ago = now - Duration::from_secs(30); +/// assert_eq!(format_time_since(thirty_secs_ago), "30 sec ago"); +/// ``` +pub fn format_time_since(timestamp: std::time::SystemTime) -> String { + let elapsed = timestamp + .elapsed() + .unwrap_or(std::time::Duration::from_secs(0)); + + let secs = elapsed.as_secs(); + + if secs < 60 { + format!("{} sec ago", secs) + } else if secs < 3600 { + format!("{} min ago", secs / 60) + } else if secs < 86400 { + format!("{} hr ago", secs / 3600) + } else if secs < 172800 { + "Yesterday".to_string() + } else { + format!("{} days ago", secs / 86400) + } +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +281,29 @@ mod tests { assert_eq!(format_human_size(1099511627776), "1.0T"); assert_eq!(format_human_size(1649267441664), "1.5T"); } + + // ======================================== + // FORMAT TIME SINCE + // ======================================== + + #[test] + fn test_format_time_since_seconds() { + let timestamp = std::time::SystemTime::now() - std::time::Duration::from_secs(30); + let formatted = format_time_since(timestamp); + assert!(formatted.contains("sec ago")); + } + + #[test] + fn test_format_time_since_minutes() { + let timestamp = std::time::SystemTime::now() - std::time::Duration::from_secs(120); + let formatted = format_time_since(timestamp); + assert!(formatted.contains("min ago")); + } + + #[test] + fn test_format_time_since_hours() { + let timestamp = std::time::SystemTime::now() - std::time::Duration::from_secs(7200); + let formatted = format_time_since(timestamp); + assert!(formatted.contains("hr ago")); + } } diff --git a/src/logic/ignore.rs b/src/logic/ignore.rs index 5673634..e98e545 100644 --- a/src/logic/ignore.rs +++ b/src/logic/ignore.rs @@ -43,10 +43,10 @@ pub fn pattern_matches(pattern: &str, file_path: &str) -> bool { } // Try glob matching - if let Ok(pattern_obj) = glob::Pattern::new(pattern_without_slash) { - if pattern_obj.matches(file_path.trim_start_matches('/')) { - return true; - } + if let Ok(pattern_obj) = glob::Pattern::new(pattern_without_slash) + && pattern_obj.matches(file_path.trim_start_matches('/')) + { + return true; } } else { // Pattern without / - match anywhere (path components or filename) diff --git a/src/logic/mod.rs b/src/logic/mod.rs index bbe6ed0..41c40c5 100644 --- a/src/logic/mod.rs +++ b/src/logic/mod.rs @@ -21,6 +21,7 @@ pub mod errors; pub mod file; pub mod file_navigation; pub mod folder; +pub mod folder_card; pub mod folder_history; pub mod formatting; pub mod ignore; diff --git a/src/logic/sorting.rs b/src/logic/sorting.rs index 23fe96b..728e9d4 100644 --- a/src/logic/sorting.rs +++ b/src/logic/sorting.rs @@ -2,8 +2,8 @@ //! //! Pure functions for comparing browse items across different sort modes. -use crate::api::{BrowseItem, SyncState}; use crate::SortMode; +use crate::api::{BrowseItem, SyncState}; use std::cmp::Ordering; use std::collections::HashMap; @@ -76,11 +76,7 @@ pub fn compare_browse_items( } }; - if reverse { - result.reverse() - } else { - result - } + if reverse { result.reverse() } else { result } } #[cfg(test)] diff --git a/src/logic/ui.rs b/src/logic/ui.rs index 5d67eb1..3229d89 100644 --- a/src/logic/ui.rs +++ b/src/logic/ui.rs @@ -2,7 +2,7 @@ //! //! Pure functions for UI state cycling and transitions. -use crate::{model::VimCommandState, DisplayMode, SortMode}; +use crate::{DisplayMode, SortMode, model::VimCommandState}; /// Cycle to the next display mode: Off → TimestampOnly → TimestampAndSize → Off /// diff --git a/src/main.rs b/src/main.rs index 629f7d7..c906484 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,9 @@ use clap::Parser; use crossterm::{ event::{self, Event, KeyEvent}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ collections::{HashMap, HashSet}, fs, io, @@ -201,6 +201,28 @@ impl App { } } + /// Invalidate all folder-related caches when folder content changes + /// + /// This is called by both File and Directory cache invalidation events + /// to ensure consistent cache cleanup for any folder mutation. + fn invalidate_folder_caches(&mut self, folder_id: &str) { + // Invalidate folder status + let _ = self.cache.invalidate_folder_status(folder_id); + + // Invalidate out-of-sync cache and refresh summary modal if open + self.invalidate_and_refresh_out_of_sync_summary(folder_id); + + // Invalidate local changed cache + let _ = self.cache.invalidate_local_changed(folder_id); + + // Request fresh folder status + let _ = self + .api_tx + .send(services::api::ApiRequest::GetFolderStatus { + folder_id: folder_id.to_string(), + }); + } + /// Clean up stale pending deletes (older than 60 seconds) fn cleanup_stale_pending_deletes(&mut self) { let now = Instant::now(); @@ -272,6 +294,39 @@ impl App { logic::folder::calculate_local_state_summary(&self.model.syncthing.folder_statuses) } + /// Refresh device count by querying connections API + async fn refresh_device_count(&mut self) { + match self.client.get_system_connections().await { + Ok(connections) => { + // Count connected devices (exclude self, filter by connected status) + let my_device_id = self + .model + .syncthing + .system_status + .as_ref() + .map(|s| s.my_id.as_str()); + + let connected_count = connections + .connections + .iter() + .filter(|(device_id, conn)| { + conn.connected && Some(device_id.as_str()) != my_device_id + }) + .count(); + + self.model.syncthing.connected_device_count = Some(connected_count); + + log_debug(&format!( + "Device count refreshed: {} connected", + connected_count + )); + } + Err(e) => { + log_debug(&format!("Failed to refresh device count: {}", e)); + } + } + } + /// Flush pending database writes in a single transaction fn flush_pending_db_writes(&mut self) { if self.pending_sync_state_writes.is_empty() { @@ -545,14 +600,14 @@ impl App { async fn load_folder_statuses(&mut self) { for folder in &self.model.syncthing.folders { // Try cache first - use it without validation on initial load - if !self.model.syncthing.statuses_loaded { - if let Ok(Some(cached_status)) = self.cache.get_folder_status(&folder.id) { - self.model - .syncthing - .folder_statuses - .insert(folder.id.clone(), cached_status); - continue; - } + if !self.model.syncthing.statuses_loaded + && let Ok(Some(cached_status)) = self.cache.get_folder_status(&folder.id) + { + self.model + .syncthing + .folder_statuses + .insert(folder.id.clone(), cached_status); + continue; } // Cache miss or this is a refresh - fetch from API @@ -561,20 +616,19 @@ impl App { // Check if sequence changed from last known value if let Some(&last_seq) = self.model.performance.last_known_sequences.get(&folder.id) + && last_seq != sequence { - if last_seq != sequence { - // Sequence changed! Invalidate cached data for this folder - let _ = self.cache.invalidate_folder(&folder.id); - - // Clear in-memory sync states for this folder if we're currently viewing it - // This ensures files that changed get refreshed - if !self.model.navigation.breadcrumb_trail.is_empty() - && self.model.navigation.breadcrumb_trail[0].folder_id == folder.id - { - for level in &mut self.model.navigation.breadcrumb_trail { - if level.folder_id == folder.id { - level.file_sync_states.clear(); - } + // Sequence changed! Invalidate cached data for this folder + let _ = self.cache.invalidate_folder(&folder.id); + + // Clear in-memory sync states for this folder if we're currently viewing it + // This ensures files that changed get refreshed + if !self.model.navigation.breadcrumb_trail.is_empty() + && self.model.navigation.breadcrumb_trail[0].folder_id == folder.id + { + for level in &mut self.model.navigation.breadcrumb_trail { + if level.folder_id == folder.id { + level.file_sync_states.clear(); } } } @@ -881,6 +935,9 @@ async fn main() -> Result<()> { // Initialize app let mut app = App::new(config, config_path_str).await?; + // Load initial device count + app.refresh_device_count().await; + // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -919,10 +976,10 @@ async fn run_app( })?; // Auto-dismiss toast after 1.5 seconds - if let Some((_, timestamp)) = app.model.ui.toast_message { - if crate::logic::ui::should_dismiss_toast(timestamp.elapsed().as_millis()) { - app.model.ui.toast_message = None; - } + if let Some((_, timestamp)) = app.model.ui.toast_message + && crate::logic::ui::should_dismiss_toast(timestamp.elapsed().as_millis()) + { + app.model.ui.toast_message = None; } if app.model.ui.should_quit { @@ -969,23 +1026,22 @@ async fn run_app( app.image_state_map.insert(file_path.clone(), image_state); // Update popup if it's still showing the same file - if let Some(ref mut popup_state) = app.model.ui.file_info_popup { - if popup_state.file_path == file_path { - log_debug(&format!("Updating image state for {}", file_path)); - - // Update file_content based on image state - if let Some(img_state) = app.image_state_map.get(&file_path) { - match img_state { - ImagePreviewState::Ready { .. } => { - popup_state.file_content = - Ok("[Image preview - see right panel]".to_string()); - } - ImagePreviewState::Failed { .. } => { - popup_state.file_content = - Err("Image preview unavailable".to_string()); - } - _ => {} + if let Some(ref mut popup_state) = app.model.ui.file_info_popup + && popup_state.file_path == file_path + { + log_debug(&format!("Updating image state for {}", file_path)); + + // Update file_content based on image state + if let Some(img_state) = app.image_state_map.get(&file_path) { + match img_state { + ImagePreviewState::Ready { .. } => { + popup_state.file_content = + Ok("[Image preview - see right panel]".to_string()); } + ImagePreviewState::Failed { .. } => { + popup_state.file_content = Err("Image preview unavailable".to_string()); + } + _ => {} } } } @@ -1080,6 +1136,7 @@ async fn run_app( // System status every 30 seconds, connection stats every 2-3 seconds if app.last_system_status_update.elapsed() >= std::time::Duration::from_secs(30) { let _ = app.api_tx.send(services::api::ApiRequest::GetSystemStatus); + app.refresh_device_count().await; app.last_system_status_update = Instant::now(); } @@ -1167,12 +1224,12 @@ async fn run_app( } // Increased poll timeout from 100ms to 250ms to reduce CPU usage when idle - if event::poll(std::time::Duration::from_millis(250))? { - if let Event::Key(key) = event::read()? { - // Flush before processing user input to ensure consistency - app.flush_pending_db_writes(); - app.handle_key(key).await?; - } + if event::poll(std::time::Duration::from_millis(250))? + && let Event::Key(key) = event::read()? + { + // Flush before processing user input to ensure consistency + app.flush_pending_db_writes(); + app.handle_key(key).await?; } } diff --git a/src/model/syncthing.rs b/src/model/syncthing.rs index 17be54e..48b7261 100644 --- a/src/model/syncthing.rs +++ b/src/model/syncthing.rs @@ -66,6 +66,9 @@ pub struct SyncthingModel { /// Cached transfer rates (download, upload) in bytes/sec pub last_transfer_rates: Option<(f64, f64)>, + /// Connected device count + pub connected_device_count: Option, + // ============================================ // FOLDER-SPECIFIC STATE // ============================================ @@ -91,6 +94,7 @@ impl SyncthingModel { last_connection_stats: None, device_name: None, last_transfer_rates: None, + connected_device_count: None, last_folder_updates: HashMap::new(), } } @@ -176,4 +180,32 @@ mod tests { }; assert_eq!(state3, state4); } + + #[test] + fn test_devices_storage() { + let mut model = SyncthingModel::new(); + + let devices = vec![ + crate::api::Device { + id: "DEVICE1".to_string(), + name: "Device 1".to_string(), + }, + crate::api::Device { + id: "DEVICE2".to_string(), + name: "Device 2".to_string(), + }, + ]; + + model.devices = devices.clone(); + assert_eq!(model.devices.len(), 2); + assert_eq!(model.devices[0].name, "Device 1"); + } + + #[test] + fn test_connected_device_count() { + let mut model = SyncthingModel::new(); + + model.connected_device_count = Some(3); + assert_eq!(model.connected_device_count, Some(3)); + } } diff --git a/src/model/types.rs b/src/model/types.rs index 845a45f..a0634f1 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -180,6 +180,26 @@ pub struct FolderHistoryEntry { pub file_size: Option, } +/// Device details modal state +#[derive(Debug, Clone)] +pub struct DeviceDetailsModal { + pub devices: Vec, + pub selected_index: usize, +} + +/// Device information for details modal +#[derive(Debug, Clone)] +pub struct DeviceInfo { + pub device_id: String, + pub device_name: String, + pub connected: bool, + pub address: String, + pub download_rate: Option, // bytes/sec + pub upload_rate: Option, // bytes/sec + pub shared_folder_count: usize, + pub total_folder_count: usize, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/model/ui.rs b/src/model/ui.rs index ccf0063..ba1ec98 100644 --- a/src/model/ui.rs +++ b/src/model/ui.rs @@ -83,6 +83,12 @@ pub struct UiModel { /// Folder update history modal state pub folder_history_modal: Option, + /// Per-folder activity tracking (folder_id -> (event_message, timestamp)) + pub folder_activity: std::collections::HashMap, + + /// Device details modal state + pub device_details_modal: Option, + // ============================================ // VISUAL STATE // ============================================ @@ -119,6 +125,8 @@ impl UiModel { out_of_sync_filter: None, out_of_sync_summary: None, folder_history_modal: None, + folder_activity: std::collections::HashMap::new(), + device_details_modal: None, sixel_cleanup_frames: 0, image_font_size: None, should_quit: false, @@ -408,4 +416,17 @@ mod tests { model.search_origin_level = None; assert!(model.search_origin_level.is_none()); } + + #[test] + fn test_folder_activity() { + let mut model = UiModel::new(false); + + model.folder_activity.insert( + "test-folder".to_string(), + ("test.txt synced".to_string(), std::time::SystemTime::now()), + ); + + assert_eq!(model.folder_activity.len(), 1); + assert!(model.folder_activity.contains_key("test-folder")); + } } diff --git a/src/services/api.rs b/src/services/api.rs index dc18fb5..0686348 100644 --- a/src/services/api.rs +++ b/src/services/api.rs @@ -4,7 +4,7 @@ use std::fs::OpenOptions; use std::io::Write; use std::sync::atomic::Ordering; use tokio::sync::mpsc; -use tokio::time::{interval, Duration}; +use tokio::time::{Duration, interval}; use crate::api::{ BrowseItem, ConnectionStats, Device, FileDetails, FolderStatus, NeedResponse, SyncthingClient, diff --git a/src/services/events.rs b/src/services/events.rs index 209a7eb..38baee5 100644 --- a/src/services/events.rs +++ b/src/services/events.rs @@ -84,6 +84,12 @@ pub enum CacheInvalidation { #[allow(dead_code)] timestamp: std::time::SystemTime, }, + /// Activity event for status bar display + Activity { + folder_id: String, + event_message: String, + timestamp: std::time::SystemTime, + }, } /// Spawn the event listener task @@ -240,7 +246,10 @@ async fn event_listener_loop( // Check for missed events (gap in IDs) if event.id != last_event_id + 1 && last_event_id > 0 { - log_debug(&format!("DEBUG [EVENT]: WARNING - Missed events! Last ID: {}, Current ID: {}", last_event_id, event.id)); + log_debug(&format!( + "DEBUG [EVENT]: WARNING - Missed events! Last ID: {}, Current ID: {}", + last_event_id, event.id + )); } // Process events we care about @@ -249,25 +258,23 @@ async fn event_listener_loop( // LocalIndexUpdated has a "filenames" array instead of "item" if let Some(folder_id) = event.data.get("folder").and_then(|v| v.as_str()) - { - if let Some(filenames) = + && let Some(filenames) = event.data.get("filenames").and_then(|v| v.as_array()) - { - let timestamp = parse_event_time(&event.time); - for filename in filenames { - if let Some(file_path) = filename.as_str() { - let invalidation = CacheInvalidation::File { - folder_id: folder_id.to_string(), - file_path: file_path.to_string(), - timestamp, - }; - - log_debug(&format!( - "DEBUG [EVENT]: Sending invalidation: {:?}", - invalidation - )); - let _ = invalidation_tx.send(invalidation); - } + { + let timestamp = parse_event_time(&event.time); + for filename in filenames { + if let Some(file_path) = filename.as_str() { + let invalidation = CacheInvalidation::File { + folder_id: folder_id.to_string(), + file_path: file_path.to_string(), + timestamp, + }; + + log_debug(&format!( + "DEBUG [EVENT]: Sending invalidation: {:?}", + invalidation + )); + let _ = invalidation_tx.send(invalidation); } } } @@ -275,108 +282,141 @@ async fn event_listener_loop( "ItemStarted" => { if let Some(folder_id) = event.data.get("folder").and_then(|v| v.as_str()) - { - if let Some(item_path) = + && let Some(item_path) = event.data.get("item").and_then(|v| v.as_str()) - { - let timestamp = parse_event_time(&event.time); - let invalidation = CacheInvalidation::ItemStarted { - folder_id: folder_id.to_string(), - file_path: item_path.to_string(), - timestamp, - }; - log_debug(&format!( - "DEBUG [EVENT]: ItemStarted: {:?}", - invalidation - )); - let _ = invalidation_tx.send(invalidation); - } + { + let timestamp = parse_event_time(&event.time); + let invalidation = CacheInvalidation::ItemStarted { + folder_id: folder_id.to_string(), + file_path: item_path.to_string(), + timestamp, + }; + log_debug(&format!( + "DEBUG [EVENT]: ItemStarted: {:?}", + invalidation + )); + let _ = invalidation_tx.send(invalidation); } } "ItemFinished" => { if let Some(folder_id) = event.data.get("folder").and_then(|v| v.as_str()) - { - if let Some(item_path) = + && let Some(item_path) = event.data.get("item").and_then(|v| v.as_str()) - { - let timestamp = parse_event_time(&event.time); + { + let timestamp = parse_event_time(&event.time); - // Send ItemFinished notification - let finished_invalidation = - CacheInvalidation::ItemFinished { - folder_id: folder_id.to_string(), - file_path: item_path.to_string(), - timestamp, - }; - log_debug(&format!( - "DEBUG [EVENT]: ItemFinished: {:?}", - finished_invalidation - )); - let _ = invalidation_tx.send(finished_invalidation); - - // Also send cache invalidation - let item_type = - event.data.get("type").and_then(|v| v.as_str()); - let cache_invalidation = if item_type == Some("dir") - || item_path.ends_with('/') - { - CacheInvalidation::Directory { - folder_id: folder_id.to_string(), - dir_path: item_path.to_string(), - timestamp, - } - } else { - CacheInvalidation::File { - folder_id: folder_id.to_string(), - file_path: item_path.to_string(), - timestamp, - } + // Send ItemFinished notification + let finished_invalidation = + CacheInvalidation::ItemFinished { + folder_id: folder_id.to_string(), + file_path: item_path.to_string(), + timestamp, }; - log_debug(&format!( - "DEBUG [EVENT]: Sending cache invalidation: {:?}", - cache_invalidation - )); - let _ = invalidation_tx.send(cache_invalidation); - } + log_debug(&format!( + "DEBUG [EVENT]: ItemFinished: {:?}", + finished_invalidation + )); + let _ = invalidation_tx.send(finished_invalidation); + + // Send Activity event for status bar + let action = event + .data + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("synced"); + let item_type = + event.data.get("type").and_then(|v| v.as_str()); + let item_type_str = if item_type == Some("dir") + || item_path.ends_with('/') + { + "folder" + } else { + "file" + }; + + // Extract just the filename/dirname for display + let display_name = item_path + .trim_end_matches('/') + .split('/') + .next_back() + .unwrap_or(item_path); + + let event_message = format!( + "{} {} '{}'", + action.to_uppercase(), + item_type_str, + display_name + ); + + let activity_event = CacheInvalidation::Activity { + folder_id: folder_id.to_string(), + event_message, + timestamp, + }; + log_debug(&format!( + "DEBUG [EVENT]: Activity: {:?}", + activity_event + )); + let _ = invalidation_tx.send(activity_event); + + // Also send cache invalidation + let cache_invalidation = if item_type == Some("dir") + || item_path.ends_with('/') + { + CacheInvalidation::Directory { + folder_id: folder_id.to_string(), + dir_path: item_path.to_string(), + timestamp, + } + } else { + CacheInvalidation::File { + folder_id: folder_id.to_string(), + file_path: item_path.to_string(), + timestamp, + } + }; + log_debug(&format!( + "DEBUG [EVENT]: Sending cache invalidation: {:?}", + cache_invalidation + )); + let _ = invalidation_tx.send(cache_invalidation); } } "LocalChangeDetected" | "RemoteChangeDetected" => { if let Some(folder_id) = event.data.get("folder").and_then(|v| v.as_str()) - { - if let Some(item_path) = + && let Some(item_path) = event.data.get("item").and_then(|v| v.as_str()) + { + let timestamp = parse_event_time(&event.time); + // Check if it's a directory + let item_type = + event.data.get("type").and_then(|v| v.as_str()); + + let invalidation = if item_type == Some("dir") + || item_path.ends_with('/') { - let timestamp = parse_event_time(&event.time); - // Check if it's a directory - let item_type = - event.data.get("type").and_then(|v| v.as_str()); - - let invalidation = if item_type == Some("dir") - || item_path.ends_with('/') - { - // Directory change - invalidate entire directory - CacheInvalidation::Directory { - folder_id: folder_id.to_string(), - dir_path: item_path.to_string(), - timestamp, - } - } else { - // File change - invalidate single file - CacheInvalidation::File { - folder_id: folder_id.to_string(), - file_path: item_path.to_string(), - timestamp, - } - }; + // Directory change - invalidate entire directory + CacheInvalidation::Directory { + folder_id: folder_id.to_string(), + dir_path: item_path.to_string(), + timestamp, + } + } else { + // File change - invalidate single file + CacheInvalidation::File { + folder_id: folder_id.to_string(), + file_path: item_path.to_string(), + timestamp, + } + }; - log_debug(&format!( - "DEBUG [EVENT]: Sending invalidation: {:?}", - invalidation - )); - let _ = invalidation_tx.send(invalidation); - } + log_debug(&format!( + "DEBUG [EVENT]: Sending invalidation: {:?}", + invalidation + )); + let _ = invalidation_tx.send(invalidation); } } "RemoteIndexUpdated" => { diff --git a/src/ui/breadcrumb.rs b/src/ui/breadcrumb.rs index 85b3051..fb4c18d 100644 --- a/src/ui/breadcrumb.rs +++ b/src/ui/breadcrumb.rs @@ -2,11 +2,11 @@ use super::icons::IconRenderer; use crate::api::{BrowseItem, SyncState}; use ::stui::DisplayMode; use ratatui::{ + Frame, layout::{Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState}, - Frame, }; use unicode_width::UnicodeWidthStr; diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 28e0de7..8a44332 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -1,4 +1,5 @@ use ratatui::{ + Frame, layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -6,13 +7,22 @@ use ratatui::{ Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, }, - Frame, }; use super::icons::IconRenderer; use crate::model::FileInfoPopupState; use crate::utils; -use crate::{api::Device, ImagePreviewState}; +use crate::{ImagePreviewState, api::Device}; + +/// Create a centered rectangle within the given area +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + Rect { + x: (area.width.saturating_sub(width)) / 2, + y: (area.height.saturating_sub(height)) / 2, + width, + height, + } +} /// Render the revert confirmation dialog (for restoring deleted files in receive-only folders) pub fn render_revert_confirmation(f: &mut Frame, changed_files: &[String]) { @@ -44,12 +54,7 @@ pub fn render_revert_confirmation(f: &mut Frame, changed_files: &[String]) { let base_height = 10; let file_lines = changed_files.len().min(5); let prompt_height = base_height + file_lines as u16; - let prompt_area = Rect { - x: (area.width.saturating_sub(prompt_width)) / 2, - y: (area.height.saturating_sub(prompt_height)) / 2, - width: prompt_width, - height: prompt_height, - }; + let prompt_area = centered_rect(area, prompt_width, prompt_height); let prompt = Paragraph::new(prompt_text) .block( @@ -83,12 +88,7 @@ pub fn render_delete_confirmation(f: &mut Frame, display_name: &str, is_dir: boo let area = f.area(); let prompt_width = 50; let prompt_height = 11; - let prompt_area = Rect { - x: (area.width.saturating_sub(prompt_width)) / 2, - y: (area.height.saturating_sub(prompt_height)) / 2, - width: prompt_width, - height: prompt_height, - }; + let prompt_area = centered_rect(area, prompt_width, prompt_height); let prompt = Paragraph::new(prompt_text) .block( @@ -121,12 +121,7 @@ pub fn render_pause_resume_confirmation(f: &mut Frame, folder_label: &str, is_pa let area = f.area(); let prompt_width = 50; let prompt_height = 10; - let prompt_area = Rect { - x: (area.width.saturating_sub(prompt_width)) / 2, - y: (area.height.saturating_sub(prompt_height)) / 2, - width: prompt_width, - height: prompt_height, - }; + let prompt_area = centered_rect(area, prompt_width, prompt_height); let border_color = if is_paused { Color::Green @@ -337,19 +332,17 @@ fn render_metadata_column( ])); // Image resolution (if this is an image with loaded metadata) - if state.is_image { - if let Some( + if state.is_image + && let Some( crate::ImagePreviewState::Ready { metadata, .. } | crate::ImagePreviewState::Failed { metadata }, ) = image_state_map.get(&state.file_path) - { - if let Some((width, height)) = metadata.dimensions { - lines.push(Line::from(vec![ - Span::styled("Resolution: ", Style::default().fg(Color::Yellow)), - Span::raw(format!("{}x{}", width, height)), - ])); - } - } + && let Some((width, height)) = metadata.dimensions + { + lines.push(Line::from(vec![ + Span::styled("Resolution: ", Style::default().fg(Color::Yellow)), + Span::raw(format!("{}x{}", width, height)), + ])); } lines.push(Line::from("")); @@ -395,31 +388,31 @@ fn render_metadata_column( lines.push(Line::from("")); // Sync status comparison (more user-friendly than sequence numbers) - if let Some(local) = &details.local { - if let Some(global) = &details.global { - let (status_text, sync_state) = if local.sequence == global.sequence { - ("In Sync", crate::api::SyncState::Synced) - } else if local.sequence < global.sequence { - ("Behind (needs update)", crate::api::SyncState::OutOfSync) - } else { - ("Ahead (local changes)", crate::api::SyncState::OutOfSync) - }; - - // Get icon span from icon_renderer (returns [file_icon, status_icon]) - let icon_spans = icon_renderer.item_with_sync_state(false, sync_state); - - let mut status_spans = vec![Span::styled( - "Sync Status: ", - Style::default().fg(Color::Yellow), - )]; - // Add just the status icon (second element, skip the file icon) - if icon_spans.len() > 1 { - status_spans.push(icon_spans[1].clone()); - } - status_spans.push(Span::raw(status_text)); - - lines.push(Line::from(status_spans)); + if let Some(local) = &details.local + && let Some(global) = &details.global + { + let (status_text, sync_state) = if local.sequence == global.sequence { + ("In Sync", crate::api::SyncState::Synced) + } else if local.sequence < global.sequence { + ("Behind (needs update)", crate::api::SyncState::OutOfSync) + } else { + ("Ahead (local changes)", crate::api::SyncState::OutOfSync) + }; + + // Get icon span from icon_renderer (returns [file_icon, status_icon]) + let icon_spans = icon_renderer.item_with_sync_state(false, sync_state); + + let mut status_spans = vec![Span::styled( + "Sync Status: ", + Style::default().fg(Color::Yellow), + )]; + // Add just the status icon (second element, skip the file icon) + if icon_spans.len() > 1 { + status_spans.push(icon_spans[1].clone()); } + status_spans.push(Span::raw(status_text)); + + lines.push(Line::from(status_spans)); } lines.push(Line::from("")); @@ -524,7 +517,7 @@ fn render_preview_column( } ImagePreviewState::Ready { .. } => { // Get mutable reference to protocol for rendering - if let Some(ImagePreviewState::Ready { + if let Some(&mut ImagePreviewState::Ready { ref mut protocol, ref metadata, }) = image_state_map.get_mut(&state.file_path) @@ -869,12 +862,7 @@ pub fn render_rescan_confirmation(f: &mut Frame, folder_label: &str) { let area = f.area(); let dialog_width = 52; let dialog_height = 9; - let dialog_area = Rect { - x: (area.width.saturating_sub(dialog_width)) / 2, - y: (area.height.saturating_sub(dialog_height)) / 2, - width: dialog_width, - height: dialog_height, - }; + let dialog_area = centered_rect(area, dialog_width, dialog_height); f.render_widget(Clear, dialog_area); f.render_widget(paragraph, dialog_area); diff --git a/src/ui/folder_history.rs b/src/ui/folder_history.rs index 5ef285e..a6595cb 100644 --- a/src/ui/folder_history.rs +++ b/src/ui/folder_history.rs @@ -6,13 +6,13 @@ use crate::logic::formatting::format_human_size; use crate::model::types::FolderHistoryModal; use crate::ui::icons::IconRenderer; use ratatui::{ + Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, widgets::{ Block, Borders, Clear, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState, }, - Frame, }; /// Render the folder update history modal diff --git a/src/ui/folder_list.rs b/src/ui/folder_list.rs index c00d241..5802a0c 100644 --- a/src/ui/folder_list.rs +++ b/src/ui/folder_list.rs @@ -1,118 +1,212 @@ -use super::icons::{FolderState, IconRenderer}; -use super::status_bar::map_folder_state; +use super::icons::IconRenderer; use crate::api::{Folder, FolderStatus}; +use crate::logic::folder_card::{ + FolderCardState, calculate_folder_card_state, format_file_count, format_folder_type, + format_out_of_sync_details, format_size, format_status_message, +}; use ratatui::{ + Frame, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState}, - Frame, }; use std::collections::HashMap; -/// Render the folder list panel +/// Render folder cards with inline stats and status #[allow(clippy::too_many_arguments)] pub fn render_folder_list( f: &mut Frame, area: Rect, folders: &[Folder], folder_statuses: &HashMap, - statuses_loaded: bool, + _statuses_loaded: bool, folders_state: &mut ListState, is_focused: bool, - icon_renderer: &IconRenderer, - last_folder_updates: &HashMap, + _icon_renderer: &IconRenderer, + _last_folder_updates: &HashMap, ) { - // Render Folders List - let folders_items: Vec = folders + // Calculate title with folder counts + let title = calculate_folder_list_title(folders, folder_statuses); + + // Calculate maximum column widths for alignment + let max_size_width = folders + .iter() + .map(|f| { + folder_statuses + .get(&f.id) + .map(|s| format_size(s.global_bytes).len()) + .unwrap_or(3) // "..." length + }) + .max() + .unwrap_or(8); + + let max_count_width = folders + .iter() + .map(|f| { + folder_statuses + .get(&f.id) + .map(|s| format_file_count(s.global_files).len()) + .unwrap_or(3) // "..." length + }) + .max() + .unwrap_or(11); + + // Render cards as multi-line ListItems + let folder_items: Vec = folders .iter() .map(|folder| { - let display_name = folder.label.as_ref().unwrap_or(&folder.id); - - // Determine folder state - let folder_state = if !statuses_loaded { - FolderState::Loading - } else if folder.paused { - FolderState::Paused - } else if let Some(status) = folder_statuses.get(&folder.id) { - // Use the same mapping logic as status bar for consistency - let api_state = if status.state.is_empty() { - "paused" - } else { - &status.state - }; - - let (state, _label) = map_folder_state( - api_state, - status.receive_only_total_items, - status.need_total_items, - ); - state - } else { - FolderState::Error - }; - - // Build the folder display with optional last update info - let mut icon_spans = icon_renderer.folder_with_status(folder_state); - icon_spans.push(Span::raw(display_name)); - let folder_line = Line::from(icon_spans); - - // Add last update info if available - if let Some((timestamp, last_file)) = last_folder_updates.get(&folder.id) { - // Calculate time since last update - let elapsed = timestamp - .elapsed() - .unwrap_or(std::time::Duration::from_secs(0)); - let time_str = if elapsed.as_secs() < 60 { - format!("{}s ago", elapsed.as_secs()) - } else if elapsed.as_secs() < 3600 { - format!("{}m ago", elapsed.as_secs() / 60) - } else if elapsed.as_secs() < 86400 { - format!("{}h ago", elapsed.as_secs() / 3600) - } else { - format!("{}d ago", elapsed.as_secs() / 86400) - }; + let status = folder_statuses.get(&folder.id); + let card_state = calculate_folder_card_state(folder, status); - // Truncate filename if too long - let max_file_len = 40; - let file_display = if last_file.len() > max_file_len { - format!("...{}", &last_file[last_file.len() - max_file_len..]) - } else { - last_file.clone() - }; - - // Multi-line item with update info - ListItem::new(vec![ - folder_line, - Line::from(Span::styled( - format!(" ↳ {} - {}", time_str, file_display), - Style::default().fg(Color::Rgb(150, 150, 150)), // Medium gray visible on both dark gray and black backgrounds - )), - ]) - } else { - // Single-line item without update info - ListItem::new(folder_line) - } + render_folder_card(folder, status, &card_state, max_size_width, max_count_width) }) .collect(); - let folders_list = List::new(folders_items) + let folders_list = List::new(folder_items) .block( Block::default() - .title("Folders") .borders(Borders::ALL) + .title(title) .border_style(if is_focused { Style::default().fg(Color::Cyan) } else { - Style::default().fg(Color::Gray) + Style::default() }), ) .highlight_style( Style::default() - .bg(Color::DarkGray) + .fg(Color::Cyan) .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("> "); + ); f.render_stateful_widget(folders_list, area, folders_state); } + +/// Calculate folder list title with counts +fn calculate_folder_list_title( + folders: &[Folder], + statuses: &HashMap, +) -> String { + let total = folders.len(); + let synced = folders + .iter() + .filter(|f| { + if f.paused { + return false; + } + if let Some(status) = statuses.get(&f.id) { + status.state == "idle" + && status.need_total_items == 0 + && status.receive_only_total_items == 0 + } else { + false + } + }) + .count(); + + let syncing = folders + .iter() + .filter(|f| { + if let Some(status) = statuses.get(&f.id) { + status.state == "syncing" || status.state == "sync-preparing" + } else { + false + } + }) + .count(); + + let paused = folders.iter().filter(|f| f.paused).count(); + + let mut parts = vec![format!("{} total", total)]; + if synced > 0 { + parts.push(format!("{} synced", synced)); + } + if syncing > 0 { + parts.push(format!("{} syncing", syncing)); + } + if paused > 0 { + parts.push(format!("{} paused", paused)); + } + + format!(" Folders ({}) ", parts.join(", ")) +} + +/// Render a single folder card as a multi-line ListItem +fn render_folder_card( + folder: &Folder, + status: Option<&FolderStatus>, + state: &FolderCardState, + max_size_width: usize, + max_count_width: usize, +) -> ListItem<'static> { + let mut lines = Vec::new(); + + // Line 1: Folder icon + sync status + name + let display_name = folder.label.as_ref().unwrap_or(&folder.id); + let icon = match state { + FolderCardState::Synced => "✅", + FolderCardState::OutOfSync { .. } => "⚠️", + FolderCardState::Syncing { .. } => "🔄", + FolderCardState::Paused => "⏸", + FolderCardState::Error => "❌", + FolderCardState::Loading => "⏳", + }; + + lines.push(Line::from(vec![ + Span::raw("📁"), + Span::raw(icon), + Span::raw(" "), + Span::raw(display_name.to_string()), + ])); + + // Line 2: Type | Size | File Count | Status + // Use fixed width for Type (14) and dynamic widths for Size/Count based on actual data + let folder_type_str = format_folder_type(&folder.folder_type); + let size_str = status + .map(|s| format_size(s.global_bytes)) + .unwrap_or_else(|| "...".to_string()); + let file_count_str = status + .map(|s| format_file_count(s.global_files)) + .unwrap_or_else(|| "...".to_string()); + let status_msg = format_status_message(state); + + lines.push(Line::from(format!( + " {:<14} │ {:>width_size$} │ {:>width_count$} │ {}", + folder_type_str, + size_str, + file_count_str, + status_msg, + width_size = max_size_width, + width_count = max_count_width + ))); + + // Line 3 (optional): Out-of-sync details (for both OutOfSync and Syncing states) + match state { + FolderCardState::OutOfSync { + remote_needed, + local_changes, + } + | FolderCardState::Syncing { + remote_needed, + local_changes, + } => { + if let Some(status) = status + && let Some(details) = format_out_of_sync_details( + *remote_needed, + *local_changes, + status.need_bytes, + &folder.folder_type, + ) + { + lines.push(Line::from(format!(" {}", details))); + } + } + _ => {} + } + + // Line 4: Blank separator between folders + lines.push(Line::from("")); + + ListItem::new(lines) +} diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 4172d33..1ba6eff 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -15,6 +15,7 @@ pub enum IconMode { /// Folder states for rendering #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FolderState { + #[allow(dead_code)] Loading, Paused, Syncing, @@ -240,4 +241,20 @@ impl IconRenderer { Span::styled(icon, Style::default().fg(color)) } + + /// Render clock icon for activity status + pub fn render_clock(&self) -> &'static str { + match self.mode { + IconMode::Emoji => "🕐", + IconMode::NerdFont => "\u{F017}", // clock icon + } + } + + /// Render devices/network icon for device count + pub fn render_devices(&self) -> &'static str { + match self.mode { + IconMode::Emoji => "📡", + IconMode::NerdFont => "\u{F6FF}", // network-wired icon + } + } } diff --git a/src/ui/legend.rs b/src/ui/legend.rs index f5c7245..57c8ed8 100644 --- a/src/ui/legend.rs +++ b/src/ui/legend.rs @@ -1,9 +1,9 @@ use ratatui::{ + Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, - Frame, }; /// Build hotkey spans (extracted for testability) @@ -43,8 +43,6 @@ fn build_hotkey_spans( // Folder-specific actions - only in folder view (focus_level == 0) if focus_level == 0 { hotkey_spans.extend(vec![ - Span::styled("f", Style::default().fg(Color::Yellow)), - Span::raw(":Summary "), Span::styled("u", Style::default().fg(Color::Yellow)), Span::raw(":Updates "), Span::styled("c", Style::default().fg(Color::Yellow)), diff --git a/src/ui/out_of_sync_summary.rs b/src/ui/out_of_sync_summary.rs index e0ba2e4..569747d 100644 --- a/src/ui/out_of_sync_summary.rs +++ b/src/ui/out_of_sync_summary.rs @@ -1,9 +1,9 @@ use ratatui::{ + Frame, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem}, - Frame, }; use crate::api::Folder; diff --git a/src/ui/render.rs b/src/ui/render.rs index a553982..cb6e3ce 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -94,6 +94,9 @@ pub fn render(f: &mut Frame, app: &mut App) { app.model.performance.cache_hit, pending_operations_count, out_of_sync_filter_active, + &app.model.ui.folder_activity, + &app.model.syncthing.last_folder_updates, + app.model.syncthing.connected_device_count, ); // Determine if search should be visible @@ -124,25 +127,25 @@ pub fn render(f: &mut Frame, app: &mut App) { ); // Render folders pane if visible - if layout_info.folders_visible { - if let Some(folders_area) = layout_info.folders_area { - // Create temporary ListState for rendering - let mut temp_state = ratatui::widgets::ListState::default(); - temp_state.select(app.model.navigation.folders_state_selection); - folder_list::render_folder_list( - f, - folders_area, - &app.model.syncthing.folders, - &app.model.syncthing.folder_statuses, - app.model.syncthing.statuses_loaded, - &mut temp_state, - app.model.navigation.focus_level == 0, - &app.icon_renderer, - &app.model.syncthing.last_folder_updates, - ); - // Sync back the selection (though folder_list doesn't usually modify it) - app.model.navigation.folders_state_selection = temp_state.selected(); - } + if layout_info.folders_visible + && let Some(folders_area) = layout_info.folders_area + { + // Create temporary ListState for rendering + let mut temp_state = ratatui::widgets::ListState::default(); + temp_state.select(app.model.navigation.folders_state_selection); + folder_list::render_folder_list( + f, + folders_area, + &app.model.syncthing.folders, + &app.model.syncthing.folder_statuses, + app.model.syncthing.statuses_loaded, + &mut temp_state, + app.model.navigation.focus_level == 0, + &app.icon_renderer, + &app.model.syncthing.last_folder_updates, + ); + // Sync back the selection (though folder_list doesn't usually modify it) + app.model.navigation.folders_state_selection = temp_state.selected(); } // Render breadcrumb levels @@ -264,6 +267,9 @@ pub fn render(f: &mut Frame, app: &mut App) { app.model.performance.cache_hit, pending_operations_count, out_of_sync_filter_active, + &app.model.ui.folder_activity, + &app.model.syncthing.last_folder_updates, + app.model.syncthing.connected_device_count, ); // Render confirmation dialogs if active diff --git a/src/ui/search.rs b/src/ui/search.rs index 86dd93c..4cd96a7 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -3,11 +3,11 @@ //! Renders the search input box with query, match count, and blinking cursor. use ratatui::{ + Frame, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, - Frame, }; /// Render search input box above legend diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index d7f86fc..819c47b 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -2,14 +2,43 @@ use crate::api::{Folder, FolderStatus, SyncState}; use crate::ui::icons::{FolderState, IconRenderer}; use crate::utils; use ratatui::{ + Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, - Frame, }; use std::collections::HashMap; +/// Represents the content for a status bar with left and right sections +struct StatusContent { + left: String, + right: String, +} + +impl StatusContent { + /// Format status content into a single line with padding + fn format(&self, area: Rect) -> String { + use unicode_width::UnicodeWidthStr; + + let total_width = area.width.saturating_sub(2) as usize; // Subtract borders + let left_width = self.left.width(); + let right_width = self.right.width(); + let separator = " │ "; + let separator_width = separator.width(); + + let padding = total_width.saturating_sub(left_width + right_width + separator_width); + + format!( + "{}{}{}{}", + self.left, + " ".repeat(padding), + separator, + self.right + ) + } +} + /// Map SyncState to user-friendly label for file/directory display fn map_sync_state_label(sync_state: SyncState) -> &'static str { match sync_state { @@ -69,6 +98,7 @@ pub fn map_folder_state( /// Build the status bar paragraph (reusable for both rendering and height calculation) #[allow(clippy::too_many_arguments)] pub fn build_status_paragraph( + area: Rect, icon_renderer: &IconRenderer, focus_level: usize, folders: &[Folder], @@ -84,24 +114,40 @@ pub fn build_status_paragraph( cache_hit: Option, pending_operations_count: usize, out_of_sync_filter_active: bool, + folder_activity: &HashMap, + last_folder_updates: &HashMap, + connected_device_count: Option, ) -> Paragraph<'static> { - let status_line = build_status_line( - icon_renderer, - focus_level, - folders, - folder_statuses, - folders_state_selected, - breadcrumb_folder_label, - breadcrumb_folder_id, - breadcrumb_item_count, - breadcrumb_selected_item, - sort_mode, - sort_reverse, - last_load_time_ms, - cache_hit, - pending_operations_count, - out_of_sync_filter_active, - ); + // Use activity status for folder view, breadcrumb status otherwise + let status_line = if focus_level == 0 { + let content = build_activity_status( + icon_renderer, + folders, + folders_state_selected, + folder_activity, + last_folder_updates, + connected_device_count, + ); + content.format(area) + } else { + build_status_line( + icon_renderer, + focus_level, + folders, + folder_statuses, + folders_state_selected, + breadcrumb_folder_label, + breadcrumb_folder_id, + breadcrumb_item_count, + breadcrumb_selected_item, + sort_mode, + sort_reverse, + last_load_time_ms, + cache_hit, + pending_operations_count, + out_of_sync_filter_active, + ) + }; // Parse status_line and color the labels (before colons) let status_spans: Vec = if status_line.is_empty() { @@ -145,6 +191,88 @@ pub fn build_status_paragraph( .wrap(Wrap { trim: false }) } +/// Build activity status content for folder view (focus_level == 0) +/// +/// Shows recent file activity and connected device count +fn build_activity_status( + icon_renderer: &IconRenderer, + folders: &[Folder], + folders_state_selected: Option, + folder_activity: &HashMap, + last_folder_updates: &HashMap, + connected_device_count: Option, +) -> StatusContent { + use crate::logic::formatting::format_time_since; + + // Use icon renderer for symbols + let clock_icon = icon_renderer.render_clock(); + let devices_icon = icon_renderer.render_devices(); + + // Determine which activity to show based on selection state + let (event, timestamp) = if let Some(selected_idx) = folders_state_selected { + // A folder is selected - ONLY show that folder's activity + if let Some(folder) = folders.get(selected_idx) { + // Try folder_activity first (from live events), fallback to last_folder_updates (from /rest/stats/folder) + folder_activity + .get(&folder.id) + .map(|(event, timestamp)| (Some(event.as_str()), Some(*timestamp))) + .or_else(|| { + last_folder_updates + .get(&folder.id) + .map(|(timestamp, filename)| (Some(filename.as_str()), Some(*timestamp))) + }) + .unwrap_or((None, None)) + } else { + (None, None) + } + } else { + // No folder selected - show nothing + (None, None) + }; + + let left = if let (Some(event), Some(timestamp)) = (event, timestamp) { + format!( + "{} Last activity: {} • {}", + clock_icon, + event, + format_time_since(timestamp) + ) + } else { + format!("{} No recent activity", clock_icon) + }; + + // Show folder-specific device count if a folder is selected + let right = if let Some(selected_idx) = folders_state_selected { + if let Some(folder) = folders.get(selected_idx) { + let device_count = folder.devices.len(); + if device_count == 0 { + format!("{} Not shared", devices_icon) + } else if device_count == 1 { + format!("{} Shared with 1 device", devices_icon) + } else { + format!("{} Shared with {} devices", devices_icon, device_count) + } + } else { + format!("{} ...", devices_icon) + } + } else { + // No folder selected - show global count + if let Some(count) = connected_device_count { + if count == 0 { + format!("{} 0 devices", devices_icon) + } else if count == 1 { + format!("{} 1 device connected", devices_icon) + } else { + format!("{} {} devices connected", devices_icon, count) + } + } else { + format!("{} ...", devices_icon) + } + }; + + StatusContent { left, right } +} + /// Build the status line string (extracted for reuse) #[allow(clippy::too_many_arguments)] fn build_status_line( @@ -358,15 +486,15 @@ fn build_status_line( metrics.push(format!("Selected: {}", formatted_name)); // Add ignored status if applicable - if sync_state == Some(SyncState::Ignored) { - if let Some(exists_val) = exists { - let ignored_status = if exists_val { - "Ignored, not deleted!" - } else { - "Ignored" - }; - metrics.push(ignored_status.to_string()); - } + if sync_state == Some(SyncState::Ignored) + && let Some(exists_val) = exists + { + let ignored_status = if exists_val { + "Ignored, not deleted!" + } else { + "Ignored" + }; + metrics.push(ignored_status.to_string()); } } @@ -396,8 +524,12 @@ pub fn render_status_bar( cache_hit: Option, pending_operations_count: usize, out_of_sync_filter_active: bool, + folder_activity: &HashMap, + last_folder_updates: &HashMap, + connected_device_count: Option, ) { let status_bar = build_status_paragraph( + area, icon_renderer, focus_level, folders, @@ -413,6 +545,9 @@ pub fn render_status_bar( cache_hit, pending_operations_count, out_of_sync_filter_active, + folder_activity, + last_folder_updates, + connected_device_count, ); f.render_widget(status_bar, area); } @@ -436,25 +571,41 @@ pub fn calculate_status_height( cache_hit: Option, pending_operations_count: usize, out_of_sync_filter_active: bool, + folder_activity: &HashMap, + last_folder_updates: &HashMap, + connected_device_count: Option, ) -> u16 { // Build status line WITHOUT block borders for accurate line counting - let status_line = build_status_line( - icon_renderer, - focus_level, - folders, - folder_statuses, - folders_state_selected, - breadcrumb_folder_label, - breadcrumb_folder_id, - breadcrumb_item_count, - breadcrumb_selected_item, - sort_mode, - sort_reverse, - last_load_time_ms, - cache_hit, - pending_operations_count, - out_of_sync_filter_active, - ); + let area = Rect::new(0, 0, terminal_width, 3); // Dummy rect for width calculation + let status_line = if focus_level == 0 { + let content = build_activity_status( + icon_renderer, + folders, + folders_state_selected, + folder_activity, + last_folder_updates, + connected_device_count, + ); + content.format(area) + } else { + build_status_line( + icon_renderer, + focus_level, + folders, + folder_statuses, + folders_state_selected, + breadcrumb_folder_label, + breadcrumb_folder_id, + breadcrumb_item_count, + breadcrumb_selected_item, + sort_mode, + sort_reverse, + last_load_time_ms, + cache_hit, + pending_operations_count, + out_of_sync_filter_active, + ) + }; // Parse status_line and color the labels (same as in build_status_paragraph) let status_spans: Vec = if status_line.is_empty() { diff --git a/src/ui/system_bar.rs b/src/ui/system_bar.rs index 687e5b2..85b2c33 100644 --- a/src/ui/system_bar.rs +++ b/src/ui/system_bar.rs @@ -1,11 +1,11 @@ use crate::api::SystemStatus; use crate::model::syncthing::ConnectionState; use ratatui::{ + Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, - Frame, }; /// Format uptime seconds into human-readable string (e.g., "3d 15h", "15h 44m", "44m 30s") diff --git a/src/ui/toast.rs b/src/ui/toast.rs index ca4a183..432ac0f 100644 --- a/src/ui/toast.rs +++ b/src/ui/toast.rs @@ -1,9 +1,9 @@ use ratatui::{ + Frame, layout::{Alignment, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, - Frame, }; /// Render a toast notification (brief pop-up message) diff --git a/tests/out_of_sync_filter_test.rs b/tests/out_of_sync_filter_test.rs index d4c14bc..e201705 100644 --- a/tests/out_of_sync_filter_test.rs +++ b/tests/out_of_sync_filter_test.rs @@ -10,7 +10,7 @@ //! 3. Expected: Only "foo" should be visible (it's the only out-of-sync file) use stui::api::BrowseItem; -use stui::model::{types::OutOfSyncFilterState, Model}; +use stui::model::{Model, types::OutOfSyncFilterState}; /// Test: Filter state should be tracked when activated #[test] diff --git a/tests/reconnection_test.rs b/tests/reconnection_test.rs index 4efa216..57c6ba6 100644 --- a/tests/reconnection_test.rs +++ b/tests/reconnection_test.rs @@ -4,7 +4,7 @@ //! 1. App starts with Syncthing down → shows setup help dialog //! 2. Syncthing comes back online → folders populate + dialog dismisses -use stui::model::{syncthing::ConnectionState, Model}; +use stui::model::{Model, syncthing::ConnectionState}; /// Test: When app starts disconnected with no folders, setup help should be shown #[test] @@ -81,6 +81,7 @@ fn test_folder_population_dismisses_setup_help() { path: "/data/test1".to_string(), folder_type: "sendreceive".to_string(), paused: false, + devices: vec![], }, stui::api::Folder { id: "test-folder-2".to_string(), @@ -88,6 +89,7 @@ fn test_folder_population_dismisses_setup_help() { path: "/data/test2".to_string(), folder_type: "sendreceive".to_string(), paused: false, + devices: vec![], }, ]; @@ -143,6 +145,7 @@ fn test_complete_reconnection_flow() { path: "/data/test".to_string(), folder_type: "sendreceive".to_string(), paused: false, + devices: vec![], }]; if !model.syncthing.folders.is_empty() { @@ -180,6 +183,7 @@ fn test_reconnection_with_existing_folders_no_flag() { path: "/data/existing".to_string(), folder_type: "sendreceive".to_string(), paused: false, + devices: vec![], }]; model.ui.needs_folder_refresh = false; @@ -361,6 +365,7 @@ fn test_complete_flow_after_dismissed_dialog() { path: "/data/test".to_string(), folder_type: "sendreceive".to_string(), paused: false, + devices: vec![], }]; // Select first folder