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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -410,6 +419,18 @@ Display visual indicators for file/folder states following `<file|dir><status>`

**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)
Expand Down Expand Up @@ -452,8 +473,8 @@ CLI flags: `--debug`, `--vim`, `--config <path>`
- `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.
Expand Down Expand Up @@ -491,6 +512,8 @@ CLI flags: `--debug`, `--vim`, `--config <path>`
### 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).

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
Expand Down
84 changes: 83 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<FolderDevice>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String, ConnectionInfo>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct LastFileInfo {
pub at: String,
Expand Down Expand Up @@ -620,6 +649,25 @@ impl SyncthingClient {
Ok(stats)
}

/// Get system connections (device connectivity and transfer stats)
pub async fn get_system_connections(&self) -> Result<ConnectionsResponse> {
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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
}
}
27 changes: 20 additions & 7 deletions src/app/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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((
Expand Down
2 changes: 1 addition & 1 deletion src/app/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
// ============================================================================
Expand Down
20 changes: 9 additions & 11 deletions src/app/folder_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)>;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/app/ignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading