feat(tui): Phase 4 completion + Phase 5 session/lock/text-width
Phase 4 (bd-1df9) — all 5 acceptance criteria met: - Sync screen with delta ledger (bd-2x2h, bd-y095) - Doctor screen with health checks (bd-2iqk) - Stats screen with document counts (bd-2iqk) - CLI integration: lore tui subcommand (bd-26lp) - CLI integration: lore sync --tui flag (bd-3l56) Phase 5 (bd-3h00) — session persistence + instance lock + text width: - text_width.rs: Unicode-aware measurement, truncation, padding (16 tests) - instance_lock.rs: Advisory PID lock with stale recovery (6 tests) - session.rs: Atomic write + CRC32 checksum + quarantine (9 tests) Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
This commit is contained in:
199
crates/lore-tui/src/state/doctor.rs
Normal file
199
crates/lore-tui/src/state/doctor.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Doctor screen state — health check results.
|
||||
//!
|
||||
//! Displays a list of environment health checks with pass/warn/fail
|
||||
//! indicators. Checks are synchronous (config, DB, projects, FTS) —
|
||||
//! network checks (GitLab auth, Ollama) are not run from the TUI.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HealthStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Status of a single health check.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HealthStatus {
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl HealthStatus {
|
||||
/// Human-readable label for display.
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pass => "PASS",
|
||||
Self::Warn => "WARN",
|
||||
Self::Fail => "FAIL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HealthCheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single health check result for display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealthCheck {
|
||||
/// Check category name (e.g., "Config", "Database").
|
||||
pub name: String,
|
||||
/// Pass/warn/fail status.
|
||||
pub status: HealthStatus,
|
||||
/// Human-readable detail (e.g., path, version, count).
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DoctorState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the Doctor screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DoctorState {
|
||||
/// Health check results (empty until loaded).
|
||||
pub checks: Vec<HealthCheck>,
|
||||
/// Whether checks have been loaded at least once.
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl DoctorState {
|
||||
/// Apply loaded health check results.
|
||||
pub fn apply_checks(&mut self, checks: Vec<HealthCheck>) {
|
||||
self.checks = checks;
|
||||
self.loaded = true;
|
||||
}
|
||||
|
||||
/// Overall status — worst status across all checks.
|
||||
#[must_use]
|
||||
pub fn overall_status(&self) -> HealthStatus {
|
||||
if self.checks.iter().any(|c| c.status == HealthStatus::Fail) {
|
||||
HealthStatus::Fail
|
||||
} else if self.checks.iter().any(|c| c.status == HealthStatus::Warn) {
|
||||
HealthStatus::Warn
|
||||
} else {
|
||||
HealthStatus::Pass
|
||||
}
|
||||
}
|
||||
|
||||
/// Count of checks by status.
|
||||
#[must_use]
|
||||
pub fn count_by_status(&self, status: HealthStatus) -> usize {
|
||||
self.checks.iter().filter(|c| c.status == status).count()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_checks() -> Vec<HealthCheck> {
|
||||
vec![
|
||||
HealthCheck {
|
||||
name: "Config".into(),
|
||||
status: HealthStatus::Pass,
|
||||
detail: "/home/user/.config/lore/config.json".into(),
|
||||
},
|
||||
HealthCheck {
|
||||
name: "Database".into(),
|
||||
status: HealthStatus::Pass,
|
||||
detail: "schema v12".into(),
|
||||
},
|
||||
HealthCheck {
|
||||
name: "Projects".into(),
|
||||
status: HealthStatus::Warn,
|
||||
detail: "0 projects configured".into(),
|
||||
},
|
||||
HealthCheck {
|
||||
name: "FTS Index".into(),
|
||||
status: HealthStatus::Fail,
|
||||
detail: "No documents indexed".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_state() {
|
||||
let state = DoctorState::default();
|
||||
assert!(state.checks.is_empty());
|
||||
assert!(!state.loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_checks() {
|
||||
let mut state = DoctorState::default();
|
||||
state.apply_checks(sample_checks());
|
||||
assert!(state.loaded);
|
||||
assert_eq!(state.checks.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overall_status_fail_wins() {
|
||||
let mut state = DoctorState::default();
|
||||
state.apply_checks(sample_checks());
|
||||
assert_eq!(state.overall_status(), HealthStatus::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overall_status_all_pass() {
|
||||
let mut state = DoctorState::default();
|
||||
state.apply_checks(vec![
|
||||
HealthCheck {
|
||||
name: "Config".into(),
|
||||
status: HealthStatus::Pass,
|
||||
detail: "ok".into(),
|
||||
},
|
||||
HealthCheck {
|
||||
name: "Database".into(),
|
||||
status: HealthStatus::Pass,
|
||||
detail: "ok".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(state.overall_status(), HealthStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overall_status_warn_without_fail() {
|
||||
let mut state = DoctorState::default();
|
||||
state.apply_checks(vec![
|
||||
HealthCheck {
|
||||
name: "Config".into(),
|
||||
status: HealthStatus::Pass,
|
||||
detail: "ok".into(),
|
||||
},
|
||||
HealthCheck {
|
||||
name: "Ollama".into(),
|
||||
status: HealthStatus::Warn,
|
||||
detail: "not running".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(state.overall_status(), HealthStatus::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overall_status_empty_is_pass() {
|
||||
let state = DoctorState::default();
|
||||
assert_eq!(state.overall_status(), HealthStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_by_status() {
|
||||
let mut state = DoctorState::default();
|
||||
state.apply_checks(sample_checks());
|
||||
assert_eq!(state.count_by_status(HealthStatus::Pass), 2);
|
||||
assert_eq!(state.count_by_status(HealthStatus::Warn), 1);
|
||||
assert_eq!(state.count_by_status(HealthStatus::Fail), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_status_labels() {
|
||||
assert_eq!(HealthStatus::Pass.label(), "PASS");
|
||||
assert_eq!(HealthStatus::Warn.label(), "WARN");
|
||||
assert_eq!(HealthStatus::Fail.label(), "FAIL");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//! Users enter a file path, toggle options (follow renames, merged only,
|
||||
//! show discussions), and browse a chronological MR list.
|
||||
|
||||
use crate::text_width::{next_char_boundary, prev_char_boundary};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileHistoryState
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,24 +227,6 @@ impl FileHistoryState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte offset of the previous char boundary.
|
||||
fn prev_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos.saturating_sub(1);
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the byte offset of the next char boundary.
|
||||
fn next_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,15 +16,19 @@
|
||||
pub mod bootstrap;
|
||||
pub mod command_palette;
|
||||
pub mod dashboard;
|
||||
pub mod doctor;
|
||||
pub mod file_history;
|
||||
pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
pub mod mr_list;
|
||||
pub mod search;
|
||||
pub mod stats;
|
||||
pub mod sync;
|
||||
pub mod sync_delta_ledger;
|
||||
pub mod timeline;
|
||||
pub mod trace;
|
||||
pub mod scope_picker;
|
||||
pub mod who;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -35,15 +39,18 @@ use crate::message::Screen;
|
||||
pub use bootstrap::BootstrapState;
|
||||
pub use command_palette::CommandPaletteState;
|
||||
pub use dashboard::DashboardState;
|
||||
pub use doctor::DoctorState;
|
||||
pub use file_history::FileHistoryState;
|
||||
pub use issue_detail::IssueDetailState;
|
||||
pub use issue_list::IssueListState;
|
||||
pub use mr_detail::MrDetailState;
|
||||
pub use mr_list::MrListState;
|
||||
pub use search::SearchState;
|
||||
pub use stats::StatsState;
|
||||
pub use sync::SyncState;
|
||||
pub use timeline::TimelineState;
|
||||
pub use trace::TraceState;
|
||||
pub use scope_picker::ScopePickerState;
|
||||
pub use who::WhoState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,17 +178,20 @@ pub struct AppState {
|
||||
// Per-screen states.
|
||||
pub bootstrap: BootstrapState,
|
||||
pub dashboard: DashboardState,
|
||||
pub doctor: DoctorState,
|
||||
pub issue_list: IssueListState,
|
||||
pub issue_detail: IssueDetailState,
|
||||
pub mr_list: MrListState,
|
||||
pub mr_detail: MrDetailState,
|
||||
pub search: SearchState,
|
||||
pub stats: StatsState,
|
||||
pub timeline: TimelineState,
|
||||
pub who: WhoState,
|
||||
pub trace: TraceState,
|
||||
pub file_history: FileHistoryState,
|
||||
pub sync: SyncState,
|
||||
pub command_palette: CommandPaletteState,
|
||||
pub scope_picker: ScopePickerState,
|
||||
|
||||
// Cross-cutting state.
|
||||
pub global_scope: ScopeContext,
|
||||
|
||||
234
crates/lore-tui/src/state/scope_picker.rs
Normal file
234
crates/lore-tui/src/state/scope_picker.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Scope picker overlay state.
|
||||
//!
|
||||
//! The scope picker lets users filter all screens to a specific project.
|
||||
//! It appears as a modal overlay when the user presses `P`.
|
||||
|
||||
use crate::scope::ProjectInfo;
|
||||
use crate::state::ScopeContext;
|
||||
|
||||
/// State for the scope picker overlay.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScopePickerState {
|
||||
/// Available projects (populated on open).
|
||||
pub projects: Vec<ProjectInfo>,
|
||||
/// Currently highlighted index (0 = "All Projects", 1..N = specific projects).
|
||||
pub selected_index: usize,
|
||||
/// Whether the picker overlay is visible.
|
||||
pub visible: bool,
|
||||
/// Scroll offset for long project lists.
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
/// Max visible rows in the picker before scrolling kicks in.
|
||||
const MAX_VISIBLE_ROWS: usize = 15;
|
||||
|
||||
impl ScopePickerState {
|
||||
/// Open the picker with the given project list.
|
||||
///
|
||||
/// Pre-selects the row matching the current scope, or "All Projects" (index 0)
|
||||
/// if no project filter is active.
|
||||
pub fn open(&mut self, projects: Vec<ProjectInfo>, current_scope: &ScopeContext) {
|
||||
self.projects = projects;
|
||||
self.visible = true;
|
||||
self.scroll_offset = 0;
|
||||
|
||||
// Pre-select the currently active scope.
|
||||
self.selected_index = match current_scope.project_id {
|
||||
None => 0, // "All Projects" row
|
||||
Some(id) => self
|
||||
.projects
|
||||
.iter()
|
||||
.position(|p| p.id == id)
|
||||
.map_or(0, |i| i + 1), // +1 because index 0 is "All Projects"
|
||||
};
|
||||
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
/// Close the picker without changing scope.
|
||||
pub fn close(&mut self) {
|
||||
self.visible = false;
|
||||
}
|
||||
|
||||
/// Move selection up.
|
||||
pub fn select_prev(&mut self) {
|
||||
if self.selected_index > 0 {
|
||||
self.selected_index -= 1;
|
||||
self.ensure_visible();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection down.
|
||||
pub fn select_next(&mut self) {
|
||||
let max_index = self.projects.len(); // 0="All" + N projects
|
||||
if self.selected_index < max_index {
|
||||
self.selected_index += 1;
|
||||
self.ensure_visible();
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the current selection and return the new scope.
|
||||
#[must_use]
|
||||
pub fn confirm(&self) -> ScopeContext {
|
||||
if self.selected_index == 0 {
|
||||
// "All Projects"
|
||||
ScopeContext {
|
||||
project_id: None,
|
||||
project_name: None,
|
||||
}
|
||||
} else {
|
||||
let project = &self.projects[self.selected_index - 1];
|
||||
ScopeContext {
|
||||
project_id: Some(project.id),
|
||||
project_name: Some(project.path.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of rows (1 for "All" + project count).
|
||||
#[must_use]
|
||||
pub fn row_count(&self) -> usize {
|
||||
1 + self.projects.len()
|
||||
}
|
||||
|
||||
/// Ensure the selected index is within the visible scroll window.
|
||||
fn ensure_visible(&mut self) {
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
} else if self.selected_index >= self.scroll_offset + MAX_VISIBLE_ROWS {
|
||||
self.scroll_offset = self.selected_index.saturating_sub(MAX_VISIBLE_ROWS - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_projects() -> Vec<ProjectInfo> {
|
||||
vec![
|
||||
ProjectInfo {
|
||||
id: 1,
|
||||
path: "alpha/repo".into(),
|
||||
},
|
||||
ProjectInfo {
|
||||
id: 2,
|
||||
path: "beta/repo".into(),
|
||||
},
|
||||
ProjectInfo {
|
||||
id: 3,
|
||||
path: "gamma/repo".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_no_scope_selects_all() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
let scope = ScopeContext::default();
|
||||
picker.open(sample_projects(), &scope);
|
||||
|
||||
assert!(picker.visible);
|
||||
assert_eq!(picker.selected_index, 0); // "All Projects"
|
||||
assert_eq!(picker.projects.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_with_scope_preselects_project() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
let scope = ScopeContext {
|
||||
project_id: Some(2),
|
||||
project_name: Some("beta/repo".into()),
|
||||
};
|
||||
picker.open(sample_projects(), &scope);
|
||||
|
||||
assert_eq!(picker.selected_index, 2); // index 1 in projects = index 2 in picker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_and_next() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
|
||||
picker.select_next();
|
||||
assert_eq!(picker.selected_index, 1);
|
||||
picker.select_next();
|
||||
assert_eq!(picker.selected_index, 2);
|
||||
picker.select_prev();
|
||||
assert_eq!(picker.selected_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_at_zero_stays() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
|
||||
picker.select_prev();
|
||||
assert_eq!(picker.selected_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_next_at_max_stays() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
|
||||
// 4 total rows (All + 3 projects), max index = 3
|
||||
for _ in 0..10 {
|
||||
picker.select_next();
|
||||
}
|
||||
assert_eq!(picker.selected_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_all_projects() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
|
||||
let scope = picker.confirm();
|
||||
assert!(scope.project_id.is_none());
|
||||
assert!(scope.project_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_specific_project() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
|
||||
picker.select_next(); // index 1 = first project (alpha/repo, id=1)
|
||||
let scope = picker.confirm();
|
||||
assert_eq!(scope.project_id, Some(1));
|
||||
assert_eq!(scope.project_name.as_deref(), Some("alpha/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_hides_picker() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
assert!(picker.visible);
|
||||
|
||||
picker.close();
|
||||
assert!(!picker.visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_row_count() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
picker.open(sample_projects(), &ScopeContext::default());
|
||||
assert_eq!(picker.row_count(), 4); // "All" + 3 projects
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_with_unknown_project_selects_all() {
|
||||
let mut picker = ScopePickerState::default();
|
||||
let scope = ScopeContext {
|
||||
project_id: Some(999), // Not in list
|
||||
project_name: Some("unknown".into()),
|
||||
};
|
||||
picker.open(sample_projects(), &scope);
|
||||
assert_eq!(picker.selected_index, 0); // Falls back to "All"
|
||||
}
|
||||
}
|
||||
153
crates/lore-tui/src/state/stats.rs
Normal file
153
crates/lore-tui/src/state/stats.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Stats screen state — database and index statistics.
|
||||
//!
|
||||
//! Shows entity counts, FTS coverage, embedding coverage, and queue
|
||||
//! health. Data is produced by synchronous DB queries.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatsData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Database statistics for TUI display.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StatsData {
|
||||
/// Total documents in the database.
|
||||
pub total_documents: i64,
|
||||
/// Issues stored.
|
||||
pub issues: i64,
|
||||
/// Merge requests stored.
|
||||
pub merge_requests: i64,
|
||||
/// Discussions stored.
|
||||
pub discussions: i64,
|
||||
/// Notes stored.
|
||||
pub notes: i64,
|
||||
/// Documents indexed in FTS.
|
||||
pub fts_indexed: i64,
|
||||
/// Documents with embeddings.
|
||||
pub embedded_documents: i64,
|
||||
/// Total embedding chunks.
|
||||
pub total_chunks: i64,
|
||||
/// Embedding coverage percentage (0.0–100.0).
|
||||
pub coverage_pct: f64,
|
||||
/// Pending queue items (dirty sources).
|
||||
pub queue_pending: i64,
|
||||
/// Failed queue items.
|
||||
pub queue_failed: i64,
|
||||
}
|
||||
|
||||
impl StatsData {
|
||||
/// FTS coverage percentage relative to total documents.
|
||||
#[must_use]
|
||||
pub fn fts_coverage_pct(&self) -> f64 {
|
||||
if self.total_documents == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.fts_indexed as f64 / self.total_documents as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether there are pending queue items that need processing.
|
||||
#[must_use]
|
||||
pub fn has_queue_work(&self) -> bool {
|
||||
self.queue_pending > 0 || self.queue_failed > 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatsState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the Stats screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StatsState {
|
||||
/// Statistics data (None until loaded).
|
||||
pub data: Option<StatsData>,
|
||||
/// Whether data has been loaded at least once.
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl StatsState {
|
||||
/// Apply loaded stats data.
|
||||
pub fn apply_data(&mut self, data: StatsData) {
|
||||
self.data = Some(data);
|
||||
self.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_stats() -> StatsData {
|
||||
StatsData {
|
||||
total_documents: 500,
|
||||
issues: 200,
|
||||
merge_requests: 150,
|
||||
discussions: 100,
|
||||
notes: 50,
|
||||
fts_indexed: 450,
|
||||
embedded_documents: 300,
|
||||
total_chunks: 1200,
|
||||
coverage_pct: 60.0,
|
||||
queue_pending: 5,
|
||||
queue_failed: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_state() {
|
||||
let state = StatsState::default();
|
||||
assert!(state.data.is_none());
|
||||
assert!(!state.loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_data() {
|
||||
let mut state = StatsState::default();
|
||||
state.apply_data(sample_stats());
|
||||
assert!(state.loaded);
|
||||
assert!(state.data.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fts_coverage_pct() {
|
||||
let stats = sample_stats();
|
||||
let pct = stats.fts_coverage_pct();
|
||||
assert!((pct - 90.0).abs() < 0.01); // 450/500 = 90%
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fts_coverage_pct_zero_documents() {
|
||||
let stats = StatsData::default();
|
||||
assert_eq!(stats.fts_coverage_pct(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_queue_work() {
|
||||
let stats = sample_stats();
|
||||
assert!(stats.has_queue_work());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_queue_work() {
|
||||
let stats = StatsData {
|
||||
queue_pending: 0,
|
||||
queue_failed: 0,
|
||||
..sample_stats()
|
||||
};
|
||||
assert!(!stats.has_queue_work());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stats_data_default() {
|
||||
let stats = StatsData::default();
|
||||
assert_eq!(stats.total_documents, 0);
|
||||
assert_eq!(stats.issues, 0);
|
||||
assert_eq!(stats.coverage_pct, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,597 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Sync screen state.
|
||||
//! Sync screen state: progress tracking, coalescing, and summary.
|
||||
//!
|
||||
//! The sync screen shows real-time progress during data synchronization
|
||||
//! and transitions to a summary view when complete. A progress coalescer
|
||||
//! prevents render thrashing from rapid progress updates.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync lanes (entity types being synced)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sync entity types that progress is tracked for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SyncLane {
|
||||
Issues,
|
||||
MergeRequests,
|
||||
Discussions,
|
||||
Notes,
|
||||
Events,
|
||||
Statuses,
|
||||
}
|
||||
|
||||
impl SyncLane {
|
||||
/// Human-readable label for this lane.
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Issues => "Issues",
|
||||
Self::MergeRequests => "MRs",
|
||||
Self::Discussions => "Discussions",
|
||||
Self::Notes => "Notes",
|
||||
Self::Events => "Events",
|
||||
Self::Statuses => "Statuses",
|
||||
}
|
||||
}
|
||||
|
||||
/// All lanes in display order.
|
||||
pub const ALL: &'static [SyncLane] = &[
|
||||
Self::Issues,
|
||||
Self::MergeRequests,
|
||||
Self::Discussions,
|
||||
Self::Notes,
|
||||
Self::Events,
|
||||
Self::Statuses,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-lane progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Progress for a single sync lane.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaneProgress {
|
||||
/// Current items processed.
|
||||
pub current: u64,
|
||||
/// Total items expected (0 = unknown).
|
||||
pub total: u64,
|
||||
/// Whether this lane has completed.
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
impl LaneProgress {
|
||||
/// Fraction complete (0.0..=1.0). Returns 0.0 if total is unknown.
|
||||
#[must_use]
|
||||
pub fn fraction(&self) -> f64 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.current as f64 / self.total as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-entity-type change counts after sync completes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EntityChangeCounts {
|
||||
pub new: u64,
|
||||
pub updated: u64,
|
||||
}
|
||||
|
||||
/// Summary of a completed sync run.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncSummary {
|
||||
pub issues: EntityChangeCounts,
|
||||
pub merge_requests: EntityChangeCounts,
|
||||
pub discussions: EntityChangeCounts,
|
||||
pub notes: EntityChangeCounts,
|
||||
pub elapsed_ms: u64,
|
||||
/// Per-project errors (project path -> error message).
|
||||
pub project_errors: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SyncSummary {
|
||||
/// Total number of changes across all entity types.
|
||||
#[must_use]
|
||||
pub fn total_changes(&self) -> u64 {
|
||||
self.issues.new
|
||||
+ self.issues.updated
|
||||
+ self.merge_requests.new
|
||||
+ self.merge_requests.updated
|
||||
+ self.discussions.new
|
||||
+ self.discussions.updated
|
||||
+ self.notes.new
|
||||
+ self.notes.updated
|
||||
}
|
||||
|
||||
/// Whether any errors occurred during sync.
|
||||
#[must_use]
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.project_errors.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync screen mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Display mode for the sync screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SyncScreenMode {
|
||||
/// Full-screen sync progress with per-lane bars.
|
||||
#[default]
|
||||
FullScreen,
|
||||
/// Compact single-line progress for embedding in Bootstrap screen.
|
||||
Inline,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync phase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current phase of the sync operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum SyncPhase {
|
||||
/// Sync hasn't started yet.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Sync is running.
|
||||
Running,
|
||||
/// Sync completed successfully.
|
||||
Complete,
|
||||
/// Sync was cancelled by user.
|
||||
Cancelled,
|
||||
/// Sync failed with an error.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress coalescer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Batches rapid progress updates to prevent render thrashing.
|
||||
///
|
||||
/// At most one update is emitted per `floor_ms`. Updates arriving faster
|
||||
/// are coalesced — only the latest value survives.
|
||||
#[derive(Debug)]
|
||||
pub struct ProgressCoalescer {
|
||||
/// Minimum interval between emitted updates.
|
||||
floor_ms: u64,
|
||||
/// Timestamp of the last emitted update.
|
||||
last_emit: Option<Instant>,
|
||||
/// Number of updates coalesced (dropped) since last emit.
|
||||
coalesced_count: u64,
|
||||
}
|
||||
|
||||
impl ProgressCoalescer {
|
||||
/// Create a new coalescer with the given floor interval in milliseconds.
|
||||
#[must_use]
|
||||
pub fn new(floor_ms: u64) -> Self {
|
||||
Self {
|
||||
floor_ms,
|
||||
last_emit: None,
|
||||
coalesced_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default coalescer with 100ms floor (10 updates/second max).
|
||||
#[must_use]
|
||||
pub fn default_floor() -> Self {
|
||||
Self::new(100)
|
||||
}
|
||||
|
||||
/// Should this update be emitted?
|
||||
///
|
||||
/// Returns `true` if enough time has elapsed since the last emit.
|
||||
/// The caller should only render/process the update when this returns true.
|
||||
pub fn should_emit(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
match self.last_emit {
|
||||
None => {
|
||||
self.last_emit = Some(now);
|
||||
self.coalesced_count = 0;
|
||||
true
|
||||
}
|
||||
Some(last) => {
|
||||
let elapsed_ms = now.duration_since(last).as_millis() as u64;
|
||||
if elapsed_ms >= self.floor_ms {
|
||||
self.last_emit = Some(now);
|
||||
self.coalesced_count = 0;
|
||||
true
|
||||
} else {
|
||||
self.coalesced_count += 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of updates that have been coalesced since the last emit.
|
||||
#[must_use]
|
||||
pub fn coalesced_count(&self) -> u64 {
|
||||
self.coalesced_count
|
||||
}
|
||||
|
||||
/// Reset the coalescer (e.g., when sync restarts).
|
||||
pub fn reset(&mut self) {
|
||||
self.last_emit = None;
|
||||
self.coalesced_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SyncState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the sync progress/summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct SyncState {
|
||||
/// Current sync phase.
|
||||
pub phase: SyncPhase,
|
||||
/// Display mode (full screen vs inline).
|
||||
pub mode: SyncScreenMode,
|
||||
/// Per-lane progress (updated during Running phase).
|
||||
pub lanes: [LaneProgress; 6],
|
||||
/// Current stage label (e.g., "Fetching issues...").
|
||||
pub stage: String,
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
/// Log lines from the sync process.
|
||||
pub log_lines: Vec<String>,
|
||||
pub completed: bool,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
/// Stream throughput stats (items per second).
|
||||
pub items_per_sec: f64,
|
||||
/// Bytes synced.
|
||||
pub bytes_synced: u64,
|
||||
/// Total items synced.
|
||||
pub items_synced: u64,
|
||||
/// When the current sync run started (for throughput calculation).
|
||||
pub started_at: Option<Instant>,
|
||||
/// Progress coalescer for render throttling.
|
||||
pub coalescer: ProgressCoalescer,
|
||||
/// Summary (populated after sync completes).
|
||||
pub summary: Option<SyncSummary>,
|
||||
/// Scroll offset for log lines view.
|
||||
pub log_scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl Default for SyncState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: SyncPhase::Idle,
|
||||
mode: SyncScreenMode::FullScreen,
|
||||
lanes: Default::default(),
|
||||
stage: String::new(),
|
||||
log_lines: Vec::new(),
|
||||
items_per_sec: 0.0,
|
||||
bytes_synced: 0,
|
||||
items_synced: 0,
|
||||
started_at: None,
|
||||
coalescer: ProgressCoalescer::default_floor(),
|
||||
summary: None,
|
||||
log_scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
/// Reset state for a new sync run.
|
||||
pub fn start(&mut self) {
|
||||
self.phase = SyncPhase::Running;
|
||||
self.lanes = Default::default();
|
||||
self.stage.clear();
|
||||
self.log_lines.clear();
|
||||
self.items_per_sec = 0.0;
|
||||
self.bytes_synced = 0;
|
||||
self.items_synced = 0;
|
||||
self.started_at = Some(Instant::now());
|
||||
self.coalescer.reset();
|
||||
self.summary = None;
|
||||
self.log_scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Apply a progress update for a specific lane.
|
||||
pub fn update_progress(&mut self, stage: &str, current: u64, total: u64) {
|
||||
self.stage = stage.to_string();
|
||||
|
||||
// Map stage name to lane index.
|
||||
if let Some(lane) = self.lane_for_stage(stage) {
|
||||
lane.current = current;
|
||||
lane.total = total;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch progress increment.
|
||||
pub fn update_batch(&mut self, stage: &str, batch_size: u64) {
|
||||
self.stage = stage.to_string();
|
||||
|
||||
if let Some(lane) = self.lane_for_stage(stage) {
|
||||
lane.current += batch_size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark sync as completed with summary.
|
||||
pub fn complete(&mut self, elapsed_ms: u64) {
|
||||
self.phase = SyncPhase::Complete;
|
||||
// Mark all lanes as done.
|
||||
for lane in &mut self.lanes {
|
||||
lane.done = true;
|
||||
}
|
||||
// Build summary from lane data if not already set.
|
||||
if self.summary.is_none() {
|
||||
self.summary = Some(SyncSummary {
|
||||
elapsed_ms,
|
||||
..Default::default()
|
||||
});
|
||||
} else if let Some(ref mut summary) = self.summary {
|
||||
summary.elapsed_ms = elapsed_ms;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark sync as cancelled.
|
||||
pub fn cancel(&mut self) {
|
||||
self.phase = SyncPhase::Cancelled;
|
||||
}
|
||||
|
||||
/// Mark sync as failed.
|
||||
pub fn fail(&mut self, error: String) {
|
||||
self.phase = SyncPhase::Failed(error);
|
||||
}
|
||||
|
||||
/// Add a log line.
|
||||
pub fn add_log_line(&mut self, line: String) {
|
||||
self.log_lines.push(line);
|
||||
// Auto-scroll to bottom.
|
||||
if self.log_lines.len() > 1 {
|
||||
self.log_scroll_offset = self.log_lines.len().saturating_sub(20);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update stream stats.
|
||||
pub fn update_stream_stats(&mut self, bytes: u64, items: u64) {
|
||||
self.bytes_synced = bytes;
|
||||
self.items_synced = items;
|
||||
// Compute actual throughput from elapsed time since sync start.
|
||||
if items > 0 {
|
||||
if let Some(started) = self.started_at {
|
||||
let elapsed_secs = started.elapsed().as_secs_f64();
|
||||
if elapsed_secs > 0.0 {
|
||||
self.items_per_sec = items as f64 / elapsed_secs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether sync is currently running.
|
||||
#[must_use]
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.phase == SyncPhase::Running
|
||||
}
|
||||
|
||||
/// Overall progress fraction (average of all lanes).
|
||||
#[must_use]
|
||||
pub fn overall_progress(&self) -> f64 {
|
||||
let active_lanes: Vec<&LaneProgress> =
|
||||
self.lanes.iter().filter(|l| l.total > 0).collect();
|
||||
if active_lanes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f64 = active_lanes.iter().map(|l| l.fraction()).sum();
|
||||
sum / active_lanes.len() as f64
|
||||
}
|
||||
|
||||
/// Map a stage name to the corresponding lane.
|
||||
fn lane_for_stage(&mut self, stage: &str) -> Option<&mut LaneProgress> {
|
||||
let lower = stage.to_lowercase();
|
||||
let idx = if lower.contains("issue") {
|
||||
Some(0)
|
||||
} else if lower.contains("merge") || lower.contains("mr") {
|
||||
Some(1)
|
||||
} else if lower.contains("discussion") {
|
||||
Some(2)
|
||||
} else if lower.contains("note") {
|
||||
Some(3)
|
||||
} else if lower.contains("event") {
|
||||
Some(4)
|
||||
} else if lower.contains("status") {
|
||||
Some(5)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
idx.map(|i| &mut self.lanes[i])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_lane_progress_fraction() {
|
||||
let lane = LaneProgress {
|
||||
current: 50,
|
||||
total: 100,
|
||||
done: false,
|
||||
};
|
||||
assert!((lane.fraction() - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lane_progress_fraction_zero_total() {
|
||||
let lane = LaneProgress::default();
|
||||
assert!((lane.fraction()).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_summary_total_changes() {
|
||||
let summary = SyncSummary {
|
||||
issues: EntityChangeCounts { new: 5, updated: 3 },
|
||||
merge_requests: EntityChangeCounts { new: 2, updated: 1 },
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(summary.total_changes(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_summary_has_errors() {
|
||||
let mut summary = SyncSummary::default();
|
||||
assert!(!summary.has_errors());
|
||||
|
||||
summary
|
||||
.project_errors
|
||||
.push(("grp/repo".into(), "timeout".into()));
|
||||
assert!(summary.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_start_resets() {
|
||||
let mut state = SyncState {
|
||||
stage: "old".into(),
|
||||
phase: SyncPhase::Complete,
|
||||
..SyncState::default()
|
||||
};
|
||||
state.log_lines.push("old log".into());
|
||||
|
||||
state.start();
|
||||
|
||||
assert_eq!(state.phase, SyncPhase::Running);
|
||||
assert!(state.stage.is_empty());
|
||||
assert!(state.log_lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_update_progress() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
|
||||
state.update_progress("Fetching issues", 10, 50);
|
||||
assert_eq!(state.lanes[0].current, 10);
|
||||
assert_eq!(state.lanes[0].total, 50);
|
||||
assert_eq!(state.stage, "Fetching issues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_update_batch() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
|
||||
state.update_batch("MR processing", 5);
|
||||
state.update_batch("MR processing", 3);
|
||||
assert_eq!(state.lanes[1].current, 8); // MR lane
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_complete() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
|
||||
state.complete(5000);
|
||||
assert_eq!(state.phase, SyncPhase::Complete);
|
||||
assert!(state.summary.is_some());
|
||||
assert_eq!(state.summary.as_ref().unwrap().elapsed_ms, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_overall_progress() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
|
||||
state.update_progress("issues", 50, 100);
|
||||
state.update_progress("merge requests", 25, 100);
|
||||
// Two active lanes: 0.5 and 0.25, average = 0.375
|
||||
assert!((state.overall_progress() - 0.375).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_overall_progress_no_active_lanes() {
|
||||
let state = SyncState::default();
|
||||
assert!((state.overall_progress()).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_coalescer_first_always_emits() {
|
||||
let mut coalescer = ProgressCoalescer::new(100);
|
||||
assert!(coalescer.should_emit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_coalescer_rapid_updates_coalesced() {
|
||||
let mut coalescer = ProgressCoalescer::new(100);
|
||||
assert!(coalescer.should_emit()); // First always emits.
|
||||
|
||||
// Rapid-fire updates within 100ms should be coalesced.
|
||||
let mut emitted = 0;
|
||||
for _ in 0..50 {
|
||||
if coalescer.should_emit() {
|
||||
emitted += 1;
|
||||
}
|
||||
}
|
||||
// With ~0ms between calls, at most 0-1 additional emits expected.
|
||||
assert!(
|
||||
emitted <= 1,
|
||||
"Expected at most 1 emit, got {emitted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_coalescer_emits_after_floor() {
|
||||
let mut coalescer = ProgressCoalescer::new(50);
|
||||
assert!(coalescer.should_emit());
|
||||
|
||||
// Wait longer than floor.
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
assert!(coalescer.should_emit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_coalescer_reset() {
|
||||
let mut coalescer = ProgressCoalescer::new(100);
|
||||
coalescer.should_emit();
|
||||
coalescer.should_emit(); // Coalesced.
|
||||
|
||||
coalescer.reset();
|
||||
assert!(coalescer.should_emit()); // Fresh start.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_lane_labels() {
|
||||
assert_eq!(SyncLane::Issues.label(), "Issues");
|
||||
assert_eq!(SyncLane::MergeRequests.label(), "MRs");
|
||||
assert_eq!(SyncLane::Notes.label(), "Notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_add_log_line() {
|
||||
let mut state = SyncState::default();
|
||||
state.add_log_line("line 1".into());
|
||||
state.add_log_line("line 2".into());
|
||||
assert_eq!(state.log_lines.len(), 2);
|
||||
assert_eq!(state.log_lines[0], "line 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_cancel() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
state.cancel();
|
||||
assert_eq!(state.phase, SyncPhase::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_fail() {
|
||||
let mut state = SyncState::default();
|
||||
state.start();
|
||||
state.fail("network timeout".into());
|
||||
assert!(matches!(state.phase, SyncPhase::Failed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
222
crates/lore-tui/src/state/sync_delta_ledger.rs
Normal file
222
crates/lore-tui/src/state/sync_delta_ledger.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Sync delta ledger — records entity changes during a sync run.
|
||||
//!
|
||||
//! After sync completes, the dashboard and list screens can query the
|
||||
//! ledger to highlight "new since last sync" items. The ledger is
|
||||
//! ephemeral (per-run, not persisted to disk).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Kind of change that occurred to an entity during sync.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChangeKind {
|
||||
New,
|
||||
Updated,
|
||||
}
|
||||
|
||||
/// Entity type for the ledger.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LedgerEntityType {
|
||||
Issue,
|
||||
MergeRequest,
|
||||
Discussion,
|
||||
Note,
|
||||
}
|
||||
|
||||
/// Per-run record of changed entity IDs during sync.
|
||||
///
|
||||
/// Used to highlight newly synced items in list/dashboard views.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncDeltaLedger {
|
||||
pub new_issue_iids: HashSet<i64>,
|
||||
pub updated_issue_iids: HashSet<i64>,
|
||||
pub new_mr_iids: HashSet<i64>,
|
||||
pub updated_mr_iids: HashSet<i64>,
|
||||
pub new_discussion_count: u64,
|
||||
pub updated_discussion_count: u64,
|
||||
pub new_note_count: u64,
|
||||
}
|
||||
|
||||
impl SyncDeltaLedger {
|
||||
/// Record a change to an entity.
|
||||
pub fn record_change(&mut self, entity_type: LedgerEntityType, iid: i64, kind: ChangeKind) {
|
||||
match (entity_type, kind) {
|
||||
(LedgerEntityType::Issue, ChangeKind::New) => {
|
||||
self.new_issue_iids.insert(iid);
|
||||
}
|
||||
(LedgerEntityType::Issue, ChangeKind::Updated) => {
|
||||
self.updated_issue_iids.insert(iid);
|
||||
}
|
||||
(LedgerEntityType::MergeRequest, ChangeKind::New) => {
|
||||
self.new_mr_iids.insert(iid);
|
||||
}
|
||||
(LedgerEntityType::MergeRequest, ChangeKind::Updated) => {
|
||||
self.updated_mr_iids.insert(iid);
|
||||
}
|
||||
(LedgerEntityType::Discussion, ChangeKind::New) => {
|
||||
self.new_discussion_count += 1;
|
||||
}
|
||||
(LedgerEntityType::Discussion, ChangeKind::Updated) => {
|
||||
self.updated_discussion_count += 1;
|
||||
}
|
||||
(LedgerEntityType::Note, ChangeKind::New) => {
|
||||
self.new_note_count += 1;
|
||||
}
|
||||
(LedgerEntityType::Note, ChangeKind::Updated) => {
|
||||
// Notes don't have a meaningful "updated" count.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a summary of changes from this sync run.
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> super::sync::SyncSummary {
|
||||
use super::sync::{EntityChangeCounts, SyncSummary};
|
||||
SyncSummary {
|
||||
issues: EntityChangeCounts {
|
||||
new: self.new_issue_iids.len() as u64,
|
||||
updated: self.updated_issue_iids.len() as u64,
|
||||
},
|
||||
merge_requests: EntityChangeCounts {
|
||||
new: self.new_mr_iids.len() as u64,
|
||||
updated: self.updated_mr_iids.len() as u64,
|
||||
},
|
||||
discussions: EntityChangeCounts {
|
||||
new: self.new_discussion_count,
|
||||
updated: self.updated_discussion_count,
|
||||
},
|
||||
notes: EntityChangeCounts {
|
||||
new: self.new_note_count,
|
||||
updated: 0,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any entity was an issue IID that was newly added in this sync.
|
||||
#[must_use]
|
||||
pub fn is_new_issue(&self, iid: i64) -> bool {
|
||||
self.new_issue_iids.contains(&iid)
|
||||
}
|
||||
|
||||
/// Whether any entity was an MR IID that was newly added in this sync.
|
||||
#[must_use]
|
||||
pub fn is_new_mr(&self, iid: i64) -> bool {
|
||||
self.new_mr_iids.contains(&iid)
|
||||
}
|
||||
|
||||
/// Total changes recorded.
|
||||
#[must_use]
|
||||
pub fn total_changes(&self) -> u64 {
|
||||
self.new_issue_iids.len() as u64
|
||||
+ self.updated_issue_iids.len() as u64
|
||||
+ self.new_mr_iids.len() as u64
|
||||
+ self.updated_mr_iids.len() as u64
|
||||
+ self.new_discussion_count
|
||||
+ self.updated_discussion_count
|
||||
+ self.new_note_count
|
||||
}
|
||||
|
||||
/// Clear the ledger for a new sync run.
|
||||
pub fn clear(&mut self) {
|
||||
self.new_issue_iids.clear();
|
||||
self.updated_issue_iids.clear();
|
||||
self.new_mr_iids.clear();
|
||||
self.updated_mr_iids.clear();
|
||||
self.new_discussion_count = 0;
|
||||
self.updated_discussion_count = 0;
|
||||
self.new_note_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_record_new_issues() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Issue, 2, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Issue, 3, ChangeKind::Updated);
|
||||
|
||||
assert_eq!(ledger.new_issue_iids.len(), 2);
|
||||
assert_eq!(ledger.updated_issue_iids.len(), 1);
|
||||
assert!(ledger.is_new_issue(1));
|
||||
assert!(ledger.is_new_issue(2));
|
||||
assert!(!ledger.is_new_issue(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_new_mrs() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::MergeRequest, 20, ChangeKind::Updated);
|
||||
|
||||
assert!(ledger.is_new_mr(10));
|
||||
assert!(!ledger.is_new_mr(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_counts() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Issue, 2, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Issue, 3, ChangeKind::Updated);
|
||||
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Discussion, 0, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
|
||||
|
||||
let summary = ledger.summary();
|
||||
assert_eq!(summary.issues.new, 2);
|
||||
assert_eq!(summary.issues.updated, 1);
|
||||
assert_eq!(summary.merge_requests.new, 1);
|
||||
assert_eq!(summary.discussions.new, 1);
|
||||
assert_eq!(summary.notes.new, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_changes() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::Updated);
|
||||
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
|
||||
|
||||
assert_eq!(ledger.total_changes(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_same_iid() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
// Recording same IID twice should deduplicate.
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
|
||||
assert_eq!(ledger.new_issue_iids.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let mut ledger = SyncDeltaLedger::default();
|
||||
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
|
||||
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
|
||||
|
||||
ledger.clear();
|
||||
|
||||
assert_eq!(ledger.total_changes(), 0);
|
||||
assert!(ledger.new_issue_iids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_ledger_summary() {
|
||||
let ledger = SyncDeltaLedger::default();
|
||||
let summary = ledger.summary();
|
||||
assert_eq!(summary.total_changes(), 0);
|
||||
assert!(!summary.has_errors());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use std::collections::HashSet;
|
||||
|
||||
use lore::core::trace::TraceResult;
|
||||
|
||||
use crate::text_width::{next_char_boundary, prev_char_boundary};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TraceState
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -18,7 +20,7 @@ use lore::core::trace::TraceResult;
|
||||
pub struct TraceState {
|
||||
/// User-entered file path (with optional :line suffix).
|
||||
pub path_input: String,
|
||||
/// Cursor position within `path_input`.
|
||||
/// Cursor position within `path_input` (byte offset).
|
||||
pub path_cursor: usize,
|
||||
/// Whether the path input field has keyboard focus.
|
||||
pub path_focused: bool,
|
||||
@@ -188,48 +190,35 @@ impl TraceState {
|
||||
|
||||
// --- Text editing helpers ---
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
/// Insert a character at the cursor position (byte offset).
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
let byte_pos = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
self.path_input.insert(byte_pos, ch);
|
||||
self.path_cursor += 1;
|
||||
self.path_input.insert(self.path_cursor, ch);
|
||||
self.path_cursor += ch.len_utf8();
|
||||
self.update_autocomplete();
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor.
|
||||
/// Delete the character before the cursor (byte offset).
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.path_cursor == 0 {
|
||||
return;
|
||||
}
|
||||
self.path_cursor -= 1;
|
||||
let byte_pos = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
let end = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor + 1)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
self.path_input.drain(byte_pos..end);
|
||||
let prev = prev_char_boundary(&self.path_input, self.path_cursor);
|
||||
self.path_input.drain(prev..self.path_cursor);
|
||||
self.path_cursor = prev;
|
||||
self.update_autocomplete();
|
||||
}
|
||||
|
||||
/// Move cursor left.
|
||||
/// Move cursor left (byte offset).
|
||||
pub fn cursor_left(&mut self) {
|
||||
self.path_cursor = self.path_cursor.saturating_sub(1);
|
||||
if self.path_cursor > 0 {
|
||||
self.path_cursor = prev_char_boundary(&self.path_input, self.path_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right.
|
||||
/// Move cursor right (byte offset).
|
||||
pub fn cursor_right(&mut self) {
|
||||
let max = self.path_input.chars().count();
|
||||
if self.path_cursor < max {
|
||||
self.path_cursor += 1;
|
||||
if self.path_cursor < self.path_input.len() {
|
||||
self.path_cursor = next_char_boundary(&self.path_input, self.path_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +255,7 @@ impl TraceState {
|
||||
pub fn accept_autocomplete(&mut self) {
|
||||
if let Some(match_) = self.autocomplete_matches.get(self.autocomplete_index) {
|
||||
self.path_input = match_.clone();
|
||||
self.path_cursor = self.path_input.chars().count();
|
||||
self.path_cursor = self.path_input.len();
|
||||
self.autocomplete_matches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
use lore::core::who_types::WhoResult;
|
||||
|
||||
use crate::text_width::{next_char_boundary, prev_char_boundary};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WhoMode
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,24 +293,6 @@ impl WhoState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte offset of the previous char boundary.
|
||||
fn prev_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos.saturating_sub(1);
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the byte offset of the next char boundary.
|
||||
fn next_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user