refactor(sync): overhaul progress display with stage spinners and summaries
Phase 2 of the UX overhaul. Replaces the old numbered-stage progress system (1/4, 2/4...) and manual indicatif ProgressBar/ProgressStyle setup with the new centralized progress helpers. Sync command changes (src/cli/commands/sync.rs): - Replace stage_spinner(n, total, msg) with stage_spinner_v2(icon, label, status) removing the rigid numbered-stage counter in favor of named stages - Replace manual ProgressBar::new + ProgressStyle::default_bar for docs and embed sub-progress with nested_progress(label, len, robot_mode) - Add finish_stage() calls that display a completion summary with elapsed time, e.g. "Issues 42 issues from 3 projects 1.2s" - Each stage (Issues, MRs, Docs, Embed) now reports what it did on completion rather than just clearing the spinner silently - Embed failure path uses Icons::warning() instead of inline Theme formatting, keeping error display consistent with success path - Remove indicatif direct dependency from sync.rs (now handled by progress module) Main entry point changes (src/main.rs): - Add GlyphMode detection: auto-detect Unicode/Nerd Font support or fall back to ASCII based on --icons flag, --color=never, NO_COLOR, or robot mode - Update all LoreRenderer::init() calls to pass GlyphMode alongside ColorMode for icon-aware rendering throughout the CLI - Overhaul handle_error() formatting: use Icons::error() glyph, bold error text, arrow prefixed action suggestions, and breathing room with blank lines for scannability - Migrate handle_embed() progress bar from manual ProgressBar + ProgressStyle to nested_progress() helper, matching sync command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
teernisse
parent
96b288ccdd
commit
af8fc4af76
@@ -1,13 +1,13 @@
|
|||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Icons, Theme, format_number};
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::Config;
|
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::error::Result;
|
||||||
use crate::core::metrics::{MetricsLayer, StageTiming};
|
use crate::core::metrics::{MetricsLayer, StageTiming};
|
||||||
use crate::core::shutdown::ShutdownSignal;
|
use crate::core::shutdown::ShutdownSignal;
|
||||||
@@ -76,23 +76,10 @@ pub async fn run_sync(
|
|||||||
IngestDisplay::progress_only()
|
IngestDisplay::progress_only()
|
||||||
};
|
};
|
||||||
|
|
||||||
let total_stages: u8 = if options.no_docs && options.no_embed {
|
// ── Stage: Issues ──
|
||||||
2
|
let stage_start = Instant::now();
|
||||||
} else if options.no_docs || options.no_embed {
|
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
|
||||||
3
|
info!("Sync: ingesting issues");
|
||||||
} 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");
|
|
||||||
let issues_result = run_ingest(
|
let issues_result = run_ingest(
|
||||||
config,
|
config,
|
||||||
"issues",
|
"issues",
|
||||||
@@ -110,21 +97,23 @@ pub async fn run_sync(
|
|||||||
result.resource_events_fetched += issues_result.resource_events_fetched;
|
result.resource_events_fetched += issues_result.resource_events_fetched;
|
||||||
result.resource_events_failed += issues_result.resource_events_failed;
|
result.resource_events_failed += issues_result.resource_events_failed;
|
||||||
result.status_enrichment_errors += issues_result.status_enrichment_errors;
|
result.status_enrichment_errors += issues_result.status_enrichment_errors;
|
||||||
spinner.finish_and_clear();
|
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 signal.is_cancelled() {
|
if signal.is_cancelled() {
|
||||||
info!("Shutdown requested after issues stage, returning partial sync results");
|
info!("Shutdown requested after issues stage, returning partial sync results");
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
current_stage += 1;
|
// ── Stage: MRs ──
|
||||||
let spinner = stage_spinner(
|
let stage_start = Instant::now();
|
||||||
current_stage,
|
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
|
||||||
total_stages,
|
info!("Sync: ingesting merge requests");
|
||||||
"Fetching merge requests from GitLab...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
|
|
||||||
let mrs_result = run_ingest(
|
let mrs_result = run_ingest(
|
||||||
config,
|
config,
|
||||||
"mrs",
|
"mrs",
|
||||||
@@ -143,37 +132,26 @@ pub async fn run_sync(
|
|||||||
result.resource_events_failed += mrs_result.resource_events_failed;
|
result.resource_events_failed += mrs_result.resource_events_failed;
|
||||||
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
||||||
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
|
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
|
||||||
spinner.finish_and_clear();
|
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 signal.is_cancelled() {
|
if signal.is_cancelled() {
|
||||||
info!("Shutdown requested after MRs stage, returning partial sync results");
|
info!("Shutdown requested after MRs stage, returning partial sync results");
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage: Docs ──
|
||||||
if !options.no_docs {
|
if !options.no_docs {
|
||||||
current_stage += 1;
|
let stage_start = Instant::now();
|
||||||
let spinner = stage_spinner(
|
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
|
||||||
current_stage,
|
info!("Sync: generating documents");
|
||||||
total_stages,
|
|
||||||
"Processing documents...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: generating documents");
|
|
||||||
|
|
||||||
let docs_bar = if options.robot_mode {
|
let docs_bar = nested_progress("Docs", 0, 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_clone = docs_bar.clone();
|
let docs_bar_clone = docs_bar.clone();
|
||||||
let tick_started = Arc::new(AtomicBool::new(false));
|
let tick_started = Arc::new(AtomicBool::new(false));
|
||||||
let tick_started_clone = Arc::clone(&tick_started);
|
let tick_started_clone = Arc::clone(&tick_started);
|
||||||
@@ -189,35 +167,22 @@ pub async fn run_sync(
|
|||||||
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
|
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
|
||||||
result.documents_regenerated = docs_result.regenerated;
|
result.documents_regenerated = docs_result.regenerated;
|
||||||
docs_bar.finish_and_clear();
|
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 {
|
} else {
|
||||||
info!("Sync: skipping document generation (--no-docs)");
|
info!("Sync: skipping document generation (--no-docs)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage: Embed ──
|
||||||
if !options.no_embed {
|
if !options.no_embed {
|
||||||
current_stage += 1;
|
let stage_start = Instant::now();
|
||||||
let spinner = stage_spinner(
|
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
|
||||||
current_stage,
|
info!("Sync: embedding documents");
|
||||||
total_stages,
|
|
||||||
"Generating embeddings...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
|
|
||||||
|
|
||||||
let embed_bar = if options.robot_mode {
|
let embed_bar = nested_progress("Embed", 0, 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_clone = embed_bar.clone();
|
let embed_bar_clone = embed_bar.clone();
|
||||||
let tick_started = Arc::new(AtomicBool::new(false));
|
let tick_started = Arc::new(AtomicBool::new(false));
|
||||||
let tick_started_clone = Arc::clone(&tick_started);
|
let tick_started_clone = Arc::clone(&tick_started);
|
||||||
@@ -234,14 +199,16 @@ pub async fn run_sync(
|
|||||||
Ok(embed_result) => {
|
Ok(embed_result) => {
|
||||||
result.documents_embedded = embed_result.docs_embedded;
|
result.documents_embedded = embed_result.docs_embedded;
|
||||||
embed_bar.finish_and_clear();
|
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) => {
|
Err(e) => {
|
||||||
embed_bar.finish_and_clear();
|
embed_bar.finish_and_clear();
|
||||||
spinner.finish_and_clear();
|
let warn_summary = format!("skipped ({})", e);
|
||||||
if !options.robot_mode {
|
finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
|
||||||
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
|
|
||||||
}
|
|
||||||
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/main.rs
52
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_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||||
run_sync_status, run_timeline, run_who,
|
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::robot::{RobotMeta, strip_schemas};
|
||||||
use lore::cli::{
|
use lore::cli::{
|
||||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
||||||
@@ -145,24 +145,29 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// I1: Respect NO_COLOR convention (https://no-color.org/)
|
// I1: Respect NO_COLOR convention (https://no-color.org/)
|
||||||
|
let force_ascii = robot_mode
|
||||||
|
|| cli.color == "never"
|
||||||
|
|| std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty());
|
||||||
|
let glyphs = GlyphMode::detect(cli.icons.as_deref(), force_ascii);
|
||||||
|
|
||||||
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
|
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);
|
console::set_colors_enabled(false);
|
||||||
} else {
|
} else {
|
||||||
match cli.color.as_str() {
|
match cli.color.as_str() {
|
||||||
"never" => {
|
"never" => {
|
||||||
LoreRenderer::init(ColorMode::Never);
|
LoreRenderer::init(ColorMode::Never, glyphs);
|
||||||
console::set_colors_enabled(false);
|
console::set_colors_enabled(false);
|
||||||
}
|
}
|
||||||
"always" => {
|
"always" => {
|
||||||
LoreRenderer::init(ColorMode::Always);
|
LoreRenderer::init(ColorMode::Always, glyphs);
|
||||||
console::set_colors_enabled(true);
|
console::set_colors_enabled(true);
|
||||||
}
|
}
|
||||||
"auto" => {
|
"auto" => {
|
||||||
LoreRenderer::init(ColorMode::Auto);
|
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
LoreRenderer::init(ColorMode::Auto);
|
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||||
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,9 +414,15 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
);
|
);
|
||||||
std::process::exit(gi_error.exit_code());
|
std::process::exit(gi_error.exit_code());
|
||||||
} else {
|
} 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() {
|
if let Some(suggestion) = gi_error.suggestion() {
|
||||||
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
|
eprintln!();
|
||||||
|
eprintln!(" {suggestion}");
|
||||||
}
|
}
|
||||||
let actions = gi_error.actions();
|
let actions = gi_error.actions();
|
||||||
if !actions.is_empty() {
|
if !actions.is_empty() {
|
||||||
@@ -419,11 +430,12 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
for action in &actions {
|
for action in &actions {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {} {}",
|
" {} {}",
|
||||||
Theme::dim().render("$"),
|
Theme::dim().render("\u{2192}"),
|
||||||
Theme::bold().render(action)
|
Theme::bold().render(action)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eprintln!();
|
||||||
std::process::exit(gi_error.exit_code());
|
std::process::exit(gi_error.exit_code());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,7 +455,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -1966,7 +1984,6 @@ async fn handle_embed(
|
|||||||
args: EmbedArgs,
|
args: EmbedArgs,
|
||||||
robot_mode: bool,
|
robot_mode: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
@@ -1985,18 +2002,7 @@ async fn handle_embed(
|
|||||||
std::process::exit(130);
|
std::process::exit(130);
|
||||||
});
|
});
|
||||||
|
|
||||||
let embed_bar = if robot_mode {
|
let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, 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 bar_clone = embed_bar.clone();
|
let bar_clone = embed_bar.clone();
|
||||||
let tick_started = Arc::new(AtomicBool::new(false));
|
let tick_started = Arc::new(AtomicBool::new(false));
|
||||||
let tick_clone = Arc::clone(&tick_started);
|
let tick_clone = Arc::clone(&tick_started);
|
||||||
|
|||||||
Reference in New Issue
Block a user