Compare commits
4 Commits
146eb61623
...
bbfcfa2082
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfcfa2082 | ||
|
|
45a989637c | ||
|
|
1b66b80ac4 | ||
|
|
09ffcfcf0f |
@@ -172,7 +172,15 @@ pub fn fetch_recent_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunI
|
||||
let run_id: Option<String> = row.get(8)?;
|
||||
|
||||
Ok((
|
||||
id, status, command, started_at, finished_at, items, errors, error, run_id,
|
||||
id,
|
||||
status,
|
||||
command,
|
||||
started_at,
|
||||
finished_at,
|
||||
items,
|
||||
errors,
|
||||
error,
|
||||
run_id,
|
||||
))
|
||||
})
|
||||
.context("querying sync runs")?;
|
||||
@@ -265,6 +273,7 @@ mod tests {
|
||||
.expect("insert project");
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_sync_run(
|
||||
conn: &Connection,
|
||||
started_at: i64,
|
||||
@@ -314,8 +323,26 @@ mod tests {
|
||||
create_sync_schema(&conn);
|
||||
|
||||
let now = 1_700_000_000_000_i64;
|
||||
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "succeeded", "sync", 100, 0, None);
|
||||
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "failed", "sync", 50, 2, Some("timeout"));
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 60_000,
|
||||
Some(now - 30_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
100,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 120_000,
|
||||
Some(now - 90_000),
|
||||
"failed",
|
||||
"sync",
|
||||
50,
|
||||
2,
|
||||
Some("timeout"),
|
||||
);
|
||||
|
||||
let clock = FakeClock::from_ms(now);
|
||||
let result = detect_running_sync(&conn, &clock).unwrap();
|
||||
@@ -386,8 +413,26 @@ mod tests {
|
||||
create_sync_schema(&conn);
|
||||
|
||||
let now = 1_700_000_000_000_i64;
|
||||
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "succeeded", "sync", 100, 0, None);
|
||||
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "succeeded", "sync", 200, 0, None);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 120_000,
|
||||
Some(now - 90_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
100,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 60_000,
|
||||
Some(now - 30_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
200,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
|
||||
let runs = fetch_recent_runs(&conn, 10).unwrap();
|
||||
assert_eq!(runs.len(), 2);
|
||||
@@ -425,7 +470,16 @@ mod tests {
|
||||
create_sync_schema(&conn);
|
||||
|
||||
let now = 1_700_000_000_000_i64;
|
||||
insert_sync_run(&conn, now - 60_000, Some(now - 15_000), "succeeded", "sync", 0, 0, None);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 60_000,
|
||||
Some(now - 15_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
|
||||
let runs = fetch_recent_runs(&conn, 10).unwrap();
|
||||
assert_eq!(runs[0].duration_ms, Some(45_000));
|
||||
@@ -517,8 +571,26 @@ mod tests {
|
||||
|
||||
let now = 1_700_000_000_000_i64;
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "succeeded", "sync", 150, 0, None);
|
||||
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "failed", "sync", 50, 2, Some("db locked"));
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 120_000,
|
||||
Some(now - 90_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
150,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 60_000,
|
||||
Some(now - 30_000),
|
||||
"failed",
|
||||
"sync",
|
||||
50,
|
||||
2,
|
||||
Some("db locked"),
|
||||
);
|
||||
|
||||
let clock = FakeClock::from_ms(now);
|
||||
let overview = fetch_sync_overview(&conn, &clock).unwrap();
|
||||
@@ -542,7 +614,16 @@ mod tests {
|
||||
insert_project(&conn, 1, "group/repo");
|
||||
|
||||
// A completed run.
|
||||
insert_sync_run(&conn, now - 600_000, Some(now - 570_000), "succeeded", "sync", 200, 0, None);
|
||||
insert_sync_run(
|
||||
&conn,
|
||||
now - 600_000,
|
||||
Some(now - 570_000),
|
||||
"succeeded",
|
||||
"sync",
|
||||
200,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
|
||||
// A currently running sync.
|
||||
conn.execute(
|
||||
|
||||
@@ -590,7 +590,10 @@ impl LoreApp {
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
Msg::SearchExecuted { generation, results } => {
|
||||
Msg::SearchExecuted {
|
||||
generation,
|
||||
results,
|
||||
} => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::Search), generation)
|
||||
|
||||
@@ -154,8 +154,16 @@ mod tests {
|
||||
// Insert a 4th item: should evict issue(2) (tick 2, lowest).
|
||||
cache.put(issue(4), "d"); // tick 5
|
||||
|
||||
assert_eq!(cache.get(&issue(1)), Some(&"a"), "issue(1) should survive (recently accessed)");
|
||||
assert_eq!(cache.get(&issue(2)), None, "issue(2) should be evicted (LRU)");
|
||||
assert_eq!(
|
||||
cache.get(&issue(1)),
|
||||
Some(&"a"),
|
||||
"issue(1) should survive (recently accessed)"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.get(&issue(2)),
|
||||
None,
|
||||
"issue(2) should be evicted (LRU)"
|
||||
);
|
||||
assert_eq!(cache.get(&issue(3)), Some(&"c"), "issue(3) should survive");
|
||||
assert_eq!(cache.get(&issue(4)), Some(&"d"), "issue(4) just inserted");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,84 @@ pub const fn show_preview_pane(bp: Breakpoint) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-screen responsive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether detail views (issue/MR) should show a side panel for discussions.
|
||||
///
|
||||
/// At `Lg`+ widths, enough room exists for a 60/40 or 50/50 split with
|
||||
/// description on the left and discussions/cross-refs on the right.
|
||||
#[inline]
|
||||
pub const fn detail_side_panel(bp: Breakpoint) -> bool {
|
||||
match bp {
|
||||
Breakpoint::Lg | Breakpoint::Xl => true,
|
||||
Breakpoint::Xs | Breakpoint::Sm | Breakpoint::Md => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of stat columns for the Stats/Doctor screens.
|
||||
///
|
||||
/// - `Xs` / `Sm`: 1 column (full-width stacked)
|
||||
/// - `Md`+: 2 columns (side-by-side sections)
|
||||
#[inline]
|
||||
pub const fn info_screen_columns(bp: Breakpoint) -> u16 {
|
||||
match bp {
|
||||
Breakpoint::Xs | Breakpoint::Sm => 1,
|
||||
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to show the project path column in search results.
|
||||
///
|
||||
/// On narrow terminals, the project path is dropped to give the title
|
||||
/// more room.
|
||||
#[inline]
|
||||
pub const fn search_show_project(bp: Breakpoint) -> bool {
|
||||
match bp {
|
||||
Breakpoint::Xs | Breakpoint::Sm => false,
|
||||
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Width allocated for the relative-time column in timeline events.
|
||||
///
|
||||
/// Narrow terminals get a compact time (e.g., "2h"), wider terminals
|
||||
/// get the full relative time (e.g., "2 hours ago").
|
||||
#[inline]
|
||||
pub const fn timeline_time_width(bp: Breakpoint) -> u16 {
|
||||
match bp {
|
||||
Breakpoint::Xs => 5,
|
||||
Breakpoint::Sm => 8,
|
||||
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to use abbreviated mode-tab labels in the Who screen.
|
||||
///
|
||||
/// On narrow terminals, tabs are shortened to 3-char abbreviations
|
||||
/// (e.g., "Exp" instead of "Expert") to fit all 5 modes.
|
||||
#[inline]
|
||||
pub const fn who_abbreviated_tabs(bp: Breakpoint) -> bool {
|
||||
match bp {
|
||||
Breakpoint::Xs | Breakpoint::Sm => true,
|
||||
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the progress bar in the Sync screen.
|
||||
///
|
||||
/// Scales with terminal width to use available space effectively.
|
||||
#[inline]
|
||||
pub const fn sync_progress_bar_width(bp: Breakpoint) -> u16 {
|
||||
match bp {
|
||||
Breakpoint::Xs => 15,
|
||||
Breakpoint::Sm => 25,
|
||||
Breakpoint::Md => 35,
|
||||
Breakpoint::Lg | Breakpoint::Xl => 50,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,4 +177,60 @@ mod tests {
|
||||
fn test_lore_breakpoints_matches_defaults() {
|
||||
assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT);
|
||||
}
|
||||
|
||||
// -- Per-screen responsive helpers ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_detail_side_panel() {
|
||||
assert!(!detail_side_panel(Breakpoint::Xs));
|
||||
assert!(!detail_side_panel(Breakpoint::Sm));
|
||||
assert!(!detail_side_panel(Breakpoint::Md));
|
||||
assert!(detail_side_panel(Breakpoint::Lg));
|
||||
assert!(detail_side_panel(Breakpoint::Xl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_info_screen_columns() {
|
||||
assert_eq!(info_screen_columns(Breakpoint::Xs), 1);
|
||||
assert_eq!(info_screen_columns(Breakpoint::Sm), 1);
|
||||
assert_eq!(info_screen_columns(Breakpoint::Md), 2);
|
||||
assert_eq!(info_screen_columns(Breakpoint::Lg), 2);
|
||||
assert_eq!(info_screen_columns(Breakpoint::Xl), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_show_project() {
|
||||
assert!(!search_show_project(Breakpoint::Xs));
|
||||
assert!(!search_show_project(Breakpoint::Sm));
|
||||
assert!(search_show_project(Breakpoint::Md));
|
||||
assert!(search_show_project(Breakpoint::Lg));
|
||||
assert!(search_show_project(Breakpoint::Xl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_time_width() {
|
||||
assert_eq!(timeline_time_width(Breakpoint::Xs), 5);
|
||||
assert_eq!(timeline_time_width(Breakpoint::Sm), 8);
|
||||
assert_eq!(timeline_time_width(Breakpoint::Md), 12);
|
||||
assert_eq!(timeline_time_width(Breakpoint::Lg), 12);
|
||||
assert_eq!(timeline_time_width(Breakpoint::Xl), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_abbreviated_tabs() {
|
||||
assert!(who_abbreviated_tabs(Breakpoint::Xs));
|
||||
assert!(who_abbreviated_tabs(Breakpoint::Sm));
|
||||
assert!(!who_abbreviated_tabs(Breakpoint::Md));
|
||||
assert!(!who_abbreviated_tabs(Breakpoint::Lg));
|
||||
assert!(!who_abbreviated_tabs(Breakpoint::Xl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_progress_bar_width() {
|
||||
assert_eq!(sync_progress_bar_width(Breakpoint::Xs), 15);
|
||||
assert_eq!(sync_progress_bar_width(Breakpoint::Sm), 25);
|
||||
assert_eq!(sync_progress_bar_width(Breakpoint::Md), 35);
|
||||
assert_eq!(sync_progress_bar_width(Breakpoint::Lg), 50);
|
||||
assert_eq!(sync_progress_bar_width(Breakpoint::Xl), 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +104,7 @@ impl<V> RenderCache<V> {
|
||||
///
|
||||
/// After a resize, only entries rendered at the new width are still valid.
|
||||
pub fn invalidate_width(&mut self, keep_width: u16) {
|
||||
self.entries
|
||||
.retain(|k, _| k.terminal_width == keep_width);
|
||||
self.entries.retain(|k, _| k.terminal_width == keep_width);
|
||||
}
|
||||
|
||||
/// Clear the entire cache (theme change — all colors invalidated).
|
||||
|
||||
@@ -95,8 +95,8 @@ pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionErro
|
||||
fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| SessionError::Serialize(e.to_string()))?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(state).map_err(|e| SessionError::Serialize(e.to_string()))?;
|
||||
|
||||
// Check size before writing.
|
||||
if json.len() as u64 > MAX_SESSION_SIZE {
|
||||
@@ -112,8 +112,7 @@ pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionErro
|
||||
|
||||
// Write to temp file, fsync, rename.
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let mut file =
|
||||
fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
let mut file = fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
file.write_all(payload.as_bytes())
|
||||
.map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
file.sync_all()
|
||||
@@ -179,10 +178,7 @@ pub fn load_session(path: &Path) -> Result<SessionState, SessionError> {
|
||||
|
||||
/// Move a corrupt session file to `.quarantine/` instead of deleting it.
|
||||
fn quarantine(path: &Path) -> Result<(), SessionError> {
|
||||
let quarantine_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(".quarantine");
|
||||
let quarantine_dir = path.parent().unwrap_or(Path::new(".")).join(".quarantine");
|
||||
fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?;
|
||||
|
||||
let filename = path
|
||||
|
||||
@@ -22,13 +22,13 @@ pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
pub mod mr_list;
|
||||
pub mod scope_picker;
|
||||
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};
|
||||
@@ -45,12 +45,12 @@ pub use issue_detail::IssueDetailState;
|
||||
pub use issue_list::IssueListState;
|
||||
pub use mr_detail::MrDetailState;
|
||||
pub use mr_list::MrListState;
|
||||
pub use scope_picker::ScopePickerState;
|
||||
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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -76,12 +76,17 @@ impl ScopePickerState {
|
||||
project_id: None,
|
||||
project_name: None,
|
||||
}
|
||||
} else {
|
||||
let project = &self.projects[self.selected_index - 1];
|
||||
} else if let Some(project) = self.projects.get(self.selected_index - 1) {
|
||||
ScopeContext {
|
||||
project_id: Some(project.id),
|
||||
project_name: Some(project.path.clone()),
|
||||
}
|
||||
} else {
|
||||
// Out-of-bounds — fall back to "All Projects".
|
||||
ScopeContext {
|
||||
project_id: None,
|
||||
project_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -356,12 +356,12 @@ impl SyncState {
|
||||
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;
|
||||
}
|
||||
if items > 0
|
||||
&& 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,8 +375,7 @@ impl SyncState {
|
||||
/// 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();
|
||||
let active_lanes: Vec<&LaneProgress> = self.lanes.iter().filter(|l| l.total > 0).collect();
|
||||
if active_lanes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -537,10 +536,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
// With ~0ms between calls, at most 0-1 additional emits expected.
|
||||
assert!(
|
||||
emitted <= 1,
|
||||
"Expected at most 1 emit, got {emitted}"
|
||||
);
|
||||
assert!(emitted <= 1, "Expected at most 1 emit, got {emitted}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,7 @@ use ftui::render::drawing::{BorderChars, Draw};
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::command_palette::CommandPaletteState;
|
||||
use crate::text_width::cursor_cell_offset;
|
||||
|
||||
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
|
||||
|
||||
@@ -16,14 +17,6 @@ fn text_cell_width(text: &str) -> u16 {
|
||||
text.chars().count().min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
|
||||
let mut idx = cursor.min(query.len());
|
||||
while idx > 0 && !query.is_char_boundary(idx) {
|
||||
idx -= 1;
|
||||
}
|
||||
text_cell_width(&query[..idx])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_command_palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -127,7 +127,9 @@ pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
|
||||
let detail = if check.detail.len() > max_detail {
|
||||
format!(
|
||||
"{}...",
|
||||
&check.detail[..check.detail.floor_char_boundary(max_detail.saturating_sub(3))]
|
||||
&check.detail[..check
|
||||
.detail
|
||||
.floor_char_boundary(max_detail.saturating_sub(3))]
|
||||
)
|
||||
} else {
|
||||
check.detail.clone()
|
||||
|
||||
@@ -21,8 +21,9 @@ use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
|
||||
use super::common::truncate_str;
|
||||
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
|
||||
use crate::text_width::cursor_cell_offset;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (Flexoki palette)
|
||||
@@ -137,8 +138,7 @@ fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y:
|
||||
|
||||
// Cursor indicator.
|
||||
if state.path_focused {
|
||||
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16;
|
||||
let cursor_x = after_label + cursor_col;
|
||||
let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
|
||||
if cursor_x < max_x {
|
||||
let cursor_cell = Cell {
|
||||
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg
|
||||
|
||||
@@ -16,12 +16,12 @@ pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
pub mod mr_list;
|
||||
pub mod search;
|
||||
pub mod timeline;
|
||||
pub mod trace;
|
||||
pub mod scope_picker;
|
||||
pub mod search;
|
||||
pub mod stats;
|
||||
pub mod sync;
|
||||
pub mod timeline;
|
||||
pub mod trace;
|
||||
pub mod who;
|
||||
|
||||
use ftui::layout::{Constraint, Flex};
|
||||
@@ -43,12 +43,12 @@ use issue_detail::render_issue_detail;
|
||||
use issue_list::render_issue_list;
|
||||
use mr_detail::render_mr_detail;
|
||||
use mr_list::render_mr_list;
|
||||
use search::render_search;
|
||||
use timeline::render_timeline;
|
||||
use trace::render_trace;
|
||||
use scope_picker::render_scope_picker;
|
||||
use search::render_search;
|
||||
use stats::render_stats;
|
||||
use sync::render_sync;
|
||||
use timeline::render_timeline;
|
||||
use trace::render_trace;
|
||||
use who::render_who;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -261,10 +261,7 @@ mod tests {
|
||||
let has_content = (20..60u16).any(|x| {
|
||||
(8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty()))
|
||||
});
|
||||
assert!(
|
||||
has_content,
|
||||
"Expected sync idle content in center area"
|
||||
);
|
||||
assert!(has_content, "Expected sync idle content in center area");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::{BorderChars, Draw};
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::scope_picker::ScopePickerState;
|
||||
use crate::state::ScopeContext;
|
||||
use crate::state::scope_picker::ScopePickerState;
|
||||
|
||||
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
|
||||
|
||||
@@ -131,7 +131,10 @@ pub fn render_scope_picker(
|
||||
// Truncate label to fit.
|
||||
let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix
|
||||
let display = if label.len() > max_label_len {
|
||||
format!("{prefix}{}...", &label[..label.floor_char_boundary(max_label_len.saturating_sub(3))])
|
||||
format!(
|
||||
"{prefix}{}...",
|
||||
&label[..label.floor_char_boundary(max_label_len.saturating_sub(3))]
|
||||
)
|
||||
} else {
|
||||
format!("{prefix}{label}")
|
||||
};
|
||||
|
||||
@@ -18,24 +18,11 @@
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::Cell;
|
||||
use ftui::render::drawing::Draw;
|
||||
|
||||
/// Count display-width columns for a string (char count, not byte count).
|
||||
fn text_cell_width(text: &str) -> u16 {
|
||||
text.chars().count().min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
/// Convert a byte-offset cursor position to a display-column offset.
|
||||
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
|
||||
let mut idx = cursor.min(query.len());
|
||||
while idx > 0 && !query.is_char_boundary(idx) {
|
||||
idx -= 1;
|
||||
}
|
||||
text_cell_width(&query[..idx])
|
||||
}
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::message::EntityKind;
|
||||
use crate::state::search::SearchState;
|
||||
use crate::text_width::cursor_cell_offset;
|
||||
|
||||
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
|
||||
|
||||
|
||||
@@ -93,7 +93,15 @@ pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
|
||||
if y >= area.bottom().saturating_sub(2) {
|
||||
break;
|
||||
}
|
||||
render_stat_row(frame, area.x + 2, y, label, &format_count(*count), label_width, max_x);
|
||||
render_stat_row(
|
||||
frame,
|
||||
area.x + 2,
|
||||
y,
|
||||
label,
|
||||
&format_count(*count),
|
||||
label_width,
|
||||
max_x,
|
||||
);
|
||||
y += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -262,8 +262,16 @@ fn render_summary(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
|
||||
// Summary rows.
|
||||
let rows = [
|
||||
("Issues", summary.issues.new, summary.issues.updated),
|
||||
("MRs", summary.merge_requests.new, summary.merge_requests.updated),
|
||||
("Discussions", summary.discussions.new, summary.discussions.updated),
|
||||
(
|
||||
"MRs",
|
||||
summary.merge_requests.new,
|
||||
summary.merge_requests.updated,
|
||||
),
|
||||
(
|
||||
"Discussions",
|
||||
summary.discussions.new,
|
||||
summary.discussions.updated,
|
||||
),
|
||||
("Notes", summary.notes.new, summary.notes.updated),
|
||||
];
|
||||
|
||||
@@ -404,7 +412,10 @@ fn render_failed(frame: &mut Frame<'_>, area: Rect, error: &str) {
|
||||
// Truncate error to fit screen.
|
||||
let max_len = area.width.saturating_sub(4) as usize;
|
||||
let display_err = if error.len() > max_len {
|
||||
format!("{}...", &error[..error.floor_char_boundary(max_len.saturating_sub(3))])
|
||||
format!(
|
||||
"{}...",
|
||||
&error[..error.floor_char_boundary(max_len.saturating_sub(3))]
|
||||
)
|
||||
} else {
|
||||
error.to_string()
|
||||
};
|
||||
@@ -541,9 +552,7 @@ mod tests {
|
||||
state.complete(3000);
|
||||
state.summary = Some(SyncSummary {
|
||||
elapsed_ms: 3000,
|
||||
project_errors: vec![
|
||||
("grp/repo".into(), "timeout".into()),
|
||||
],
|
||||
project_errors: vec![("grp/repo".into(), "timeout".into())],
|
||||
..Default::default()
|
||||
});
|
||||
let area = frame.bounds();
|
||||
|
||||
@@ -135,8 +135,7 @@ fn render_path_input(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16,
|
||||
|
||||
// Cursor.
|
||||
if state.path_focused {
|
||||
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16;
|
||||
let cursor_x = after_label + cursor_col;
|
||||
let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
|
||||
if cursor_x < max_x {
|
||||
let cursor_cell = Cell {
|
||||
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||
|
||||
Reference in New Issue
Block a user