Compare commits
10 Commits
ebf64816c9
...
eef73decb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef73decb5 | ||
|
|
bb6660178c | ||
|
|
64e73b1cab | ||
|
|
361757568f | ||
|
|
8572f6cc04 | ||
|
|
d0744039ef | ||
|
|
4b372dfb38 | ||
|
|
af8fc4af76 | ||
|
|
96b288ccdd | ||
|
|
d710403567 |
@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
||||
"--robot",
|
||||
"--json",
|
||||
"--color",
|
||||
"--icons",
|
||||
"--quiet",
|
||||
"--no-quiet",
|
||||
"--verbose",
|
||||
|
||||
@@ -333,7 +333,7 @@ pub fn print_count(result: &CountResult) {
|
||||
|
||||
if let Some(system_count) = result.system_count {
|
||||
println!(
|
||||
"{}: {} {}",
|
||||
"{}: {:>10} {}",
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str),
|
||||
Theme::dim().render(&format!(
|
||||
@@ -343,22 +343,22 @@ pub fn print_count(result: &CountResult) {
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {}",
|
||||
"{}: {:>10}",
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(breakdown) = &result.state_breakdown {
|
||||
println!(" opened: {}", render::format_number(breakdown.opened));
|
||||
println!(" opened: {:>10}", render::format_number(breakdown.opened));
|
||||
if let Some(merged) = breakdown.merged {
|
||||
println!(" merged: {}", render::format_number(merged));
|
||||
println!(" merged: {:>10}", render::format_number(merged));
|
||||
}
|
||||
println!(" closed: {}", render::format_number(breakdown.closed));
|
||||
println!(" closed: {:>10}", render::format_number(breakdown.closed));
|
||||
if let Some(locked) = breakdown.locked
|
||||
&& locked > 0
|
||||
{
|
||||
println!(" locked: {}", render::format_number(locked));
|
||||
println!(" locked: {:>10}", render::format_number(locked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::config::Config;
|
||||
@@ -530,7 +530,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
|
||||
}
|
||||
|
||||
pub fn print_doctor_results(result: &DoctorResult) {
|
||||
println!("\nlore doctor\n");
|
||||
println!();
|
||||
|
||||
print_check("Config", &result.checks.config.result);
|
||||
print_check("Database", &result.checks.database.result);
|
||||
@@ -539,31 +539,53 @@ pub fn print_doctor_results(result: &DoctorResult) {
|
||||
print_check("Ollama", &result.checks.ollama.result);
|
||||
print_check("Logging", &result.checks.logging.result);
|
||||
|
||||
// Count statuses
|
||||
let checks = [
|
||||
&result.checks.config.result,
|
||||
&result.checks.database.result,
|
||||
&result.checks.gitlab.result,
|
||||
&result.checks.projects.result,
|
||||
&result.checks.ollama.result,
|
||||
&result.checks.logging.result,
|
||||
];
|
||||
let passed = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Ok)
|
||||
.count();
|
||||
let warnings = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Warning)
|
||||
.count();
|
||||
let failed = checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Error)
|
||||
.count();
|
||||
|
||||
println!();
|
||||
|
||||
let mut summary_parts = Vec::new();
|
||||
if result.success {
|
||||
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
|
||||
if ollama_ok {
|
||||
println!("{}", Theme::success().render("Status: Ready"));
|
||||
summary_parts.push(Theme::success().render("Ready"));
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
Theme::success().render("Status: Ready"),
|
||||
Theme::warning()
|
||||
.render("(lexical search available, semantic search requires Ollama)")
|
||||
);
|
||||
summary_parts.push(Theme::error().render("Not ready"));
|
||||
}
|
||||
} else {
|
||||
println!("{}", Theme::error().render("Status: Not ready"));
|
||||
summary_parts.push(format!("{passed} passed"));
|
||||
if warnings > 0 {
|
||||
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
|
||||
}
|
||||
if failed > 0 {
|
||||
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
|
||||
}
|
||||
println!(" {}", summary_parts.join(" \u{b7} "));
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_check(name: &str, result: &CheckResult) {
|
||||
let symbol = match result.status {
|
||||
CheckStatus::Ok => Theme::success().render("\u{2713}"),
|
||||
CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
|
||||
CheckStatus::Error => Theme::error().render("\u{2717}"),
|
||||
let icon = match result.status {
|
||||
CheckStatus::Ok => Theme::success().render(Icons::success()),
|
||||
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
|
||||
CheckStatus::Error => Theme::error().render(Icons::error()),
|
||||
};
|
||||
|
||||
let message = result.message.as_deref().unwrap_or("");
|
||||
@@ -573,5 +595,5 @@ fn print_check(name: &str, result: &CheckResult) {
|
||||
CheckStatus::Error => Theme::error().render(message),
|
||||
};
|
||||
|
||||
println!(" {symbol} {:<10} {message_styled}", name);
|
||||
println!(" {icon} {:<10} {message_styled}", name);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::LazyLock;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::config::Config;
|
||||
use crate::core::db::create_connection;
|
||||
@@ -428,7 +428,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!();
|
||||
|
||||
if response.drift_detected {
|
||||
println!("{}", Theme::error().bold().render("DRIFT DETECTED"));
|
||||
println!(
|
||||
"{} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render("DRIFT DETECTED")
|
||||
);
|
||||
if let Some(dp) = &response.drift_point {
|
||||
println!(
|
||||
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||
@@ -439,7 +443,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||
}
|
||||
} else {
|
||||
println!("{}", Theme::success().render("No drift detected"));
|
||||
println!(
|
||||
"{} {}",
|
||||
Theme::success().render(Icons::success()),
|
||||
Theme::success().render("No drift detected")
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -450,7 +458,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!("{}", Theme::bold().render("Similarity Curve:"));
|
||||
for pt in &response.similarity_curve {
|
||||
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
||||
let bar: String = "#".repeat(bar_len);
|
||||
let bar: String = "\u{2588}".repeat(bar_len);
|
||||
println!(
|
||||
" {:>3} {:.2} {} @{}",
|
||||
pt.note_index, pt.similarity, bar, pt.author
|
||||
|
||||
@@ -46,6 +46,21 @@ pub struct IngestResult {
|
||||
pub mr_diffs_failed: usize,
|
||||
pub status_enrichment_errors: usize,
|
||||
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
|
||||
pub project_summaries: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
/// Per-project summary for display in stage completion sub-rows.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProjectSummary {
|
||||
pub path: String,
|
||||
pub items_upserted: usize,
|
||||
pub discussions_synced: usize,
|
||||
pub events_fetched: usize,
|
||||
pub events_failed: usize,
|
||||
pub statuses_enriched: usize,
|
||||
pub statuses_seen: usize,
|
||||
pub mr_diffs_fetched: usize,
|
||||
pub mr_diffs_failed: usize,
|
||||
}
|
||||
|
||||
/// Per-project status enrichment result, collected during ingestion.
|
||||
@@ -388,11 +403,11 @@ async fn run_ingest_inner(
|
||||
let s = multi.add(ProgressBar::new_spinner());
|
||||
s.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.blue} {msg}")
|
||||
.template("{spinner:.cyan} {msg}")
|
||||
.unwrap(),
|
||||
);
|
||||
s.set_message(format!("Fetching {type_label} from {path}..."));
|
||||
s.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
s.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
s
|
||||
};
|
||||
|
||||
@@ -403,12 +418,13 @@ async fn run_ingest_inner(
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.blue} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}",
|
||||
" {spinner:.dim} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
b.set_prefix(path.clone());
|
||||
b.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
b
|
||||
};
|
||||
|
||||
@@ -445,7 +461,7 @@ async fn run_ingest_inner(
|
||||
spinner_clone.finish_and_clear();
|
||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Syncing discussions... (0/{agg_total})"
|
||||
));
|
||||
@@ -465,7 +481,7 @@ async fn run_ingest_inner(
|
||||
spinner_clone.finish_and_clear();
|
||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Syncing discussions... (0/{agg_total})"
|
||||
));
|
||||
@@ -486,11 +502,11 @@ async fn run_ingest_inner(
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.blue} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dim}] {pos}/{len}")
|
||||
.template(" {spinner:.dim} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching resource events...".to_string()
|
||||
@@ -510,7 +526,7 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching closes-issues references...".to_string()
|
||||
);
|
||||
@@ -524,7 +540,7 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching MR file changes...".to_string()
|
||||
);
|
||||
@@ -535,35 +551,37 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentStarted => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Enriching work item statuses..."
|
||||
));
|
||||
ProgressEvent::StatusEnrichmentStarted { total } => {
|
||||
spinner_clone.finish_and_clear();
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.dim} {prefix:.cyan} Statuses [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
|
||||
.unwrap()
|
||||
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||
);
|
||||
disc_bar_clone.set_prefix(path_for_cb.clone());
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||
stage_bar_clone.set_message(
|
||||
"Enriching work item statuses...".to_string()
|
||||
);
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Fetching statuses... ({items_so_far} work items)"
|
||||
));
|
||||
disc_bar_clone.set_position(items_so_far as u64);
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Enriching work item statuses... ({items_so_far} fetched)"
|
||||
));
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentWriting { total } => {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: Writing {total} statuses..."
|
||||
));
|
||||
disc_bar_clone.set_message(format!("Writing {total} statuses..."));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Writing {total} work item statuses..."
|
||||
));
|
||||
}
|
||||
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
if enriched > 0 || cleared > 0 {
|
||||
spinner_clone.set_message(format!(
|
||||
"{path_for_cb}: {enriched} statuses enriched, {cleared} cleared"
|
||||
));
|
||||
stage_bar_clone.set_message(format!(
|
||||
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
||||
));
|
||||
@@ -656,6 +674,17 @@ async fn run_ingest_inner(
|
||||
first_partial_error: result.first_partial_error.clone(),
|
||||
error: result.status_enrichment_error.clone(),
|
||||
});
|
||||
total.project_summaries.push(ProjectSummary {
|
||||
path: path.clone(),
|
||||
items_upserted: result.issues_upserted,
|
||||
discussions_synced: result.discussions_fetched,
|
||||
events_fetched: result.resource_events_fetched,
|
||||
events_failed: result.resource_events_failed,
|
||||
statuses_enriched: result.statuses_enriched,
|
||||
statuses_seen: result.statuses_seen,
|
||||
mr_diffs_fetched: 0,
|
||||
mr_diffs_failed: 0,
|
||||
});
|
||||
}
|
||||
Ok(ProjectIngestOutcome::Mrs {
|
||||
ref path,
|
||||
@@ -679,6 +708,17 @@ async fn run_ingest_inner(
|
||||
total.resource_events_failed += result.resource_events_failed;
|
||||
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
||||
total.mr_diffs_failed += result.mr_diffs_failed;
|
||||
total.project_summaries.push(ProjectSummary {
|
||||
path: path.clone(),
|
||||
items_upserted: result.mrs_upserted,
|
||||
discussions_synced: result.discussions_fetched,
|
||||
events_fetched: result.resource_events_fetched,
|
||||
events_failed: result.resource_events_failed,
|
||||
statuses_enriched: 0,
|
||||
statuses_seen: 0,
|
||||
mr_diffs_fetched: result.mr_diffs_fetched,
|
||||
mr_diffs_failed: result.mr_diffs_failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme};
|
||||
use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -657,15 +657,17 @@ fn format_assignees(assignees: &[String]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_discussions(total: i64, unresolved: i64) -> String {
|
||||
fn format_discussions(total: i64, unresolved: i64) -> StyledCell {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
return StyledCell::plain(String::new());
|
||||
}
|
||||
|
||||
if unresolved > 0 {
|
||||
format!("{total}/{unresolved}!")
|
||||
let text = format!("{total}/");
|
||||
let warn = Theme::warning().render(&format!("{unresolved}!"));
|
||||
StyledCell::plain(format!("{text}{warn}"))
|
||||
} else {
|
||||
format!("{total}")
|
||||
StyledCell::plain(format!("{total}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +683,8 @@ pub fn print_list_issues(result: &ListResult) {
|
||||
}
|
||||
|
||||
println!(
|
||||
"Issues (showing {} of {})\n",
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Issues"),
|
||||
result.issues.len(),
|
||||
result.total_count
|
||||
);
|
||||
@@ -698,16 +701,17 @@ pub fn print_list_issues(result: &ListResult) {
|
||||
|
||||
for issue in &result.issues {
|
||||
let title = render::truncate(&issue.title, 45);
|
||||
let relative_time = render::format_relative_time(issue.updated_at);
|
||||
let labels = render::format_labels(&issue.labels, 2);
|
||||
let relative_time = render::format_relative_time_compact(issue.updated_at);
|
||||
let labels = render::format_labels_bare(&issue.labels, 2);
|
||||
let assignee = format_assignees(&issue.assignees);
|
||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||
|
||||
let state_cell = if issue.state == "opened" {
|
||||
StyledCell::styled(&issue.state, Theme::success())
|
||||
let (icon, state_style) = if issue.state == "opened" {
|
||||
(Icons::issue_opened(), Theme::success())
|
||||
} else {
|
||||
StyledCell::styled(&issue.state, Theme::dim())
|
||||
(Icons::issue_closed(), Theme::dim())
|
||||
};
|
||||
let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style);
|
||||
|
||||
let mut row = vec![
|
||||
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
||||
@@ -730,7 +734,7 @@ pub fn print_list_issues(result: &ListResult) {
|
||||
row.extend([
|
||||
StyledCell::styled(assignee, Theme::accent()),
|
||||
StyledCell::styled(labels, Theme::warning()),
|
||||
StyledCell::plain(discussions),
|
||||
discussions,
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
table.add_row(row);
|
||||
@@ -783,7 +787,8 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
}
|
||||
|
||||
println!(
|
||||
"Merge Requests (showing {} of {})\n",
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Merge Requests"),
|
||||
result.mrs.len(),
|
||||
result.total_count
|
||||
);
|
||||
@@ -796,22 +801,23 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
|
||||
for mr in &result.mrs {
|
||||
let title = if mr.draft {
|
||||
format!("[DRAFT] {}", render::truncate(&mr.title, 38))
|
||||
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42))
|
||||
} else {
|
||||
render::truncate(&mr.title, 45)
|
||||
};
|
||||
|
||||
let relative_time = render::format_relative_time(mr.updated_at);
|
||||
let relative_time = render::format_relative_time_compact(mr.updated_at);
|
||||
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
||||
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
||||
|
||||
let state_cell = match mr.state.as_str() {
|
||||
"opened" => StyledCell::styled(&mr.state, Theme::success()),
|
||||
"merged" => StyledCell::styled(&mr.state, Theme::accent()),
|
||||
"closed" => StyledCell::styled(&mr.state, Theme::error()),
|
||||
"locked" => StyledCell::styled(&mr.state, Theme::warning()),
|
||||
_ => StyledCell::styled(&mr.state, Theme::dim()),
|
||||
let (icon, style) = match mr.state.as_str() {
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
"locked" => (Icons::mr_opened(), Theme::warning()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style);
|
||||
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
||||
@@ -822,7 +828,7 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
Theme::accent(),
|
||||
),
|
||||
StyledCell::styled(branches, Theme::info()),
|
||||
StyledCell::plain(discussions),
|
||||
discussions,
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
@@ -909,7 +915,8 @@ pub fn print_list_notes(result: &NoteListResult) {
|
||||
}
|
||||
|
||||
println!(
|
||||
"Notes (showing {} of {})\n",
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Notes"),
|
||||
result.notes.len(),
|
||||
result.total_count
|
||||
);
|
||||
@@ -934,7 +941,7 @@ pub fn print_list_notes(result: &NoteListResult) {
|
||||
.unwrap_or_default();
|
||||
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
||||
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
|
||||
let relative_time = render::format_relative_time(note.created_at);
|
||||
let relative_time = render::format_relative_time_compact(note.created_at);
|
||||
let note_type = format_note_type(note.note_type.as_deref());
|
||||
|
||||
table.add_row(vec![
|
||||
|
||||
@@ -62,17 +62,20 @@ fn format_labels_overflow() {
|
||||
|
||||
#[test]
|
||||
fn format_discussions_empty() {
|
||||
assert_eq!(format_discussions(0, 0), "");
|
||||
assert_eq!(format_discussions(0, 0).text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_no_unresolved() {
|
||||
assert_eq!(format_discussions(5, 0), "5");
|
||||
assert_eq!(format_discussions(5, 0).text, "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_with_unresolved() {
|
||||
assert_eq!(format_discussions(5, 2), "5/2!");
|
||||
let cell = format_discussions(5, 2);
|
||||
// Text contains styled ANSI for warning-colored unresolved count
|
||||
assert!(cell.text.starts_with("5/"), "got: {}", cell.text);
|
||||
assert!(cell.text.contains("2!"), "got: {}", cell.text);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -309,20 +309,20 @@ fn parse_json_array(json: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render FTS snippet with `<mark>` tags as terminal bold+underline.
|
||||
/// Render FTS snippet with `<mark>` tags as terminal highlight style.
|
||||
fn render_snippet(snippet: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = snippet;
|
||||
while let Some(start) = remaining.find("<mark>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
result.push_str(&Theme::muted().render(&remaining[..start]));
|
||||
remaining = &remaining[start + 6..];
|
||||
if let Some(end) = remaining.find("</mark>") {
|
||||
let highlighted = &remaining[..end];
|
||||
result.push_str(&Theme::bold().underline().render(highlighted));
|
||||
result.push_str(&Theme::highlight().render(highlighted));
|
||||
remaining = &remaining[end + 7..];
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result.push_str(&Theme::muted().render(remaining));
|
||||
result
|
||||
}
|
||||
|
||||
@@ -345,32 +345,34 @@ pub fn print_search_results(response: &SearchResponse) {
|
||||
"\n {} results for '{}' {}",
|
||||
Theme::bold().render(&response.total_results.to_string()),
|
||||
Theme::bold().render(&response.query),
|
||||
Theme::dim().render(&format!("({})", response.mode))
|
||||
Theme::muted().render(&response.mode)
|
||||
);
|
||||
println!();
|
||||
|
||||
for (i, result) in response.results.iter().enumerate() {
|
||||
println!();
|
||||
|
||||
let type_badge = match result.source_type.as_str() {
|
||||
"issue" => Theme::info().render("issue"),
|
||||
"merge_request" => Theme::accent().render("mr"),
|
||||
"issue" => Theme::issue_ref().render("issue"),
|
||||
"merge_request" => Theme::mr_ref().render(" mr "),
|
||||
"discussion" => Theme::info().render(" disc"),
|
||||
"note" => Theme::info().render("note"),
|
||||
_ => Theme::dim().render(&result.source_type),
|
||||
"note" => Theme::muted().render(" note"),
|
||||
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
|
||||
};
|
||||
|
||||
// Title line: rank, type badge, title
|
||||
println!(
|
||||
" {} {} {}",
|
||||
Theme::dim().render(&format!("{:>2}.", i + 1)),
|
||||
" {:>3}. {} {}",
|
||||
Theme::muted().render(&(i + 1).to_string()),
|
||||
type_badge,
|
||||
Theme::bold().render(&result.title)
|
||||
);
|
||||
|
||||
// Metadata: project, author, labels — compact middle-dot line
|
||||
let sep = Theme::muted().render(" \u{b7} ");
|
||||
let mut meta_parts: Vec<String> = Vec::new();
|
||||
meta_parts.push(result.project_path.clone());
|
||||
meta_parts.push(Theme::muted().render(&result.project_path));
|
||||
if let Some(ref author) = result.author {
|
||||
meta_parts.push(format!("@{author}"));
|
||||
meta_parts.push(Theme::username().render(&format!("@{author}")));
|
||||
}
|
||||
if !result.labels.is_empty() {
|
||||
let label_str = if result.labels.len() <= 3 {
|
||||
@@ -382,16 +384,13 @@ pub fn print_search_results(response: &SearchResponse) {
|
||||
result.labels.len() - 2
|
||||
)
|
||||
};
|
||||
meta_parts.push(label_str);
|
||||
meta_parts.push(Theme::muted().render(&label_str));
|
||||
}
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&meta_parts.join(" \u{b7} "))
|
||||
);
|
||||
println!(" {}", meta_parts.join(&sep));
|
||||
|
||||
// Snippet with proper highlighting
|
||||
// Snippet with highlight styling
|
||||
let rendered = render_snippet(&result.snippet);
|
||||
println!(" {}", Theme::dim().render(&rendered));
|
||||
println!(" {rendered}");
|
||||
|
||||
if let Some(ref explain) = result.explain {
|
||||
println!(
|
||||
@@ -408,10 +407,10 @@ pub fn print_search_results(response: &SearchResponse) {
|
||||
explain.rrf_score
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchJsonOutput<'a> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::cli::render::{self, Theme};
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -614,33 +614,47 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn print_show_issue(issue: &IssueDetail) {
|
||||
let header = format!("Issue #{}: {}", issue.iid, issue.title);
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
||||
println!();
|
||||
// Title line
|
||||
println!(
|
||||
" Issue #{}: {}",
|
||||
issue.iid,
|
||||
Theme::bold().render(&issue.title),
|
||||
);
|
||||
|
||||
println!("Ref: {}", Theme::dim().render(&issue.references_full));
|
||||
println!("Project: {}", Theme::info().render(&issue.project_path));
|
||||
// Details section
|
||||
println!("{}", render::section_divider("Details"));
|
||||
|
||||
let state_styled = if issue.state == "opened" {
|
||||
Theme::success().render(&issue.state)
|
||||
println!(
|
||||
" Ref {}",
|
||||
Theme::muted().render(&issue.references_full)
|
||||
);
|
||||
println!(
|
||||
" Project {}",
|
||||
Theme::info().render(&issue.project_path)
|
||||
);
|
||||
|
||||
let (icon, state_style) = if issue.state == "opened" {
|
||||
(Icons::issue_opened(), Theme::success())
|
||||
} else {
|
||||
Theme::dim().render(&issue.state)
|
||||
(Icons::issue_closed(), Theme::dim())
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
println!(
|
||||
" State {}",
|
||||
state_style.render(&format!("{icon} {}", issue.state))
|
||||
);
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
" Status {}",
|
||||
render::style_with_hex(status, issue.status_color.as_deref())
|
||||
);
|
||||
}
|
||||
|
||||
if issue.confidential {
|
||||
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||
}
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
"Status: {}",
|
||||
render::style_with_hex(status, issue.status_color.as_deref())
|
||||
);
|
||||
}
|
||||
|
||||
println!("Author: @{}", issue.author_username);
|
||||
println!(" Author @{}", issue.author_username);
|
||||
|
||||
if !issue.assignees.is_empty() {
|
||||
let label = if issue.assignees.len() > 1 {
|
||||
@@ -649,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
"Assignee"
|
||||
};
|
||||
println!(
|
||||
"{}:{} {}",
|
||||
" {}{} {}",
|
||||
label,
|
||||
" ".repeat(10 - label.len()),
|
||||
" ".repeat(12 - label.len()),
|
||||
issue
|
||||
.assignees
|
||||
.iter()
|
||||
.map(|a| format!("@{}", a))
|
||||
.map(|a| format!("@{a}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
println!("Created: {}", format_date(issue.created_at));
|
||||
println!("Updated: {}", format_date(issue.updated_at));
|
||||
println!(
|
||||
" Created {} ({})",
|
||||
format_date(issue.created_at),
|
||||
render::format_relative_time_compact(issue.created_at),
|
||||
);
|
||||
println!(
|
||||
" Updated {} ({})",
|
||||
format_date(issue.updated_at),
|
||||
render::format_relative_time_compact(issue.updated_at),
|
||||
);
|
||||
|
||||
if let Some(closed_at) = &issue.closed_at {
|
||||
println!("Closed: {}", closed_at);
|
||||
println!(" Closed {closed_at}");
|
||||
}
|
||||
|
||||
if let Some(due) = &issue.due_date {
|
||||
println!("Due: {}", due);
|
||||
println!(" Due {due}");
|
||||
}
|
||||
|
||||
if let Some(ms) = &issue.milestone {
|
||||
println!("Milestone: {}", ms);
|
||||
println!(" Milestone {ms}");
|
||||
}
|
||||
|
||||
if issue.labels.is_empty() {
|
||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
||||
} else {
|
||||
println!("Labels: {}", issue.labels.join(", "));
|
||||
}
|
||||
|
||||
if !issue.closing_merge_requests.is_empty() {
|
||||
println!();
|
||||
println!("{}", Theme::bold().render("Development:"));
|
||||
for mr in &issue.closing_merge_requests {
|
||||
let state_indicator = match mr.state.as_str() {
|
||||
"merged" => Theme::success().render(&mr.state),
|
||||
"opened" => Theme::info().render(&mr.state),
|
||||
"closed" => Theme::error().render(&mr.state),
|
||||
_ => Theme::dim().render(&mr.state),
|
||||
};
|
||||
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
|
||||
}
|
||||
if !issue.labels.is_empty() {
|
||||
println!(
|
||||
" Labels {}",
|
||||
render::format_labels_bare(&issue.labels, issue.labels.len())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(url) = &issue.web_url {
|
||||
println!("URL: {}", Theme::dim().render(url));
|
||||
println!(" URL {}", Theme::muted().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
// Development section
|
||||
if !issue.closing_merge_requests.is_empty() {
|
||||
println!("{}", render::section_divider("Development"));
|
||||
for mr in &issue.closing_merge_requests {
|
||||
let (mr_icon, mr_style) = match mr.state.as_str() {
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
println!(
|
||||
" {} !{} {} {}",
|
||||
mr_style.render(mr_icon),
|
||||
mr.iid,
|
||||
mr.title,
|
||||
mr_style.render(&mr.state),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", Theme::bold().render("Description:"));
|
||||
// Description section
|
||||
println!("{}", render::section_divider("Description"));
|
||||
if let Some(desc) = &issue.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(desc, 72, " ");
|
||||
println!(" {wrapped}");
|
||||
} else {
|
||||
println!(" {}", Theme::dim().render("(no description)"));
|
||||
println!(" {}", Theme::muted().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Discussions section
|
||||
let user_discussions: Vec<&DiscussionDetail> = issue
|
||||
.discussions
|
||||
.iter()
|
||||
@@ -719,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
||||
println!("\n {}", Theme::muted().render("No discussions"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
||||
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
for discussion in user_discussions {
|
||||
let user_notes: Vec<&NoteDetail> =
|
||||
@@ -733,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
|
||||
if let Some(first_note) = user_notes.first() {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at)
|
||||
format_date(first_note.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at)
|
||||
format_date(reply.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -757,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
}
|
||||
|
||||
pub fn print_show_mr(mr: &MrDetail) {
|
||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
println!("Project: {}", Theme::info().render(&mr.project_path));
|
||||
|
||||
let state_styled = match mr.state.as_str() {
|
||||
"opened" => Theme::success().render(&mr.state),
|
||||
"merged" => Theme::accent().render(&mr.state),
|
||||
"closed" => Theme::error().render(&mr.state),
|
||||
_ => Theme::dim().render(&mr.state),
|
||||
// Title line
|
||||
let draft_prefix = if mr.draft {
|
||||
format!("{} ", Icons::mr_draft())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
println!(
|
||||
" MR !{}: {}{}",
|
||||
mr.iid,
|
||||
draft_prefix,
|
||||
Theme::bold().render(&mr.title),
|
||||
);
|
||||
|
||||
// Details section
|
||||
println!("{}", render::section_divider("Details"));
|
||||
|
||||
println!(" Project {}", Theme::info().render(&mr.project_path));
|
||||
|
||||
let (icon, state_style) = match mr.state.as_str() {
|
||||
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||
_ => (Icons::mr_opened(), Theme::dim()),
|
||||
};
|
||||
println!(
|
||||
" State {}",
|
||||
state_style.render(&format!("{icon} {}", mr.state))
|
||||
);
|
||||
|
||||
println!(
|
||||
"Branches: {} -> {}",
|
||||
" Branches {} -> {}",
|
||||
Theme::info().render(&mr.source_branch),
|
||||
Theme::warning().render(&mr.target_branch)
|
||||
);
|
||||
|
||||
println!("Author: @{}", mr.author_username);
|
||||
println!(" Author @{}", mr.author_username);
|
||||
|
||||
if !mr.assignees.is_empty() {
|
||||
println!(
|
||||
"Assignees: {}",
|
||||
" Assignees {}",
|
||||
mr.assignees
|
||||
.iter()
|
||||
.map(|a| format!("@{}", a))
|
||||
.map(|a| format!("@{a}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
@@ -794,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
|
||||
if !mr.reviewers.is_empty() {
|
||||
println!(
|
||||
"Reviewers: {}",
|
||||
" Reviewers {}",
|
||||
mr.reviewers
|
||||
.iter()
|
||||
.map(|r| format!("@{}", r))
|
||||
.map(|r| format!("@{r}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
println!("Created: {}", format_date(mr.created_at));
|
||||
println!("Updated: {}", format_date(mr.updated_at));
|
||||
println!(
|
||||
" Created {} ({})",
|
||||
format_date(mr.created_at),
|
||||
render::format_relative_time_compact(mr.created_at),
|
||||
);
|
||||
println!(
|
||||
" Updated {} ({})",
|
||||
format_date(mr.updated_at),
|
||||
render::format_relative_time_compact(mr.updated_at),
|
||||
);
|
||||
|
||||
if let Some(merged_at) = mr.merged_at {
|
||||
println!("Merged: {}", format_date(merged_at));
|
||||
println!(
|
||||
" Merged {} ({})",
|
||||
format_date(merged_at),
|
||||
render::format_relative_time_compact(merged_at),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(closed_at) = mr.closed_at {
|
||||
println!("Closed: {}", format_date(closed_at));
|
||||
println!(
|
||||
" Closed {} ({})",
|
||||
format_date(closed_at),
|
||||
render::format_relative_time_compact(closed_at),
|
||||
);
|
||||
}
|
||||
|
||||
if mr.labels.is_empty() {
|
||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
||||
} else {
|
||||
println!("Labels: {}", mr.labels.join(", "));
|
||||
if !mr.labels.is_empty() {
|
||||
println!(
|
||||
" Labels {}",
|
||||
render::format_labels_bare(&mr.labels, mr.labels.len())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(url) = &mr.web_url {
|
||||
println!("URL: {}", Theme::dim().render(url));
|
||||
println!(" URL {}", Theme::muted().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", Theme::bold().render("Description:"));
|
||||
// Description section
|
||||
println!("{}", render::section_divider("Description"));
|
||||
if let Some(desc) = &mr.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(desc, 72, " ");
|
||||
println!(" {wrapped}");
|
||||
} else {
|
||||
println!(" {}", Theme::dim().render("(no description)"));
|
||||
println!(" {}", Theme::muted().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Discussions section
|
||||
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
||||
.discussions
|
||||
.iter()
|
||||
@@ -843,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
||||
println!("\n {}", Theme::muted().render("No discussions"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
||||
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
for discussion in user_discussions {
|
||||
let user_notes: Vec<&MrNoteDetail> =
|
||||
@@ -861,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} ({}):",
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at)
|
||||
format_date(first_note.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
" {} {}",
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at)
|
||||
format_date(reply.created_at),
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
println!(" {}", wrapped);
|
||||
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||
println!(" {wrapped}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,26 +328,44 @@ fn section(title: &str) {
|
||||
|
||||
pub fn print_stats(result: &StatsResult) {
|
||||
section("Documents");
|
||||
let mut parts = vec![format!("{} total", result.documents.total)];
|
||||
let mut parts = vec![format!(
|
||||
"{} total",
|
||||
render::format_number(result.documents.total)
|
||||
)];
|
||||
if result.documents.issues > 0 {
|
||||
parts.push(format!("{} issues", result.documents.issues));
|
||||
parts.push(format!(
|
||||
"{} issues",
|
||||
render::format_number(result.documents.issues)
|
||||
));
|
||||
}
|
||||
if result.documents.merge_requests > 0 {
|
||||
parts.push(format!("{} MRs", result.documents.merge_requests));
|
||||
parts.push(format!(
|
||||
"{} MRs",
|
||||
render::format_number(result.documents.merge_requests)
|
||||
));
|
||||
}
|
||||
if result.documents.discussions > 0 {
|
||||
parts.push(format!("{} discussions", result.documents.discussions));
|
||||
parts.push(format!(
|
||||
"{} discussions",
|
||||
render::format_number(result.documents.discussions)
|
||||
));
|
||||
}
|
||||
println!(" {}", parts.join(" \u{b7} "));
|
||||
if result.documents.truncated > 0 {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::warning().render(&format!("{} truncated", result.documents.truncated))
|
||||
Theme::warning().render(&format!(
|
||||
"{} truncated",
|
||||
render::format_number(result.documents.truncated)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
section("Search Index");
|
||||
println!(" {} FTS indexed", result.fts.indexed);
|
||||
println!(
|
||||
" {} FTS indexed",
|
||||
render::format_number(result.fts.indexed)
|
||||
);
|
||||
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
||||
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
} else if result.embeddings.coverage_pct >= 50.0 {
|
||||
@@ -357,12 +375,17 @@ pub fn print_stats(result: &StatsResult) {
|
||||
};
|
||||
println!(
|
||||
" {} embedding coverage ({}/{})",
|
||||
coverage_color, result.embeddings.embedded_documents, result.documents.total,
|
||||
coverage_color,
|
||||
render::format_number(result.embeddings.embedded_documents),
|
||||
render::format_number(result.documents.total),
|
||||
);
|
||||
if result.embeddings.total_chunks > 0 {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks))
|
||||
Theme::dim().render(&format!(
|
||||
"{} chunks",
|
||||
render::format_number(result.embeddings.total_chunks)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use crate::cli::render::{self, Theme};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use crate::cli::render::{self, Icons, Theme, format_number};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
use tracing::Instrument;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::progress::stage_spinner;
|
||||
use crate::cli::progress::{finish_stage, nested_progress, stage_spinner_v2};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::metrics::{MetricsLayer, StageTiming};
|
||||
use crate::core::shutdown::ShutdownSignal;
|
||||
|
||||
use super::embed::run_embed;
|
||||
use super::generate_docs::run_generate_docs;
|
||||
use super::ingest::{DryRunPreview, IngestDisplay, run_ingest, run_ingest_dry_run};
|
||||
use super::ingest::{DryRunPreview, IngestDisplay, ProjectSummary, run_ingest, run_ingest_dry_run};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
@@ -41,6 +39,11 @@ pub struct SyncResult {
|
||||
pub documents_regenerated: usize,
|
||||
pub documents_embedded: usize,
|
||||
pub status_enrichment_errors: usize,
|
||||
pub statuses_enriched: usize,
|
||||
#[serde(skip)]
|
||||
pub issue_projects: Vec<ProjectSummary>,
|
||||
#[serde(skip)]
|
||||
pub mr_projects: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
pub async fn run_sync(
|
||||
@@ -76,23 +79,10 @@ pub async fn run_sync(
|
||||
IngestDisplay::progress_only()
|
||||
};
|
||||
|
||||
let total_stages: u8 = if options.no_docs && options.no_embed {
|
||||
2
|
||||
} else if options.no_docs || options.no_embed {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let mut current_stage: u8 = 0;
|
||||
|
||||
current_stage += 1;
|
||||
let spinner = stage_spinner(
|
||||
current_stage,
|
||||
total_stages,
|
||||
"Fetching issues from GitLab...",
|
||||
options.robot_mode,
|
||||
);
|
||||
info!("Sync stage {current_stage}/{total_stages}: ingesting issues");
|
||||
// ── Stage: Issues ──
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
|
||||
debug!("Sync: ingesting issues");
|
||||
let issues_result = run_ingest(
|
||||
config,
|
||||
"issues",
|
||||
@@ -110,21 +100,30 @@ pub async fn run_sync(
|
||||
result.resource_events_fetched += issues_result.resource_events_fetched;
|
||||
result.resource_events_failed += issues_result.resource_events_failed;
|
||||
result.status_enrichment_errors += issues_result.status_enrichment_errors;
|
||||
spinner.finish_and_clear();
|
||||
for sep in &issues_result.status_enrichment_projects {
|
||||
result.statuses_enriched += sep.enriched;
|
||||
}
|
||||
result.issue_projects = issues_result.project_summaries;
|
||||
let issues_summary = format!(
|
||||
"{} issues from {} {}",
|
||||
format_number(result.issues_updated as i64),
|
||||
issues_result.projects_synced,
|
||||
if issues_result.projects_synced == 1 { "project" } else { "projects" }
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed());
|
||||
if !options.robot_mode {
|
||||
print_issue_sub_rows(&result.issue_projects);
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested after issues stage, returning partial sync results");
|
||||
debug!("Shutdown requested after issues stage, returning partial sync results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
current_stage += 1;
|
||||
let spinner = stage_spinner(
|
||||
current_stage,
|
||||
total_stages,
|
||||
"Fetching merge requests from GitLab...",
|
||||
options.robot_mode,
|
||||
);
|
||||
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
|
||||
// ── Stage: MRs ──
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
|
||||
debug!("Sync: ingesting merge requests");
|
||||
let mrs_result = run_ingest(
|
||||
config,
|
||||
"mrs",
|
||||
@@ -143,45 +142,33 @@ pub async fn run_sync(
|
||||
result.resource_events_failed += mrs_result.resource_events_failed;
|
||||
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
||||
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
|
||||
spinner.finish_and_clear();
|
||||
result.mr_projects = mrs_result.project_summaries;
|
||||
let mrs_summary = format!(
|
||||
"{} merge requests from {} {}",
|
||||
format_number(result.mrs_updated as i64),
|
||||
mrs_result.projects_synced,
|
||||
if mrs_result.projects_synced == 1 { "project" } else { "projects" }
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed());
|
||||
if !options.robot_mode {
|
||||
print_mr_sub_rows(&result.mr_projects);
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested after MRs stage, returning partial sync results");
|
||||
debug!("Shutdown requested after MRs stage, returning partial sync results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Stage: Docs ──
|
||||
if !options.no_docs {
|
||||
current_stage += 1;
|
||||
let spinner = stage_spinner(
|
||||
current_stage,
|
||||
total_stages,
|
||||
"Processing documents...",
|
||||
options.robot_mode,
|
||||
);
|
||||
info!("Sync stage {current_stage}/{total_stages}: generating documents");
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
|
||||
debug!("Sync: generating documents");
|
||||
|
||||
let docs_bar = if options.robot_mode {
|
||||
ProgressBar::hidden()
|
||||
} else {
|
||||
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.blue} Processing documents [{bar:30.cyan/dim}] {pos}/{len}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
b
|
||||
};
|
||||
let docs_bar = nested_progress("Docs", 0, options.robot_mode);
|
||||
let docs_bar_clone = docs_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_started_clone = Arc::clone(&tick_started);
|
||||
let docs_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||
if total > 0 {
|
||||
if !tick_started_clone.swap(true, Ordering::Relaxed) {
|
||||
docs_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
docs_bar_clone.set_length(total as u64);
|
||||
docs_bar_clone.set_position(processed as u64);
|
||||
}
|
||||
@@ -189,43 +176,25 @@ pub async fn run_sync(
|
||||
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
|
||||
result.documents_regenerated = docs_result.regenerated;
|
||||
docs_bar.finish_and_clear();
|
||||
spinner.finish_and_clear();
|
||||
let docs_summary = format!(
|
||||
"{} documents generated",
|
||||
format_number(result.documents_regenerated as i64),
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "Docs", &docs_summary, stage_start.elapsed());
|
||||
} else {
|
||||
info!("Sync: skipping document generation (--no-docs)");
|
||||
debug!("Sync: skipping document generation (--no-docs)");
|
||||
}
|
||||
|
||||
// ── Stage: Embed ──
|
||||
if !options.no_embed {
|
||||
current_stage += 1;
|
||||
let spinner = stage_spinner(
|
||||
current_stage,
|
||||
total_stages,
|
||||
"Generating embeddings...",
|
||||
options.robot_mode,
|
||||
);
|
||||
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
|
||||
debug!("Sync: embedding documents");
|
||||
|
||||
let embed_bar = if options.robot_mode {
|
||||
ProgressBar::hidden()
|
||||
} else {
|
||||
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
b
|
||||
};
|
||||
let embed_bar = nested_progress("Embed", 0, options.robot_mode);
|
||||
let embed_bar_clone = embed_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_started_clone = Arc::clone(&tick_started);
|
||||
let embed_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||
if total > 0 {
|
||||
if !tick_started_clone.swap(true, Ordering::Relaxed) {
|
||||
embed_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
embed_bar_clone.set_length(total as u64);
|
||||
embed_bar_clone.set_position(processed as u64);
|
||||
}
|
||||
@@ -234,22 +203,24 @@ pub async fn run_sync(
|
||||
Ok(embed_result) => {
|
||||
result.documents_embedded = embed_result.docs_embedded;
|
||||
embed_bar.finish_and_clear();
|
||||
spinner.finish_and_clear();
|
||||
let embed_summary = format!(
|
||||
"{} chunks embedded",
|
||||
format_number(embed_result.chunks_embedded as i64),
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "Embed", &embed_summary, stage_start.elapsed());
|
||||
}
|
||||
Err(e) => {
|
||||
embed_bar.finish_and_clear();
|
||||
spinner.finish_and_clear();
|
||||
if !options.robot_mode {
|
||||
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
|
||||
}
|
||||
let warn_summary = format!("skipped ({})", e);
|
||||
finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
|
||||
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Sync: skipping embedding (--no-embed)");
|
||||
debug!("Sync: skipping embedding (--no-embed)");
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
issues = result.issues_updated,
|
||||
mrs = result.mrs_updated,
|
||||
discussions = result.discussions_fetched,
|
||||
@@ -273,6 +244,22 @@ pub fn print_sync(
|
||||
elapsed: std::time::Duration,
|
||||
metrics: Option<&MetricsLayer>,
|
||||
) {
|
||||
let has_data = result.issues_updated > 0
|
||||
|| result.mrs_updated > 0
|
||||
|| result.discussions_fetched > 0
|
||||
|| result.resource_events_fetched > 0
|
||||
|| result.mr_diffs_fetched > 0
|
||||
|| result.documents_regenerated > 0
|
||||
|| result.documents_embedded > 0
|
||||
|| result.statuses_enriched > 0;
|
||||
|
||||
if !has_data {
|
||||
println!(
|
||||
"\n {} ({:.1}s)\n",
|
||||
Theme::dim().render("Already up to date"),
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
} else {
|
||||
// Headline: what happened, how long
|
||||
println!(
|
||||
"\n {} {} issues and {} MRs in {:.1}s",
|
||||
@@ -293,6 +280,9 @@ pub fn print_sync(
|
||||
if result.mr_diffs_fetched > 0 {
|
||||
details.push(format!("{} diffs", result.mr_diffs_fetched));
|
||||
}
|
||||
if result.statuses_enriched > 0 {
|
||||
details.push(format!("{} statuses updated", result.statuses_enriched));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
@@ -325,6 +315,7 @@ pub fn print_sync(
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
if let Some(metrics) = metrics {
|
||||
let stages = metrics.extract_timings();
|
||||
@@ -334,6 +325,66 @@ pub fn print_sync(
|
||||
}
|
||||
}
|
||||
|
||||
fn print_issue_sub_rows(projects: &[ProjectSummary]) {
|
||||
if projects.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
for p in projects {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
p.items_upserted,
|
||||
if p.items_upserted == 1 {
|
||||
"issue"
|
||||
} else {
|
||||
"issues"
|
||||
}
|
||||
));
|
||||
if p.discussions_synced > 0 {
|
||||
parts.push(format!("{} discussions", p.discussions_synced));
|
||||
}
|
||||
if p.statuses_enriched > 0 {
|
||||
parts.push(format!("{} statuses updated", p.statuses_enriched));
|
||||
}
|
||||
if p.events_fetched > 0 {
|
||||
parts.push(format!("{} events", p.events_fetched));
|
||||
}
|
||||
let detail = parts.join(" \u{b7} ");
|
||||
let _ = crate::cli::progress::multi().println(format!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn print_mr_sub_rows(projects: &[ProjectSummary]) {
|
||||
if projects.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
for p in projects {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
p.items_upserted,
|
||||
if p.items_upserted == 1 { "MR" } else { "MRs" }
|
||||
));
|
||||
if p.discussions_synced > 0 {
|
||||
parts.push(format!("{} discussions", p.discussions_synced));
|
||||
}
|
||||
if p.mr_diffs_fetched > 0 {
|
||||
parts.push(format!("{} diffs", p.mr_diffs_fetched));
|
||||
}
|
||||
if p.events_fetched > 0 {
|
||||
parts.push(format!("{} events", p.events_fetched));
|
||||
}
|
||||
let detail = parts.join(" \u{b7} ");
|
||||
let _ = crate::cli::progress::multi().println(format!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("{}", render::section_divider(title));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::cli::render::{self, Theme};
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::progress::stage_spinner;
|
||||
use crate::cli::progress::stage_spinner_v2;
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::get_db_path;
|
||||
@@ -96,7 +96,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
||||
let seed_result = match parsed_query {
|
||||
TimelineQuery::EntityDirect { entity_type, iid } => {
|
||||
// Direct seeding: synchronous, no Ollama needed
|
||||
let spinner = stage_spinner(1, 3, "Resolving entity...", params.robot_mode);
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::search(),
|
||||
"Resolve",
|
||||
"Resolving entity...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?;
|
||||
spinner.finish_and_clear();
|
||||
result
|
||||
@@ -111,7 +116,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
||||
});
|
||||
|
||||
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
||||
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::search(),
|
||||
"Seed",
|
||||
"Seeding timeline...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let result = seed_timeline(
|
||||
&conn,
|
||||
Some(&client),
|
||||
@@ -128,7 +138,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
||||
};
|
||||
|
||||
// Stage 3: EXPAND
|
||||
let spinner = stage_spinner(2, 3, "Expanding cross-references...", params.robot_mode);
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::sync(),
|
||||
"Expand",
|
||||
"Expanding cross-references...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let expand_result = expand_timeline(
|
||||
&conn,
|
||||
&seed_result.seed_entities,
|
||||
@@ -139,7 +154,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
||||
spinner.finish_and_clear();
|
||||
|
||||
// Stage 4: COLLECT
|
||||
let spinner = stage_spinner(3, 3, "Collecting events...", params.robot_mode);
|
||||
let spinner = stage_spinner_v2(
|
||||
Icons::sync(),
|
||||
"Collect",
|
||||
"Collecting events...",
|
||||
params.robot_mode,
|
||||
);
|
||||
let (events, total_before_limit) = collect_events(
|
||||
&conn,
|
||||
&seed_result.seed_entities,
|
||||
@@ -202,6 +222,11 @@ pub fn print_timeline(result: &TimelineResult) {
|
||||
fn print_timeline_event(event: &TimelineEvent) {
|
||||
let date = render::format_date(event.timestamp);
|
||||
let tag = format_event_tag(&event.event_type);
|
||||
let entity_icon = match event.entity_type.as_str() {
|
||||
"issue" => Icons::issue_opened(),
|
||||
"merge_request" => Icons::mr_opened(),
|
||||
_ => "",
|
||||
};
|
||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||
let actor = event
|
||||
.actor
|
||||
@@ -211,8 +236,7 @@ fn print_timeline_event(event: &TimelineEvent) {
|
||||
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
||||
|
||||
let summary = render::truncate(&event.summary, 50);
|
||||
let tag_padded = format!("{:<12}", tag);
|
||||
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||
|
||||
// Show snippet for evidence notes
|
||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
||||
@@ -276,23 +300,33 @@ fn print_timeline_footer(result: &TimelineResult) {
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Format event tag: pad plain text to TAG_WIDTH, then apply style.
|
||||
const TAG_WIDTH: usize = 11;
|
||||
|
||||
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
||||
match event_type {
|
||||
TimelineEventType::Created => Theme::success().render("CREATED"),
|
||||
let (label, style) = match event_type {
|
||||
TimelineEventType::Created => ("CREATED", Theme::success()),
|
||||
TimelineEventType::StateChanged { state } => match state.as_str() {
|
||||
"closed" => Theme::error().render("CLOSED"),
|
||||
"reopened" => Theme::warning().render("REOPENED"),
|
||||
_ => Theme::dim().render(&state.to_uppercase()),
|
||||
"closed" => ("CLOSED", Theme::error()),
|
||||
"reopened" => ("REOPENED", Theme::warning()),
|
||||
_ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()),
|
||||
},
|
||||
TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
|
||||
TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
|
||||
TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
|
||||
TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
|
||||
TimelineEventType::Merged => Theme::info().render("MERGED"),
|
||||
TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
|
||||
TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
|
||||
TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"),
|
||||
TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()),
|
||||
TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()),
|
||||
TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()),
|
||||
TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()),
|
||||
TimelineEventType::Merged => ("MERGED", Theme::info()),
|
||||
TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()),
|
||||
TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()),
|
||||
TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()),
|
||||
};
|
||||
style_padded(label, TAG_WIDTH, style)
|
||||
}
|
||||
|
||||
/// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment).
|
||||
fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String {
|
||||
let padded = format!("{:<width$}", text);
|
||||
style.render(&padded)
|
||||
}
|
||||
|
||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::cli::render::{self, Theme};
|
||||
use crate::cli::render::{self, Icons, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -1951,7 +1951,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
};
|
||||
println!(
|
||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
|
||||
Theme::info().render(&format!("@{}", expert.username)),
|
||||
Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
|
||||
expert.score,
|
||||
reviews,
|
||||
notes,
|
||||
@@ -2004,16 +2004,18 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
|
||||
Theme::bold().render(&format!(
|
||||
"{} {} -- Workload Summary",
|
||||
Icons::user(),
|
||||
r.username
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
|
||||
if !r.assigned_issues.is_empty() {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render("Assigned Issues"),
|
||||
r.assigned_issues.len()
|
||||
"{}",
|
||||
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
|
||||
);
|
||||
for item in &r.assigned_issues {
|
||||
println!(
|
||||
@@ -2032,11 +2034,9 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
}
|
||||
|
||||
if !r.authored_mrs.is_empty() {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render("Authored MRs"),
|
||||
r.authored_mrs.len()
|
||||
"{}",
|
||||
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
|
||||
);
|
||||
for mr in &r.authored_mrs {
|
||||
let draft = if mr.draft { " [draft]" } else { "" };
|
||||
@@ -2057,11 +2057,9 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
}
|
||||
|
||||
if !r.reviewing_mrs.is_empty() {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render("Reviewing MRs"),
|
||||
r.reviewing_mrs.len()
|
||||
"{}",
|
||||
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
|
||||
);
|
||||
for mr in &r.reviewing_mrs {
|
||||
let author = mr
|
||||
@@ -2086,11 +2084,12 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
}
|
||||
|
||||
if !r.unresolved_discussions.is_empty() {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render("Unresolved Discussions"),
|
||||
"{}",
|
||||
render::section_divider(&format!(
|
||||
"Unresolved Discussions ({})",
|
||||
r.unresolved_discussions.len()
|
||||
))
|
||||
);
|
||||
for disc in &r.unresolved_discussions {
|
||||
println!(
|
||||
@@ -2128,7 +2127,11 @@ fn print_reviews_human(r: &ReviewsResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
|
||||
Theme::bold().render(&format!(
|
||||
"{} {} -- Review Patterns",
|
||||
Icons::user(),
|
||||
r.username
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
@@ -2289,7 +2292,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
|
||||
println!(
|
||||
" {:<16} {:<6} {:>7} {:<12} {}{}",
|
||||
Theme::info().render(&format!("@{}", user.username)),
|
||||
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
|
||||
format_overlap_role(user),
|
||||
user.touch_count,
|
||||
render::format_relative_time(user.last_seen_at),
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||
pub color: String,
|
||||
|
||||
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
|
||||
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
|
||||
pub icons: Option<String>,
|
||||
|
||||
/// Suppress non-essential output
|
||||
#[arg(
|
||||
short = 'q',
|
||||
|
||||
@@ -1,34 +1,82 @@
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::io::Write;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
use crate::cli::render::Icons;
|
||||
|
||||
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
||||
|
||||
pub fn multi() -> &'static MultiProgress {
|
||||
&MULTI
|
||||
}
|
||||
|
||||
/// Create a spinner for a numbered pipeline stage.
|
||||
/// Stage spinner with icon prefix and elapsed time on the right.
|
||||
///
|
||||
/// Returns a hidden (no-op) bar in robot mode so callers can use
|
||||
/// the same code path regardless of output mode.
|
||||
pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
||||
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||
if robot_mode {
|
||||
return ProgressBar::hidden();
|
||||
}
|
||||
let pb = multi().add(ProgressBar::new_spinner());
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.blue} {prefix} {msg}")
|
||||
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
||||
.expect("valid template"),
|
||||
);
|
||||
pb.enable_steady_tick(std::time::Duration::from_millis(80));
|
||||
pb.set_prefix(format!("[{stage}/{total}]"));
|
||||
pb.enable_steady_tick(Duration::from_millis(60));
|
||||
pb.set_prefix(format!("{icon} {label}"));
|
||||
pb.set_message(msg.to_string());
|
||||
pb
|
||||
}
|
||||
|
||||
/// Nested progress bar with count, throughput, and ETA.
|
||||
///
|
||||
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
|
||||
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
|
||||
if robot_mode {
|
||||
return ProgressBar::hidden();
|
||||
}
|
||||
let pb = multi().add(ProgressBar::new(len));
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||
)
|
||||
.expect("valid template")
|
||||
.progress_chars(Icons::progress_chars()),
|
||||
);
|
||||
pb.enable_steady_tick(Duration::from_millis(60));
|
||||
pb.set_message(msg.to_string());
|
||||
pb
|
||||
}
|
||||
|
||||
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
|
||||
///
|
||||
/// Output: ` ✓ Label summary elapsed`
|
||||
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
|
||||
let elapsed_str = format_elapsed(elapsed);
|
||||
let line = format!(" {icon} {label:<12}{summary:>40} {elapsed_str:>8}",);
|
||||
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||
pb.finish_with_message(line);
|
||||
}
|
||||
|
||||
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
|
||||
fn format_elapsed(d: Duration) -> String {
|
||||
let ms = d.as_millis();
|
||||
if ms < 1000 {
|
||||
format!("{ms}ms")
|
||||
} else if ms < 60_000 {
|
||||
format!("{:.1}s", ms as f64 / 1000.0)
|
||||
} else {
|
||||
let secs = d.as_secs();
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SuspendingWriter;
|
||||
|
||||
@@ -108,34 +156,51 @@ mod tests {
|
||||
drop(w);
|
||||
}
|
||||
|
||||
// ── Progress API tests ──
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_robot_mode_returns_hidden() {
|
||||
let pb = stage_spinner(1, 3, "Testing...", true);
|
||||
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
||||
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
||||
assert!(pb.is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_human_mode_sets_properties() {
|
||||
// In non-TTY test environments, MultiProgress may report bars as
|
||||
// hidden. Verify the human-mode code path by checking that prefix
|
||||
// and message are configured (robot-mode returns a bare hidden bar).
|
||||
let pb = stage_spinner(1, 3, "Testing...", false);
|
||||
assert_eq!(pb.prefix(), "[1/3]");
|
||||
assert_eq!(pb.message(), "Testing...");
|
||||
fn stage_spinner_v2_human_mode_sets_properties() {
|
||||
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
||||
assert!(pb.prefix().contains("Issues"));
|
||||
assert_eq!(pb.message(), "fetching...");
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_sets_prefix_format() {
|
||||
let pb = stage_spinner(2, 5, "Working...", false);
|
||||
assert_eq!(pb.prefix(), "[2/5]");
|
||||
fn nested_progress_robot_mode_returns_hidden() {
|
||||
let pb = nested_progress("Embedding...", 100, true);
|
||||
assert!(pb.is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_progress_human_mode_sets_length() {
|
||||
let pb = nested_progress("Embedding...", 100, false);
|
||||
assert_eq!(pb.length(), Some(100));
|
||||
assert_eq!(pb.message(), "Embedding...");
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_sets_message() {
|
||||
let pb = stage_spinner(1, 3, "Seeding timeline...", false);
|
||||
assert_eq!(pb.message(), "Seeding timeline...");
|
||||
pb.finish_and_clear();
|
||||
fn format_elapsed_sub_second() {
|
||||
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
||||
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_seconds() {
|
||||
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
|
||||
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_minutes() {
|
||||
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
|
||||
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,235 @@ pub enum ColorMode {
|
||||
Never,
|
||||
}
|
||||
|
||||
// ─── Glyph Mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GlyphMode {
|
||||
Nerd,
|
||||
Unicode,
|
||||
Ascii,
|
||||
}
|
||||
|
||||
impl GlyphMode {
|
||||
/// Detect glyph mode from CLI flag, env, or terminal heuristics.
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. Explicit `--icons` CLI value (passed as `cli_flag`)
|
||||
/// 2. `LORE_ICONS` environment variable
|
||||
/// 3. Force ASCII fallback if `force_ascii` is true (robot mode)
|
||||
/// 4. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals
|
||||
/// or `$NERD_FONTS=1`; otherwise Unicode
|
||||
pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self {
|
||||
// 1. CLI flag
|
||||
if let Some(flag) = cli_flag {
|
||||
return Self::from_str_lossy(flag);
|
||||
}
|
||||
|
||||
// 2. Env var
|
||||
if let Ok(val) = std::env::var("LORE_ICONS") {
|
||||
return Self::from_str_lossy(&val);
|
||||
}
|
||||
|
||||
// 3. Robot-safe fallback
|
||||
if force_ascii {
|
||||
return Self::Ascii;
|
||||
}
|
||||
|
||||
// 4. Auto-detect
|
||||
if Self::detect_nerd_capable() {
|
||||
Self::Nerd
|
||||
} else {
|
||||
Self::Unicode
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str_lossy(s: &str) -> Self {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"nerd" => Self::Nerd,
|
||||
"unicode" => Self::Unicode,
|
||||
"ascii" => Self::Ascii,
|
||||
_ => Self::Unicode,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_nerd_capable() -> bool {
|
||||
if std::env::var("NERD_FONTS")
|
||||
.ok()
|
||||
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| {
|
||||
matches!(
|
||||
tp.as_str(),
|
||||
"WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app"
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Icons ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Glyph catalog returning the right icon for the active `GlyphMode`.
|
||||
pub struct Icons;
|
||||
|
||||
impl Icons {
|
||||
fn mode() -> GlyphMode {
|
||||
RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs)
|
||||
}
|
||||
|
||||
// ── Status indicators ──
|
||||
|
||||
pub fn success() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f058}", // nf-fa-check_circle
|
||||
GlyphMode::Unicode => "\u{2714}", // heavy check mark
|
||||
GlyphMode::Ascii => "[ok]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f421}", // nf-oct-alert
|
||||
GlyphMode::Unicode => "\u{26a0}", // warning sign
|
||||
GlyphMode::Ascii => "[!]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f467}", // nf-oct-x_circle
|
||||
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
|
||||
GlyphMode::Ascii => "[X]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f449}", // nf-oct-info
|
||||
GlyphMode::Unicode => "\u{2139}", // information source
|
||||
GlyphMode::Ascii => "[i]",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entity state ──
|
||||
|
||||
pub fn issue_opened() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f41b}", // nf-oct-issue_opened
|
||||
GlyphMode::Unicode => "\u{25cb}", // white circle
|
||||
GlyphMode::Ascii => "( )",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue_closed() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f41d}", // nf-oct-issue_closed
|
||||
GlyphMode::Unicode => "\u{25cf}", // black circle
|
||||
GlyphMode::Ascii => "(x)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_opened() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f407}", // nf-oct-git_pull_request
|
||||
GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards
|
||||
GlyphMode::Ascii => "<->",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_merged() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f402}", // nf-oct-git_merge
|
||||
GlyphMode::Unicode => "\u{2714}", // heavy check mark
|
||||
GlyphMode::Ascii => "[M]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_closed() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f430}", // nf-oct-git_pull_request_closed
|
||||
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
|
||||
GlyphMode::Ascii => "[X]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_draft() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f040}", // nf-fa-pencil
|
||||
GlyphMode::Unicode => "\u{270e}", // lower right pencil
|
||||
GlyphMode::Ascii => "[D]",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Miscellaneous ──
|
||||
|
||||
pub fn note() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f3ed}", // nf-oct-comment
|
||||
GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle
|
||||
GlyphMode::Ascii => ">",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f422}", // nf-oct-search
|
||||
GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass
|
||||
GlyphMode::Ascii => "?",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f415}", // nf-oct-person
|
||||
GlyphMode::Unicode => "@",
|
||||
GlyphMode::Ascii => "@",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f46a}", // nf-oct-sync
|
||||
GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow
|
||||
GlyphMode::Ascii => "<>",
|
||||
}
|
||||
}
|
||||
|
||||
/// Waiting stage indicator (dimmed dot).
|
||||
pub fn waiting() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot
|
||||
GlyphMode::Ascii => ".",
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar characters: (filled, head, empty).
|
||||
pub fn progress_chars() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ",
|
||||
GlyphMode::Ascii => "=> ",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Renderer ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Global renderer singleton, initialized once in `main.rs`.
|
||||
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
||||
|
||||
pub struct LoreRenderer {
|
||||
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
||||
colors: bool,
|
||||
/// Icon tier for the session.
|
||||
glyphs: GlyphMode,
|
||||
}
|
||||
|
||||
impl LoreRenderer {
|
||||
/// Initialize the global renderer. Call once at startup.
|
||||
pub fn init(mode: ColorMode) {
|
||||
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
|
||||
let colors = match mode {
|
||||
ColorMode::Always => true,
|
||||
ColorMode::Never => false,
|
||||
@@ -35,7 +253,7 @@ impl LoreRenderer {
|
||||
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
|
||||
}
|
||||
};
|
||||
let _ = RENDERER.set(LoreRenderer { colors });
|
||||
let _ = RENDERER.set(LoreRenderer { colors, glyphs });
|
||||
}
|
||||
|
||||
/// Get the global renderer. Panics if `init` hasn't been called.
|
||||
@@ -49,6 +267,11 @@ impl LoreRenderer {
|
||||
pub fn colors_enabled(&self) -> bool {
|
||||
self.colors
|
||||
}
|
||||
|
||||
/// The active glyph mode.
|
||||
pub fn glyph_mode(&self) -> GlyphMode {
|
||||
self.glyphs
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been
|
||||
@@ -176,6 +399,39 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_draft() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#6b7280")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Semantic additions
|
||||
pub fn muted() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#6b7280")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#fbbf24").bold()
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timing() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#94a3b8")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Structure
|
||||
pub fn section_title() -> Style {
|
||||
if colors_on() {
|
||||
@@ -369,6 +625,24 @@ pub fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of labels without brackets.
|
||||
/// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"`
|
||||
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"`
|
||||
pub fn format_labels_bare(labels: &[String], max_shown: usize) -> String {
|
||||
if labels.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
||||
let overflow = labels.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("{} +{}", shown.join(", "), overflow)
|
||||
} else {
|
||||
shown.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration in milliseconds as a human-friendly string.
|
||||
pub fn format_duration_ms(ms: u64) -> String {
|
||||
if ms < 1000 {
|
||||
@@ -378,6 +652,26 @@ pub fn format_duration_ms(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an epoch-ms timestamp as a compact relative time string.
|
||||
/// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns.
|
||||
pub fn format_relative_time_compact(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
if diff < 0 {
|
||||
return "future".to_string();
|
||||
}
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "now".to_string(),
|
||||
d if d < 3_600_000 => format!("{}m", d / 60_000),
|
||||
d if d < 86_400_000 => format!("{}h", d / 3_600_000),
|
||||
d if d < 604_800_000 => format!("{}d", d / 86_400_000),
|
||||
d if d < 2_592_000_000 => format!("{}w", d / 604_800_000),
|
||||
_ => format!("{}mo", diff / 2_592_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table Renderer ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Column alignment for the table renderer.
|
||||
@@ -946,6 +1240,135 @@ mod tests {
|
||||
assert!(plain.contains("1"), "got: {plain}");
|
||||
}
|
||||
|
||||
// ── GlyphMode ──
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_cli_flag_overrides_all() {
|
||||
assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii);
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd);
|
||||
assert_eq!(
|
||||
GlyphMode::detect(Some("unicode"), false),
|
||||
GlyphMode::Unicode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_force_ascii_is_fallback_when_no_explicit_icon_mode() {
|
||||
// Clear env var so it doesn't short-circuit the force_ascii path.
|
||||
// SAFETY: tests run single-threaded per process for env-var-dependent tests.
|
||||
let saved = std::env::var("LORE_ICONS").ok();
|
||||
unsafe { std::env::remove_var("LORE_ICONS") };
|
||||
let result = GlyphMode::detect(None, true);
|
||||
if let Some(v) = saved {
|
||||
unsafe { std::env::set_var("LORE_ICONS", v) };
|
||||
}
|
||||
assert_eq!(result, GlyphMode::Ascii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_force_ascii_does_not_override_cli_flag() {
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Nerd);
|
||||
assert_eq!(GlyphMode::detect(Some("unicode"), true), GlyphMode::Unicode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_unknown_falls_back_to_unicode() {
|
||||
assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode);
|
||||
}
|
||||
|
||||
// ── Icons ──
|
||||
|
||||
#[test]
|
||||
fn icons_return_nonempty_strings() {
|
||||
// Without RENDERER initialized, Icons falls back to Unicode mode
|
||||
assert!(!Icons::success().is_empty());
|
||||
assert!(!Icons::warning().is_empty());
|
||||
assert!(!Icons::error().is_empty());
|
||||
assert!(!Icons::info().is_empty());
|
||||
assert!(!Icons::issue_opened().is_empty());
|
||||
assert!(!Icons::issue_closed().is_empty());
|
||||
assert!(!Icons::mr_opened().is_empty());
|
||||
assert!(!Icons::mr_merged().is_empty());
|
||||
assert!(!Icons::mr_closed().is_empty());
|
||||
assert!(!Icons::mr_draft().is_empty());
|
||||
assert!(!Icons::note().is_empty());
|
||||
assert!(!Icons::search().is_empty());
|
||||
assert!(!Icons::user().is_empty());
|
||||
assert!(!Icons::sync().is_empty());
|
||||
assert!(!Icons::waiting().is_empty());
|
||||
assert!(!Icons::progress_chars().is_empty());
|
||||
}
|
||||
|
||||
// ── format_labels_bare ──
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_empty() {
|
||||
assert_eq!(format_labels_bare(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_no_brackets() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "bug, urgent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_overflow() {
|
||||
let labels = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "a, b +2");
|
||||
}
|
||||
|
||||
// ── format_relative_time_compact ──
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_now() {
|
||||
let recent = now_ms() - 5_000;
|
||||
assert_eq!(format_relative_time_compact(recent), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_minutes() {
|
||||
let mins_ago = now_ms() - 300_000; // 5 minutes
|
||||
assert_eq!(format_relative_time_compact(mins_ago), "5m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_hours() {
|
||||
let hours_ago = now_ms() - 7_200_000; // 2 hours
|
||||
assert_eq!(format_relative_time_compact(hours_ago), "2h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_days() {
|
||||
let days_ago = now_ms() - 172_800_000; // 2 days
|
||||
assert_eq!(format_relative_time_compact(days_ago), "2d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_weeks() {
|
||||
let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks
|
||||
assert_eq!(format_relative_time_compact(weeks_ago), "2w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_months() {
|
||||
let months_ago = now_ms() - 5_184_000_000; // ~2 months
|
||||
assert_eq!(format_relative_time_compact(months_ago), "2mo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_future() {
|
||||
let future = now_ms() + 60_000;
|
||||
assert_eq!(format_relative_time_compact(future), "future");
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
|
||||
@@ -51,8 +51,8 @@ pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
||||
}
|
||||
|
||||
let directives = match verbose {
|
||||
0 => "lore=info,warn",
|
||||
1 => "lore=debug,warn",
|
||||
0 => "lore=warn",
|
||||
1 => "lore=info,warn",
|
||||
2 => "lore=debug,info",
|
||||
_ => "lore=trace,debug",
|
||||
};
|
||||
|
||||
@@ -126,14 +126,21 @@ fn parse_retry_after(response: &reqwest::Response) -> u64 {
|
||||
None => return 60,
|
||||
};
|
||||
|
||||
parse_retry_after_value(header, SystemTime::now())
|
||||
}
|
||||
|
||||
fn parse_retry_after_value(header: &str, now: SystemTime) -> u64 {
|
||||
let header = header.trim();
|
||||
|
||||
if let Ok(secs) = header.parse::<u64>() {
|
||||
return secs.max(1);
|
||||
}
|
||||
|
||||
if let Ok(date) = httpdate::parse_http_date(header)
|
||||
&& let Ok(delta) = date.duration_since(SystemTime::now())
|
||||
{
|
||||
return delta.as_secs().max(1);
|
||||
if let Ok(date) = httpdate::parse_http_date(header) {
|
||||
return match date.duration_since(now) {
|
||||
Ok(delta) => delta.as_secs().max(1),
|
||||
Err(_) => 1,
|
||||
};
|
||||
}
|
||||
|
||||
60
|
||||
|
||||
@@ -244,6 +244,21 @@ async fn test_retry_after_invalid_falls_back_to_60() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_http_date_in_past_returns_one_second() {
|
||||
let now = SystemTime::now();
|
||||
let past = now - Duration::from_secs(120);
|
||||
let date_str = httpdate::fmt_http_date(past);
|
||||
|
||||
assert_eq!(parse_retry_after_value(&date_str, now), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_delta_seconds_trims_whitespace() {
|
||||
let now = SystemTime::now();
|
||||
assert_eq!(parse_retry_after_value(" 120 ", now), 120);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_network_error() {
|
||||
let client = GraphqlClient::new("http://127.0.0.1:1", "token");
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::ops::Deref;
|
||||
|
||||
use futures::StreamExt;
|
||||
use rusqlite::{Connection, Transaction};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
@@ -61,7 +61,7 @@ pub async fn ingest_issues(
|
||||
|
||||
while let Some(issue_result) = issues_stream.next().await {
|
||||
if signal.is_cancelled() {
|
||||
info!("Issue ingestion interrupted by shutdown signal");
|
||||
debug!("Issue ingestion interrupted by shutdown signal");
|
||||
break;
|
||||
}
|
||||
let issue = issue_result?;
|
||||
@@ -108,7 +108,7 @@ pub async fn ingest_issues(
|
||||
|
||||
result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.fetched),
|
||||
("upserted", result.upserted),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
@@ -61,7 +61,7 @@ pub async fn ingest_merge_requests(
|
||||
|
||||
loop {
|
||||
if signal.is_cancelled() {
|
||||
info!("MR ingestion interrupted by shutdown signal");
|
||||
debug!("MR ingestion interrupted by shutdown signal");
|
||||
break;
|
||||
}
|
||||
let page_result = client
|
||||
@@ -121,7 +121,7 @@ pub async fn ingest_merge_requests(
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.fetched),
|
||||
("upserted", result.upserted),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use futures::future::join_all;
|
||||
use rusqlite::Connection;
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::core::dependent_queue::{
|
||||
@@ -45,7 +45,7 @@ pub enum ProgressEvent {
|
||||
MrDiffsFetchStarted { total: usize },
|
||||
MrDiffFetched { current: usize, total: usize },
|
||||
MrDiffsFetchComplete { fetched: usize, failed: usize },
|
||||
StatusEnrichmentStarted,
|
||||
StatusEnrichmentStarted { total: usize },
|
||||
StatusEnrichmentPageFetched { items_so_far: usize },
|
||||
StatusEnrichmentWriting { total: usize },
|
||||
StatusEnrichmentComplete { enriched: usize, cleared: usize },
|
||||
@@ -153,7 +153,16 @@ pub async fn ingest_project_issues_with_progress(
|
||||
if config.sync.fetch_work_item_status && !signal.is_cancelled() {
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
emit(ProgressEvent::StatusEnrichmentStarted);
|
||||
let issue_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM issues WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
emit(ProgressEvent::StatusEnrichmentStarted {
|
||||
total: issue_count as usize,
|
||||
});
|
||||
|
||||
let project_path: Option<String> = conn
|
||||
.query_row(
|
||||
@@ -225,9 +234,10 @@ pub async fn ingest_project_issues_with_progress(
|
||||
Ok((enriched, cleared)) => {
|
||||
result.statuses_enriched = enriched;
|
||||
result.statuses_cleared = cleared;
|
||||
result.statuses_without_widget =
|
||||
result.statuses_seen.saturating_sub(enriched);
|
||||
info!(
|
||||
result.statuses_without_widget = result
|
||||
.statuses_seen
|
||||
.saturating_sub(fetch_result.statuses.len());
|
||||
debug!(
|
||||
seen = result.statuses_seen,
|
||||
enriched,
|
||||
cleared,
|
||||
@@ -282,7 +292,7 @@ pub async fn ingest_project_issues_with_progress(
|
||||
if issues_needing_sync.is_empty() {
|
||||
debug!("No issues need discussion sync");
|
||||
} else {
|
||||
info!(
|
||||
debug!(
|
||||
count = issues_needing_sync.len(),
|
||||
"Starting discussion sync for issues"
|
||||
);
|
||||
@@ -347,7 +357,7 @@ pub async fn ingest_project_issues_with_progress(
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.issues_fetched),
|
||||
("upserted", result.issues_upserted),
|
||||
@@ -402,12 +412,14 @@ fn enrich_issue_statuses_txn(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Apply new/updated statuses
|
||||
// Phase 2: Apply new/updated statuses (only write when values actually differ)
|
||||
{
|
||||
let mut update_stmt = tx.prepare_cached(
|
||||
"UPDATE issues SET status_name = ?1, status_category = ?2, status_color = ?3,
|
||||
status_icon_name = ?4, status_synced_at = ?5
|
||||
WHERE project_id = ?6 AND iid = ?7",
|
||||
WHERE project_id = ?6 AND iid = ?7
|
||||
AND (status_name IS NOT ?1 OR status_category IS NOT ?2
|
||||
OR status_color IS NOT ?3 OR status_icon_name IS NOT ?4)",
|
||||
)?;
|
||||
for (iid, status) in statuses {
|
||||
let rows = update_stmt.execute(rusqlite::params![
|
||||
@@ -423,6 +435,14 @@ fn enrich_issue_statuses_txn(
|
||||
enriched += 1;
|
||||
}
|
||||
}
|
||||
// Update synced_at timestamp for unchanged rows too
|
||||
let mut touch_stmt = tx.prepare_cached(
|
||||
"UPDATE issues SET status_synced_at = ?1
|
||||
WHERE project_id = ?2 AND iid = ?3 AND status_synced_at IS NOT ?1",
|
||||
)?;
|
||||
for iid in statuses.keys() {
|
||||
touch_stmt.execute(rusqlite::params![now_ms, project_id, iid])?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
@@ -558,7 +578,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
if mrs_needing_sync.is_empty() {
|
||||
debug!("No MRs need discussion sync");
|
||||
} else {
|
||||
info!(
|
||||
debug!(
|
||||
count = mrs_needing_sync.len(),
|
||||
"Starting discussion sync for MRs"
|
||||
);
|
||||
@@ -705,7 +725,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
result.mr_diffs_failed = diffs_result.failed;
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.mrs_fetched),
|
||||
("upserted", result.mrs_upserted),
|
||||
@@ -923,7 +943,7 @@ async fn drain_resource_events(
|
||||
|
||||
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||
if reclaimed > 0 {
|
||||
info!(reclaimed, "Reclaimed stale resource event locks");
|
||||
debug!(reclaimed, "Reclaimed stale resource event locks");
|
||||
}
|
||||
|
||||
let claimable_counts = count_claimable_jobs(conn, project_id)?;
|
||||
@@ -1063,7 +1083,7 @@ async fn drain_resource_events(
|
||||
});
|
||||
|
||||
if result.fetched > 0 || result.failed > 0 {
|
||||
info!(
|
||||
debug!(
|
||||
fetched = result.fetched,
|
||||
failed = result.failed,
|
||||
"Resource events drain complete"
|
||||
@@ -1245,7 +1265,7 @@ async fn drain_mr_closes_issues(
|
||||
|
||||
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||
if reclaimed > 0 {
|
||||
info!(reclaimed, "Reclaimed stale mr_closes_issues locks");
|
||||
debug!(reclaimed, "Reclaimed stale mr_closes_issues locks");
|
||||
}
|
||||
|
||||
let claimable_counts = count_claimable_jobs(conn, project_id)?;
|
||||
@@ -1373,7 +1393,7 @@ async fn drain_mr_closes_issues(
|
||||
});
|
||||
|
||||
if result.fetched > 0 || result.failed > 0 {
|
||||
info!(
|
||||
debug!(
|
||||
fetched = result.fetched,
|
||||
failed = result.failed,
|
||||
"mr_closes_issues drain complete"
|
||||
@@ -1505,7 +1525,7 @@ async fn drain_mr_diffs(
|
||||
|
||||
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||
if reclaimed > 0 {
|
||||
info!(reclaimed, "Reclaimed stale mr_diffs locks");
|
||||
debug!(reclaimed, "Reclaimed stale mr_diffs locks");
|
||||
}
|
||||
|
||||
let claimable_counts = count_claimable_jobs(conn, project_id)?;
|
||||
@@ -1630,7 +1650,7 @@ async fn drain_mr_diffs(
|
||||
});
|
||||
|
||||
if result.fetched > 0 || result.failed > 0 {
|
||||
info!(
|
||||
debug!(
|
||||
fetched = result.fetched,
|
||||
failed = result.failed,
|
||||
"mr_diffs drain complete"
|
||||
|
||||
57
src/main.rs
57
src/main.rs
@@ -25,7 +25,7 @@ use lore::cli::commands::{
|
||||
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||
run_sync_status, run_timeline, run_who,
|
||||
};
|
||||
use lore::cli::render::{ColorMode, LoreRenderer, Theme};
|
||||
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
use lore::cli::{
|
||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
||||
@@ -144,25 +144,27 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// I1: Respect NO_COLOR convention (https://no-color.org/)
|
||||
// Icon mode is independent of color flags; robot mode still defaults to ASCII.
|
||||
let glyphs = GlyphMode::detect(cli.icons.as_deref(), robot_mode);
|
||||
|
||||
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
|
||||
LoreRenderer::init(ColorMode::Never);
|
||||
LoreRenderer::init(ColorMode::Never, glyphs);
|
||||
console::set_colors_enabled(false);
|
||||
} else {
|
||||
match cli.color.as_str() {
|
||||
"never" => {
|
||||
LoreRenderer::init(ColorMode::Never);
|
||||
LoreRenderer::init(ColorMode::Never, glyphs);
|
||||
console::set_colors_enabled(false);
|
||||
}
|
||||
"always" => {
|
||||
LoreRenderer::init(ColorMode::Always);
|
||||
LoreRenderer::init(ColorMode::Always, glyphs);
|
||||
console::set_colors_enabled(true);
|
||||
}
|
||||
"auto" => {
|
||||
LoreRenderer::init(ColorMode::Auto);
|
||||
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||
}
|
||||
other => {
|
||||
LoreRenderer::init(ColorMode::Auto);
|
||||
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
||||
}
|
||||
}
|
||||
@@ -409,9 +411,15 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
);
|
||||
std::process::exit(gi_error.exit_code());
|
||||
} else {
|
||||
eprintln!("{} {}", Theme::error().render("Error:"), gi_error);
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render(&gi_error.to_string())
|
||||
);
|
||||
if let Some(suggestion) = gi_error.suggestion() {
|
||||
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
|
||||
eprintln!();
|
||||
eprintln!(" {suggestion}");
|
||||
}
|
||||
let actions = gi_error.actions();
|
||||
if !actions.is_empty() {
|
||||
@@ -419,11 +427,12 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
for action in &actions {
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::dim().render("$"),
|
||||
Theme::dim().render("\u{2192}"),
|
||||
Theme::bold().render(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
std::process::exit(gi_error.exit_code());
|
||||
}
|
||||
}
|
||||
@@ -443,7 +452,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {}", Theme::error().render("Error:"), e);
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render(&e.to_string())
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -1901,9 +1916,9 @@ async fn handle_search(
|
||||
limit: args.limit,
|
||||
};
|
||||
|
||||
let spinner = lore::cli::progress::stage_spinner(
|
||||
1,
|
||||
1,
|
||||
let spinner = lore::cli::progress::stage_spinner_v2(
|
||||
lore::cli::render::Icons::search(),
|
||||
"Search",
|
||||
&format!("Searching ({})...", args.mode),
|
||||
robot_mode,
|
||||
);
|
||||
@@ -1966,7 +1981,6 @@ async fn handle_embed(
|
||||
args: EmbedArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -1985,18 +1999,7 @@ async fn handle_embed(
|
||||
std::process::exit(130);
|
||||
});
|
||||
|
||||
let embed_bar = if robot_mode {
|
||||
ProgressBar::hidden()
|
||||
} else {
|
||||
let b = lore::cli::progress::multi().add(ProgressBar::new(0));
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
b
|
||||
};
|
||||
let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode);
|
||||
let bar_clone = embed_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_clone = Arc::clone(&tick_started);
|
||||
|
||||
Reference in New Issue
Block a user