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
349 lines
11 KiB
Rust
349 lines
11 KiB
Rust
//! File History screen state — per-file MR timeline with rename tracking.
|
|
//!
|
|
//! Shows which MRs touched a file over time, resolving renames via BFS.
|
|
//! 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// State for the File History screen.
|
|
#[derive(Debug, Default)]
|
|
pub struct FileHistoryState {
|
|
/// User-entered file path.
|
|
pub path_input: String,
|
|
/// Cursor position within `path_input` (byte offset).
|
|
pub path_cursor: usize,
|
|
/// Whether the path input field has keyboard focus.
|
|
pub path_focused: bool,
|
|
|
|
/// The most recent result (None until first query).
|
|
pub result: Option<FileHistoryResult>,
|
|
|
|
/// Index of the currently selected MR in the result list.
|
|
pub selected_mr_index: usize,
|
|
/// Vertical scroll offset for the MR list.
|
|
pub scroll_offset: u16,
|
|
|
|
/// Whether to follow rename chains (default true).
|
|
pub follow_renames: bool,
|
|
/// Whether to show only merged MRs (default false).
|
|
pub merged_only: bool,
|
|
/// Whether to show inline discussion snippets (default false).
|
|
pub show_discussions: bool,
|
|
|
|
/// Cached list of known file paths for autocomplete.
|
|
pub known_paths: Vec<String>,
|
|
/// Filtered autocomplete matches for current input.
|
|
pub autocomplete_matches: Vec<String>,
|
|
/// Currently highlighted autocomplete suggestion index.
|
|
pub autocomplete_index: usize,
|
|
|
|
/// Monotonic generation counter for stale-response detection.
|
|
pub generation: u64,
|
|
/// Whether a query is currently in-flight.
|
|
pub loading: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Result types (local to TUI — avoids coupling to CLI command structs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Full result of a file-history query.
|
|
#[derive(Debug)]
|
|
pub struct FileHistoryResult {
|
|
/// The queried file path.
|
|
pub path: String,
|
|
/// Resolved rename chain (may be just the original path).
|
|
pub rename_chain: Vec<String>,
|
|
/// Whether renames were actually followed.
|
|
pub renames_followed: bool,
|
|
/// MRs that touched any path in the rename chain.
|
|
pub merge_requests: Vec<FileHistoryMr>,
|
|
/// DiffNote discussion snippets on the file (when requested).
|
|
pub discussions: Vec<FileDiscussion>,
|
|
/// Total MR count (may exceed displayed count if limited).
|
|
pub total_mrs: usize,
|
|
/// Number of distinct file paths searched.
|
|
pub paths_searched: usize,
|
|
}
|
|
|
|
/// A single MR that touched the file.
|
|
#[derive(Debug)]
|
|
pub struct FileHistoryMr {
|
|
pub iid: i64,
|
|
pub title: String,
|
|
/// "merged", "opened", or "closed".
|
|
pub state: String,
|
|
pub author_username: String,
|
|
/// "added", "modified", "deleted", or "renamed".
|
|
pub change_type: String,
|
|
pub merged_at_ms: Option<i64>,
|
|
pub updated_at_ms: i64,
|
|
pub merge_commit_sha: Option<String>,
|
|
}
|
|
|
|
/// A DiffNote discussion snippet on the file.
|
|
#[derive(Debug)]
|
|
pub struct FileDiscussion {
|
|
pub discussion_id: String,
|
|
pub author_username: String,
|
|
pub body_snippet: String,
|
|
pub path: String,
|
|
pub created_at_ms: i64,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl FileHistoryState {
|
|
/// Enter the screen: focus the path input.
|
|
pub fn enter(&mut self) {
|
|
self.path_focused = true;
|
|
self.path_cursor = self.path_input.len();
|
|
}
|
|
|
|
/// Leave the screen: blur all inputs.
|
|
pub fn leave(&mut self) {
|
|
self.path_focused = false;
|
|
}
|
|
|
|
/// Whether any text input has focus.
|
|
#[must_use]
|
|
pub fn has_text_focus(&self) -> bool {
|
|
self.path_focused
|
|
}
|
|
|
|
/// Blur all inputs.
|
|
pub fn blur(&mut self) {
|
|
self.path_focused = false;
|
|
}
|
|
|
|
/// Submit the current path (trigger a query).
|
|
/// Returns the generation for stale detection.
|
|
pub fn submit(&mut self) -> u64 {
|
|
self.loading = true;
|
|
self.bump_generation()
|
|
}
|
|
|
|
/// Apply query results if generation matches.
|
|
pub fn apply_results(&mut self, generation: u64, result: FileHistoryResult) {
|
|
if generation != self.generation {
|
|
return; // Stale response — discard.
|
|
}
|
|
self.result = Some(result);
|
|
self.loading = false;
|
|
self.selected_mr_index = 0;
|
|
self.scroll_offset = 0;
|
|
}
|
|
|
|
/// Toggle follow_renames. Returns new generation for re-query.
|
|
pub fn toggle_follow_renames(&mut self) -> u64 {
|
|
self.follow_renames = !self.follow_renames;
|
|
self.bump_generation()
|
|
}
|
|
|
|
/// Toggle merged_only. Returns new generation for re-query.
|
|
pub fn toggle_merged_only(&mut self) -> u64 {
|
|
self.merged_only = !self.merged_only;
|
|
self.bump_generation()
|
|
}
|
|
|
|
/// Toggle show_discussions. Returns new generation for re-query.
|
|
pub fn toggle_show_discussions(&mut self) -> u64 {
|
|
self.show_discussions = !self.show_discussions;
|
|
self.bump_generation()
|
|
}
|
|
|
|
// --- Input field operations ---
|
|
|
|
/// Insert a char at cursor.
|
|
pub fn insert_char(&mut self, c: char) {
|
|
if self.path_focused {
|
|
self.path_input.insert(self.path_cursor, c);
|
|
self.path_cursor += c.len_utf8();
|
|
}
|
|
}
|
|
|
|
/// Delete the char before cursor.
|
|
pub fn delete_char_before_cursor(&mut self) {
|
|
if self.path_focused && self.path_cursor > 0 {
|
|
let prev = prev_char_boundary(&self.path_input, self.path_cursor);
|
|
self.path_input.drain(prev..self.path_cursor);
|
|
self.path_cursor = prev;
|
|
}
|
|
}
|
|
|
|
/// Move cursor left.
|
|
pub fn cursor_left(&mut self) {
|
|
if self.path_focused && self.path_cursor > 0 {
|
|
self.path_cursor = prev_char_boundary(&self.path_input, self.path_cursor);
|
|
}
|
|
}
|
|
|
|
/// Move cursor right.
|
|
pub fn cursor_right(&mut self) {
|
|
if self.path_focused && self.path_cursor < self.path_input.len() {
|
|
self.path_cursor = next_char_boundary(&self.path_input, self.path_cursor);
|
|
}
|
|
}
|
|
|
|
// --- Selection navigation ---
|
|
|
|
/// Move selection up.
|
|
pub fn select_prev(&mut self) {
|
|
self.selected_mr_index = self.selected_mr_index.saturating_sub(1);
|
|
}
|
|
|
|
/// Move selection down (bounded by result count).
|
|
pub fn select_next(&mut self, result_count: usize) {
|
|
if result_count > 0 {
|
|
self.selected_mr_index = (self.selected_mr_index + 1).min(result_count - 1);
|
|
}
|
|
}
|
|
|
|
/// Ensure the selected row is visible within the viewport.
|
|
pub fn ensure_visible(&mut self, viewport_height: usize) {
|
|
if viewport_height == 0 {
|
|
return;
|
|
}
|
|
let offset = self.scroll_offset as usize;
|
|
if self.selected_mr_index < offset {
|
|
self.scroll_offset = self.selected_mr_index as u16;
|
|
} else if self.selected_mr_index >= offset + viewport_height {
|
|
self.scroll_offset = (self.selected_mr_index - viewport_height + 1) as u16;
|
|
}
|
|
}
|
|
|
|
// --- Internal ---
|
|
|
|
fn bump_generation(&mut self) -> u64 {
|
|
self.generation += 1;
|
|
self.generation
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_state() {
|
|
let state = FileHistoryState::default();
|
|
assert!(state.path_input.is_empty());
|
|
assert!(!state.path_focused);
|
|
assert!(state.result.is_none());
|
|
assert!(!state.follow_renames); // Default false, toggled on by user
|
|
assert!(!state.merged_only);
|
|
assert!(!state.show_discussions);
|
|
assert_eq!(state.generation, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_enter_focuses_path() {
|
|
let mut state = FileHistoryState {
|
|
path_input: "src/lib.rs".into(),
|
|
..FileHistoryState::default()
|
|
};
|
|
state.enter();
|
|
assert!(state.path_focused);
|
|
assert_eq!(state.path_cursor, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_submit_bumps_generation() {
|
|
let mut state = FileHistoryState::default();
|
|
let generation = state.submit();
|
|
assert_eq!(generation, 1);
|
|
assert!(state.loading);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stale_response_discarded() {
|
|
let mut state = FileHistoryState::default();
|
|
let stale_gen = state.submit();
|
|
// Bump again (user toggled an option).
|
|
let _new_gen = state.toggle_merged_only();
|
|
// Stale result arrives.
|
|
state.apply_results(
|
|
stale_gen,
|
|
FileHistoryResult {
|
|
path: "src/lib.rs".into(),
|
|
rename_chain: vec!["src/lib.rs".into()],
|
|
renames_followed: false,
|
|
merge_requests: vec![],
|
|
discussions: vec![],
|
|
total_mrs: 0,
|
|
paths_searched: 1,
|
|
},
|
|
);
|
|
assert!(state.result.is_none()); // Discarded.
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_options_bump_generation() {
|
|
let mut state = FileHistoryState::default();
|
|
let g1 = state.toggle_follow_renames();
|
|
assert_eq!(g1, 1);
|
|
assert!(state.follow_renames);
|
|
|
|
let g2 = state.toggle_merged_only();
|
|
assert_eq!(g2, 2);
|
|
assert!(state.merged_only);
|
|
|
|
let g3 = state.toggle_show_discussions();
|
|
assert_eq!(g3, 3);
|
|
assert!(state.show_discussions);
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_and_delete_char() {
|
|
let mut state = FileHistoryState {
|
|
path_focused: true,
|
|
..FileHistoryState::default()
|
|
};
|
|
state.insert_char('s');
|
|
state.insert_char('r');
|
|
state.insert_char('c');
|
|
assert_eq!(state.path_input, "src");
|
|
assert_eq!(state.path_cursor, 3);
|
|
|
|
state.delete_char_before_cursor();
|
|
assert_eq!(state.path_input, "sr");
|
|
assert_eq!(state.path_cursor, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_prev_next() {
|
|
let mut state = FileHistoryState::default();
|
|
state.select_next(5);
|
|
assert_eq!(state.selected_mr_index, 1);
|
|
state.select_next(5);
|
|
assert_eq!(state.selected_mr_index, 2);
|
|
state.select_prev();
|
|
assert_eq!(state.selected_mr_index, 1);
|
|
state.select_prev();
|
|
assert_eq!(state.selected_mr_index, 0);
|
|
state.select_prev(); // Should not underflow.
|
|
assert_eq!(state.selected_mr_index, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_visible() {
|
|
let mut state = FileHistoryState {
|
|
selected_mr_index: 15,
|
|
..FileHistoryState::default()
|
|
};
|
|
state.ensure_visible(5);
|
|
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
|
|
}
|
|
}
|