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:
teernisse
2026-02-18 23:40:30 -05:00
parent 418417b0f4
commit 146eb61623
45 changed files with 5216 additions and 207 deletions

View 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");
}
}

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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,

View 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"
}
}

View 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.0100.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);
}
}

View File

@@ -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(_)));
}
}

View 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());
}
}

View File

@@ -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();
}
}

View File

@@ -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
// ---------------------------------------------------------------------------