fix: more work on background tasks

This commit is contained in:
2026-07-24 11:43:11 +02:00
parent c187108f89
commit da13bac755
45 changed files with 2905 additions and 732 deletions

View File

@@ -355,7 +355,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
let progress = Arc::new(Mutex::new(Vec::new()));
let progress_sink = Arc::clone(&progress);
let (report, thumbnails) = cli_sync::run_cli_mutation(db.conn(), || {
let report = cli_sync::run_cli_mutation(db.conn(), || {
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(),
&data_dir,
@@ -369,12 +369,11 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
if progress.last() != Some(&line) {
progress.push(line);
}
true
})),
)?;
let thumbnails =
engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?;
emit_bulk(&project.id);
Ok((report, thumbnails))
Ok(report)
})?;
let mut result = output(
"Rebuild complete",
@@ -389,8 +388,8 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
"templates_updated": report.templates_updated,
"scripts_created": report.scripts_created,
"scripts_updated": report.scripts_updated,
"thumbnails_generated": thumbnails.thumbnails_generated,
"thumbnail_media_failed": thumbnails.media_failed,
"thumbnails_generated": report.thumbnails_generated,
"thumbnail_media_failed": report.thumbnail_media_failed,
}),
);
result.progress = progress

View File

@@ -6,7 +6,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection;
use crate::db::queries::post as post_q;
use crate::engine::EngineResult;
use crate::engine::{EngineError, EngineResult};
use crate::model::PostStatus;
use crate::util::{atomic_write, timestamp};
@@ -18,6 +18,15 @@ pub fn regenerate_calendar(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<()> {
regenerate_calendar_with_progress(conn, data_dir, project_id, |_, _, _| true)
}
pub fn regenerate_calendar_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_item: impl FnMut(usize, usize, &str) -> bool,
) -> EngineResult<()> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
@@ -25,7 +34,11 @@ pub fn regenerate_calendar(
let mut months: BTreeMap<String, u32> = BTreeMap::new();
let mut days: BTreeMap<String, u32> = BTreeMap::new();
for post in &posts {
let total = posts.len() + 1;
for (index, post) in posts.iter().enumerate() {
if !on_item(index + 1, total, &post.title) {
return Err(EngineError::Cancelled);
}
if post.status != PostStatus::Published {
continue;
}
@@ -41,6 +54,10 @@ pub fn regenerate_calendar(
*days.entry(day_key).or_insert(0) += 1;
}
if !on_item(total, total, "calendar.json") {
return Err(EngineError::Cancelled);
}
let calendar = serde_json::json!({
"years": years,
"months": months,
@@ -82,4 +99,20 @@ mod tests {
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
assert!(data["years"].as_object().unwrap().is_empty());
}
#[test]
fn cancellation_before_write_leaves_no_calendar() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = tempfile::tempdir().unwrap();
let project =
engine::project::create_project(db.conn(), "Test", Some(dir.path().to_str().unwrap()))
.unwrap();
let result =
regenerate_calendar_with_progress(db.conn(), dir.path(), &project.id, |_, _, _| false);
assert!(matches!(result, Err(EngineError::Cancelled)));
assert!(!dir.path().join("html/calendar.json").exists());
}
}

View File

@@ -15,6 +15,8 @@ pub enum EngineError {
Conflict(String),
#[error("validation error: {0}")]
Validation(String),
#[error("operation cancelled")]
Cancelled,
}
impl From<crate::db::DatabaseError> for EngineError {

View File

@@ -63,6 +63,15 @@ pub struct PreparedSiteGeneration {
generated_hashes: Arc<HashMap<String, String>>,
}
impl std::fmt::Debug for PreparedSiteGeneration {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PreparedSiteGeneration")
.field("source_count", &self.sources.len())
.finish_non_exhaustive()
}
}
pub fn prepare_site_generation(
conn: &Connection,
data_dir: &Path,
@@ -91,6 +100,34 @@ pub fn prepare_site_generation(
})
}
pub fn prepared_section_page_count(
prepared: &PreparedSiteGeneration,
validation: Option<&SiteValidationReport>,
section: GenerationSection,
) -> usize {
let requested = validation.map(|validation| {
validation
.missing_pages
.iter()
.chain(validation.stale_pages.iter())
.cloned()
.collect::<HashSet<_>>()
});
let fallback = validation.is_some_and(|validation| {
validation
.missing_pages
.iter()
.chain(validation.extra_pages.iter())
.chain(validation.stale_pages.iter())
.any(|path| classify_generated_path(path, &prepared.metadata).is_none())
});
crate::render::count_site_render_pages_from_context(
&prepared.render,
section,
(!fallback).then_some(requested.as_ref()).flatten(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationSection {
Core,

View File

@@ -53,9 +53,18 @@ pub fn rebuild_media_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
rebuild_media_links_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_media_links_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
conn.begin_savepoint()?;
match rebuild_media_links_inner(conn, data_dir, project_id) {
match rebuild_media_links_inner(conn, data_dir, project_id, on_item.as_ref()) {
Ok(report) => {
conn.release_savepoint()?;
Ok(report)
@@ -71,9 +80,16 @@ fn rebuild_media_links_inner(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<&ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
let mut report = MediaLinkRebuildReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
let items = qm::list_media_by_project(conn, project_id)?;
for (index, item) in items.iter().enumerate() {
if let Some(callback) = on_item
&& !callback(index + 1, items.len(), &item.original_name)
{
return Err(EngineError::Cancelled);
}
let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
.map_err(EngineError::Parse)?;
for link in qpm::list_post_media_by_media(conn, &item.id)? {
@@ -110,9 +126,22 @@ pub fn regenerate_missing_thumbnails(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ThumbnailRepairReport> {
regenerate_missing_thumbnails_with_progress(conn, data_dir, project_id, |_, _, _| true)
}
pub fn regenerate_missing_thumbnails_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_item: impl FnMut(usize, usize, &str) -> bool,
) -> EngineResult<ThumbnailRepairReport> {
let mut report = ThumbnailRepairReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
let items = qm::list_media_by_project(conn, project_id)?;
for (index, item) in items.iter().enumerate() {
if !on_item(index + 1, items.len(), &item.original_name) {
return Err(EngineError::Cancelled);
}
if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
continue;
}
@@ -653,7 +682,7 @@ pub fn rebuild_media_from_filesystem(
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Like `rebuild_media_from_filesystem` but with optional per-item progress.
pub fn rebuild_media_from_filesystem_with_progress(
@@ -699,7 +728,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
for (i, path) in canonical_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(i + 1, total, name);
if !cb(i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => {
@@ -720,7 +751,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
for (i, path) in translation_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(offset + i + 1, total, name);
if !cb(offset + i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => {

View File

@@ -70,8 +70,27 @@ pub fn compute_metadata_diff(
data_dir: &Path,
project_id: &str,
) -> EngineResult<DiffReport> {
compute_metadata_diff_with_progress(conn, data_dir, project_id, |_, _| true)
}
pub fn compute_metadata_diff_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_progress: impl FnMut(usize, usize) -> bool,
) -> EngineResult<DiffReport> {
const PHASES: usize = 8;
let mut phase = 0;
let mut next_phase = || -> EngineResult<()> {
if !on_progress(phase, PHASES) {
return Err(crate::engine::EngineError::Cancelled);
}
phase += 1;
Ok(())
};
let mut report = DiffReport::default();
next_phase()?;
if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
match diff_project(conn, data_dir, &project) {
Ok(Some(diff)) => report.diffs.push(diff),
@@ -94,6 +113,7 @@ pub fn compute_metadata_diff(
}
// 1. Diff posts
next_phase()?;
let posts = qp::list_posts_by_project(conn, project_id)?;
for post in &posts {
if post.file_path.is_empty() {
@@ -107,6 +127,7 @@ pub fn compute_metadata_diff(
}
// 2. Diff translations
next_phase()?;
for post in &posts {
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations {
@@ -122,6 +143,7 @@ pub fn compute_metadata_diff(
}
// 3. Diff media
next_phase()?;
let media_items = qm::list_media_by_project(conn, project_id)?;
for m in &media_items {
if m.sidecar_path.is_empty() {
@@ -146,6 +168,7 @@ pub fn compute_metadata_diff(
}
// 4. Diff templates
next_phase()?;
let templates = qtpl::list_templates_by_project(conn, project_id)?;
for t in &templates {
if t.file_path.is_empty() {
@@ -159,6 +182,7 @@ pub fn compute_metadata_diff(
}
// 5. Diff scripts
next_phase()?;
let scripts = qs::list_scripts_by_project(conn, project_id)?;
for s in &scripts {
if s.file_path.is_empty() {
@@ -172,6 +196,7 @@ pub fn compute_metadata_diff(
}
// 6. Detect orphans
next_phase()?;
if crate::engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
@@ -215,9 +240,14 @@ pub fn compute_metadata_diff(
}
// 7. Detect orphans
next_phase()?;
let orphans = detect_orphan_files(conn, data_dir, project_id)?;
report.orphans = orphans;
if !on_progress(PHASES, PHASES) {
return Err(crate::engine::EngineError::Cancelled);
}
Ok(report)
}

View File

@@ -716,7 +716,7 @@ pub fn rebuild_posts_from_filesystem(
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Like `rebuild_posts_from_filesystem` but with optional per-item progress.
pub fn rebuild_posts_from_filesystem_with_progress(
@@ -760,7 +760,9 @@ pub fn rebuild_posts_from_filesystem_with_progress(
for (i, path) in canonical_files.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(i + 1, total, name);
if !cb(i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_canonical_post(conn, data_dir, project_id, path) {
Ok(created) => {
@@ -781,7 +783,9 @@ pub fn rebuild_posts_from_filesystem_with_progress(
for (i, path) in translation_files.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(offset + i + 1, total, name);
if !cb(offset + i + 1, total, name) {
return Err(EngineError::Cancelled);
}
}
match rebuild_translation(conn, data_dir, project_id, path) {
Ok(created) => {
@@ -816,11 +820,25 @@ pub fn rebuild_all_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<usize> {
rebuild_all_links_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_all_links_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<usize> {
let posts = qp::list_posts_by_project(conn, project_id)?;
let mut link_count = 0;
for post in &posts {
for (index, post) in posts.iter().enumerate() {
if let Some(ref callback) = on_item
&& !callback(index + 1, posts.len(), &post.title)
{
return Err(EngineError::Cancelled);
}
// Get post content: from DB or filesystem
let content = if let Some(ref content) = post.content {
content.clone()

View File

@@ -1,7 +1,10 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
@@ -124,6 +127,26 @@ pub fn upload_site(
private_cache_dir: &Path,
preferences: &PublishingPreferences,
mut on_progress: impl FnMut(usize, usize, UploadTargetKind),
) -> EngineResult<PublishJob> {
let cancelled = AtomicBool::new(false);
upload_site_cancellable(
data_dir,
private_cache_dir,
preferences,
&cancelled,
move |current, total, kind| {
on_progress(current, total, kind);
true
},
)
}
pub fn upload_site_cancellable(
data_dir: &Path,
private_cache_dir: &Path,
preferences: &PublishingPreferences,
cancel_flag: &AtomicBool,
mut on_progress: impl FnMut(usize, usize, UploadTargetKind) -> bool,
) -> EngineResult<PublishJob> {
if std::env::var_os("SSH_AUTH_SOCK").is_none() {
return Err(EngineError::Validation(
@@ -134,7 +157,7 @@ pub fn upload_site(
data_dir,
private_cache_dir,
preferences,
&mut |program, args| run_command(program, args),
&mut |program, args| run_command_cancellable(program, args, cancel_flag),
&mut on_progress,
)
}
@@ -144,7 +167,7 @@ fn upload_site_with_runner(
private_cache_dir: &Path,
preferences: &PublishingPreferences,
runner: &mut CommandRunner<'_>,
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind),
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind) -> bool,
) -> EngineResult<PublishJob> {
let credentials = Credentials::from_preferences(preferences)?;
let targets = build_upload_targets(data_dir, &credentials);
@@ -162,7 +185,9 @@ fn upload_site_with_runner(
let cache_path = private_cache_dir.join("publishing-scp-mtimes.json");
let mut cache = read_cache(&cache_path);
for (index, target) in targets.iter().enumerate() {
on_progress(index + 1, targets.len(), target.kind);
if !on_progress(index + 1, targets.len(), target.kind) {
return Err(EngineError::Cancelled);
}
let result = match credentials.mode {
SshMode::Rsync => upload_rsync(target, &credentials, runner),
SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner),
@@ -328,21 +353,49 @@ fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> {
Ok(())
}
fn run_command(program: &str, args: &[String]) -> Result<(), String> {
let output = Command::new(program)
fn run_command_cancellable(
program: &str,
args: &[String],
cancel_flag: &AtomicBool,
) -> Result<(), String> {
let mut child = Command::new(program)
.args(args)
.output()
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("failed to start {program}: {error}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{program} exited with {}", output.status)
} else {
stderr
})
let stderr = child.stderr.take();
let stderr_reader = std::thread::spawn(move || {
let mut output = String::new();
if let Some(mut stderr) = stderr {
let _ = stderr.read_to_string(&mut output);
}
output
});
let status = loop {
if cancel_flag.load(Ordering::Acquire) {
let _ = child.kill();
let _ = child.wait();
let _ = stderr_reader.join();
return Err("operation cancelled".into());
}
if let Some(status) = child
.try_wait()
.map_err(|error| format!("failed to wait for {program}: {error}"))?
{
break status;
}
std::thread::sleep(Duration::from_millis(50));
};
let stderr = stderr_reader.join().unwrap_or_default().trim().to_string();
if status.success() {
return Ok(());
}
Err(if stderr.is_empty() {
format!("{program} exited with {status}")
} else {
stderr
})
}
#[cfg(test)]
@@ -376,7 +429,7 @@ mod tests {
commands.push((program.to_owned(), args.to_vec()));
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
@@ -397,6 +450,28 @@ mod tests {
);
}
#[test]
fn cancellation_stops_before_starting_the_next_target() {
let dir = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("html")).unwrap();
let mut commands = 0;
let result = upload_site_with_runner(
dir.path(),
cache.path(),
&preferences(SshMode::Rsync),
&mut |_, _| {
commands += 1;
Ok(())
},
&mut |_, _, _| false,
);
assert!(matches!(result, Err(EngineError::Cancelled)));
assert_eq!(commands, 0);
}
#[test]
fn scp_excludes_sidecars_and_skips_unchanged_files() {
let dir = TempDir::new().unwrap();
@@ -415,7 +490,7 @@ mod tests {
first.push((program.to_owned(), args.to_vec()));
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
assert_eq!(
@@ -437,7 +512,7 @@ mod tests {
second.push(program.to_owned());
Ok(())
},
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap();
assert!(second.is_empty());
@@ -454,7 +529,7 @@ mod tests {
cache.path(),
&preferences(SshMode::Rsync),
&mut |_, _| Err("network down".into()),
&mut |_, _, _| {},
&mut |_, _, _| true,
)
.unwrap_err();
assert!(error.to_string().contains("network down"));

View File

@@ -27,6 +27,8 @@ pub struct FullRebuildReport {
pub templates_updated: usize,
pub scripts_created: usize,
pub scripts_updated: usize,
pub thumbnails_generated: usize,
pub thumbnail_media_failed: usize,
pub errors: Vec<String>,
}
@@ -91,8 +93,19 @@ pub enum RebuildProgress {
name: String,
},
RebuildingTemplates,
TemplateItem {
current: usize,
total: usize,
name: String,
},
RebuildingScripts,
ScriptItem {
current: usize,
total: usize,
name: String,
},
ImportingTags,
RebuildingThumbnails,
RefreshingSemanticIndex,
Complete,
}
@@ -116,8 +129,19 @@ impl RebuildProgress {
name,
} => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"),
Self::TemplateItem {
current,
total,
name,
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"),
Self::ScriptItem {
current,
total,
name,
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
Self::ImportingTags => translate(locale, "engine.progress.importingTags"),
Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"),
Self::RefreshingSemanticIndex => {
translate(locale, "engine.progress.refreshingSemanticIndex")
}
@@ -137,7 +161,7 @@ fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name
}
/// Progress callback: (percent 0.0..1.0, semantic progress event).
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) + Send + Sync>;
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) -> bool + Send + Sync>;
/// Orchestrate a full rebuild from filesystem into the database.
///
@@ -178,23 +202,26 @@ fn rebuild_from_filesystem_inner(
on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
let mut report = FullRebuildReport::default();
let progress = |pct: f32, event: RebuildProgress| {
if let Some(ref f) = on_progress {
f(pct, &event);
let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> {
if let Some(ref f) = on_progress
&& !f(pct, &event)
{
return Err(crate::engine::EngineError::Cancelled);
}
Ok(())
};
// Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0
// 1. Load portable project metadata and clear all reconstructible rows.
progress(0.0, RebuildProgress::LoadingProjectMetadata);
progress(0.0, RebuildProgress::LoadingProjectMetadata)?;
fts::ensure_fts_tables(conn)?;
crate::engine::meta::startup_sync(data_dir)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
clear_project_rows(conn, project_id)?;
// 2. Rebuild posts (0.00 .. 0.35)
progress(0.01, RebuildProgress::ScanningPosts);
progress(0.01, RebuildProgress::ScanningPosts)?;
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
@@ -211,7 +238,7 @@ fn rebuild_from_filesystem_inner(
total,
name: name.to_string(),
},
);
)
});
f
});
@@ -228,7 +255,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(post_report.errors);
// 3. Rebuild media (0.35 .. 0.70)
progress(0.35, RebuildProgress::ScanningMedia);
progress(0.35, RebuildProgress::ScanningMedia)?;
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
@@ -245,7 +272,7 @@ fn rebuild_from_filesystem_inner(
total,
name: name.to_string(),
},
);
)
});
f
});
@@ -262,26 +289,87 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(media_report.errors);
// 4. Rebuild templates (0.70 .. 0.85)
progress(0.70, RebuildProgress::RebuildingTemplates);
let tpl_report =
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
progress(0.70, RebuildProgress::RebuildingTemplates)?;
let template_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.70 + current as f32 / total.max(1) as f32 * 0.15,
&RebuildProgress::TemplateItem {
current,
total,
name: name.to_string(),
},
)
}) as template_rebuild::ItemProgressFn
});
let tpl_report = template_rebuild::rebuild_templates_from_filesystem_with_progress(
conn,
data_dir,
project_id,
template_progress,
)?;
report.templates_created = tpl_report.created;
report.templates_updated = tpl_report.updated;
report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts (0.85 .. 0.95)
progress(0.85, RebuildProgress::RebuildingScripts);
let script_report =
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
progress(0.85, RebuildProgress::RebuildingScripts)?;
let script_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.85 + current as f32 / total.max(1) as f32 * 0.10,
&RebuildProgress::ScriptItem {
current,
total,
name: name.to_string(),
},
)
}) as script_rebuild::ItemProgressFn
});
let script_report = script_rebuild::rebuild_scripts_from_filesystem_with_progress(
conn,
data_dir,
project_id,
script_progress,
)?;
report.scripts_created = script_report.created;
report.scripts_updated = script_report.updated;
report.errors.extend(script_report.errors);
// 6. Restore relationships and tags (0.95 .. 1.0)
progress(0.95, RebuildProgress::ImportingTags);
progress(0.95, RebuildProgress::ImportingTags)?;
super::tag::import_tags_from_file(conn, data_dir, project_id)?;
super::tag::sync_tags_from_posts(conn, project_id)?;
post::rebuild_all_links(conn, data_dir, project_id)?;
let link_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.95 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::PostItem {
current,
total,
name: name.to_string(),
},
)
}) as post::ItemProgressFn
});
post::rebuild_all_links_with_progress(conn, data_dir, project_id, link_progress)?;
let media_link_progress = on_progress.as_ref().map(|callback| {
let callback = Arc::clone(callback);
Box::new(move |current: usize, total: usize, name: &str| {
callback(
0.96 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
)
}) as media::ItemProgressFn
});
media::rebuild_media_links_with_progress(conn, data_dir, project_id, media_link_progress)?;
if !report.errors.is_empty() {
return Err(crate::engine::EngineError::Validation(format!(
@@ -290,11 +378,39 @@ fn rebuild_from_filesystem_inner(
)));
}
progress(0.98, RebuildProgress::RefreshingSemanticIndex);
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed(project_id)?;
progress(0.97, RebuildProgress::RebuildingThumbnails)?;
let thumbnail_report = media::regenerate_missing_thumbnails_with_progress(
conn,
data_dir,
project_id,
|current, total, name| {
on_progress.as_ref().is_none_or(|callback| {
callback(
0.97 + current as f32 / total.max(1) as f32 * 0.02,
&RebuildProgress::MediaItem {
current,
total,
name: name.to_string(),
},
)
})
},
)?;
report.thumbnails_generated = thumbnail_report.thumbnails_generated;
report.thumbnail_media_failed = thumbnail_report.media_failed;
progress(1.0, RebuildProgress::Complete);
progress(0.99, RebuildProgress::RefreshingSemanticIndex)?;
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed_with_progress(project_id, |current, total| {
on_progress.as_ref().is_none_or(|callback| {
callback(
0.99 + current as f32 / total.max(1) as f32 * 0.01,
&RebuildProgress::RefreshingSemanticIndex,
)
})
})?;
progress(1.0, RebuildProgress::Complete)?;
Ok(report)
}
@@ -445,6 +561,36 @@ mod tests {
assert!(report.errors.is_empty());
}
#[test]
fn cancellation_rolls_back_the_full_rebuild_savepoint() {
let (db, dir) = setup();
let post = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"Preserved",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let result = rebuild_from_filesystem_with_progress(
db.conn(),
dir.path(),
"p1",
Some(Arc::new(|_, event| {
!matches!(event, RebuildProgress::ScanningPosts)
})),
);
assert!(matches!(result, Err(crate::engine::EngineError::Cancelled)));
assert!(qp::get_post_by_id(db.conn(), &post.id).is_ok());
}
#[test]
fn rebuild_creates_posts_and_media() {
let (db, dir) = setup();

View File

@@ -17,6 +17,8 @@ pub struct ScriptRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild scripts from the filesystem into the database.
///
/// Walks the `scripts/` directory for `*.lua` files, parses each
@@ -26,6 +28,15 @@ pub fn rebuild_scripts_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ScriptRebuildReport> {
rebuild_scripts_from_filesystem_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_scripts_from_filesystem_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<ScriptRebuildReport> {
let mut report = ScriptRebuildReport::default();
let scripts_dir = data_dir.join("scripts");
@@ -34,17 +45,23 @@ pub fn rebuild_scripts_from_filesystem(
return Ok(report);
}
for entry in WalkDir::new(&scripts_dir)
let files = WalkDir::new(&scripts_dir)
.into_iter()
.filter_map(|e| e.ok())
{
.filter(|entry| entry.path().is_file())
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("lua"))
.collect::<Vec<_>>();
for (index, entry) in files.iter().enumerate() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("lua") {
continue;
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("?");
if let Some(ref callback) = on_item
&& !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
}
match rebuild_single_script(conn, data_dir, project_id, path) {

View File

@@ -67,7 +67,7 @@ pub struct ReindexReport {
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Repair a missing or previously deployed FTS schema and report whether its
/// derived content still needs to be rebuilt.
@@ -215,8 +215,10 @@ fn index_project(
for post in post_q::list_posts_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &post.title);
if let Some(callback) = on_item
&& !callback(*current, total, &post.title)
{
return Err(EngineError::Cancelled);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let translation_data = translations
@@ -255,8 +257,10 @@ fn index_project(
for media in media_q::list_media_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &media.original_name);
if let Some(callback) = on_item
&& !callback(*current, total, &media.original_name)
{
return Err(EngineError::Cancelled);
}
let translations = media_translation::list_media_translations_by_media(conn, &media.id)?;
let translation_data = translations
@@ -374,6 +378,29 @@ mod tests {
assert_eq!(results.len(), 1);
}
#[test]
fn cancellation_rolls_back_shared_index_rebuild() {
let (db, project_id) = setup();
let dir = tempfile::tempdir().unwrap();
engine::post::create_post(
db.conn(),
dir.path(),
&project_id,
"Cancel Me",
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
let result = rebuild_search_index(db.conn(), Some(Box::new(|_, _, _| false)));
assert!(matches!(result, Err(EngineError::Cancelled)));
}
#[test]
fn rebuild_indexes_published_post_and_translation_bodies_from_files() {
let db = Database::open_in_memory().unwrap();

View File

@@ -1,7 +1,10 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// Unique task identifier.
pub type TaskId = u64;
@@ -25,6 +28,7 @@ pub struct TaskSnapshot {
pub status: TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
pub cancellation_requested: bool,
pub created_at: Instant,
}
@@ -42,228 +46,340 @@ struct TaskEntry {
created_at: Instant,
finished_at: Option<Instant>,
last_progress_report: Option<Instant>,
worker_active: bool,
worker_started: bool,
}
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
pub struct TaskManager {
max_concurrent: usize,
next_id: Mutex<TaskId>,
tasks: Mutex<Vec<TaskEntry>>,
state: Mutex<TaskState>,
state_changed: Condvar,
async_changed: Notify,
}
struct TaskState {
next_id: TaskId,
tasks: HashMap<TaskId, TaskEntry>,
order: VecDeque<TaskId>,
pending: VecDeque<TaskId>,
worker_count: usize,
}
/// Capacity reservation held from asynchronous admission until the worker exits.
pub struct TaskWorker {
manager: Arc<TaskManager>,
task_id: TaskId,
}
impl Drop for TaskWorker {
fn drop(&mut self) {
self.manager.worker_exited(self.task_id);
}
}
impl TaskManager {
/// Create a new task manager with the given concurrency limit.
pub fn new(max_concurrent: usize) -> Self {
Self {
max_concurrent,
next_id: Mutex::new(1),
tasks: Mutex::new(Vec::new()),
max_concurrent: max_concurrent.max(1),
state: Mutex::new(TaskState {
next_id: 1,
tasks: HashMap::new(),
order: VecDeque::new(),
pending: VecDeque::new(),
worker_count: 0,
}),
state_changed: Condvar::new(),
async_changed: Notify::new(),
}
}
/// Submit a new task. Returns its unique identifier.
pub fn submit(&self, label: &str) -> TaskId {
let mut next = self.next_id.lock().unwrap();
let id = *next;
*next += 1;
self.submit_with_group(label, None, None)
}
let entry = TaskEntry {
fn submit_with_group(
&self,
label: &str,
group_id: Option<&str>,
group_name: Option<&str>,
) -> TaskId {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
let id = state.next_id;
state.next_id += 1;
state.tasks.insert(
id,
label: label.to_owned(),
group_id: None,
group_name: None,
status: TaskStatus::Pending,
cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None,
message: None,
created_at: Instant::now(),
finished_at: None,
last_progress_report: None,
};
let mut tasks = self.tasks.lock().unwrap();
tasks.push(entry);
// Auto-start if under capacity
let running = tasks
.iter()
.filter(|t| t.status == TaskStatus::Running)
.count();
if running < self.max_concurrent
&& let Some(t) = tasks
.iter_mut()
.find(|t| t.id == id && t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
}
TaskEntry {
id,
label: label.to_owned(),
group_id: group_id.map(str::to_owned),
group_name: group_name.map(str::to_owned),
status: TaskStatus::Pending,
cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None,
message: None,
created_at: Instant::now(),
finished_at: None,
last_progress_report: None,
worker_active: false,
worker_started: false,
},
);
state.order.push_back(id);
state.pending.push_back(id);
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
id
}
/// Submit a new task within a group. Returns its unique identifier.
pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
let id = self.submit(label);
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) {
entry.group_id = Some(group_id.to_owned());
entry.group_name = Some(group_name.to_owned());
}
id
self.submit_with_group(label, Some(group_id), Some(group_name))
}
/// Block a worker until its task may run. Returns false if cancelled.
/// Wait synchronously for admission. Prefer [`Self::admit`] before spawning workers.
pub fn wait_until_runnable(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap();
let mut state = self.state.lock().unwrap();
loop {
match tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status)
{
Some(TaskStatus::Running) => return true,
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
match state.tasks.get(&task_id).map(|task| &task.status) {
Some(TaskStatus::Running) => {
if let Some(task) = state.tasks.get_mut(&task_id) {
task.worker_started = true;
}
return true;
}
Some(TaskStatus::Pending) => state = self.state_changed.wait(state).unwrap(),
_ => return false,
}
}
}
/// Admit without occupying a blocking-pool thread while queued.
pub async fn admit(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
loop {
let notified = self.async_changed.notified();
{
let mut state = self.state.lock().unwrap();
match state.tasks.get_mut(&task_id) {
Some(task) if task.status == TaskStatus::Running => {
task.worker_started = true;
return Some(TaskWorker {
manager: Arc::clone(self),
task_id,
});
}
Some(task) if task.status == TaskStatus::Pending => {}
_ => return None,
}
}
notified.await;
}
}
/// Admit a non-Tokio worker while retaining capacity until its guard drops.
pub fn admit_blocking(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
if self.wait_until_runnable(task_id) {
Some(TaskWorker {
manager: Arc::clone(self),
task_id,
})
} else {
None
}
}
/// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
let mut state = self.state.lock().unwrap();
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
if matches!(entry.status, TaskStatus::Running) {
entry.status = TaskStatus::Completed;
entry.progress = Some(1.0);
entry.finished_at = Some(Instant::now());
}
let released = entry.worker_active;
entry.worker_active = false;
entry.worker_started = false;
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
&& matches!(entry.status, TaskStatus::Running)
{
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
entry.finished_at = Some(Instant::now());
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
let mut state = self.state.lock().unwrap();
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
if matches!(entry.status, TaskStatus::Running) {
if entry.cancel_flag.load(Ordering::Acquire) {
entry.status = TaskStatus::Cancelled;
} else {
entry.message = Some(error.clone());
entry.status = TaskStatus::Failed(error);
}
entry.finished_at = Some(Instant::now());
}
let released = entry.worker_active;
entry.worker_active = false;
entry.worker_started = false;
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Cancel a task by setting its cancel flag and status.
pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
/// Cancel queued work immediately, or request a cooperative stop from a worker.
pub fn cancel(&self, task_id: TaskId) -> bool {
let mut state = self.state.lock().unwrap();
let mut cancelled = false;
let mut released = false;
if let Some(entry) = state.tasks.get_mut(&task_id)
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
{
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
if !entry.worker_started {
if entry.worker_active {
entry.worker_active = false;
released = true;
}
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(Instant::now());
}
cancelled = true;
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
cancelled
}
/// Cancel every active task in a group and release their workers.
pub fn cancel_group(&self, group_id: &str) {
let mut tasks = self.tasks.lock().unwrap();
let mut state = self.state.lock().unwrap();
let now = Instant::now();
for entry in tasks.iter_mut().filter(|task| {
task.group_id.as_deref() == Some(group_id)
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
}) {
let group_ids = state
.tasks
.values()
.filter(|task| {
task.group_id.as_deref() == Some(group_id)
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
})
.map(|task| task.id)
.collect::<Vec<_>>();
let mut released = 0;
for id in group_ids {
let entry = state.tasks.get_mut(&id).unwrap();
entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(now);
if !entry.worker_started {
if entry.worker_active {
entry.worker_active = false;
released += 1;
}
entry.status = TaskStatus::Cancelled;
entry.finished_at = Some(now);
}
}
Self::promote_next(&mut tasks, self.max_concurrent);
self.state_changed.notify_all();
state.worker_count = state.worker_count.saturating_sub(released);
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
/// Return the group containing a task, if any.
pub fn group_id(&self, task_id: TaskId) -> Option<String> {
self.tasks
.lock()
.unwrap()
.iter()
.find(|task| task.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.and_then(|task| task.group_id.clone())
}
/// Check whether a task has been cancelled.
pub fn is_cancelled(&self, task_id: TaskId) -> bool {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.map(|t| t.cancel_flag.load(Ordering::Acquire))
.unwrap_or(false)
}
/// Shared cancellation flag for a worker owned by this task.
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
self.tasks
.lock()
.unwrap()
.iter()
.find(|task| task.id == task_id)
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.get(&task_id)
.map(|task| Arc::clone(&task.cancel_flag))
}
/// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.status.clone())
self.get(task_id).map(|task| task.status)
}
/// Count tasks that are still queued.
pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Pending)
.count()
}
/// Count tasks that are currently running.
pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Running)
.count()
}
/// Remove finished tasks older than the configured retention period.
pub fn evict_expired(&self) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|task| {
task.finished_at
.is_none_or(|finished_at| finished_at > cutoff)
});
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
}
/// Remove every finished task while preserving running and queued work.
pub fn clear_completed(&self) {
self.tasks
.lock()
.unwrap()
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
let mut state = self.state.lock().unwrap();
Self::remove_where(&mut state, |task| task.status == TaskStatus::Completed);
}
/// Remove all terminal task results while preserving active work.
pub fn clear_finished(&self) {
let mut state = self.state.lock().unwrap();
Self::remove_where(&mut state, |task| {
task.finished_at.is_some() && !task.worker_active
});
}
/// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.tasks.get_mut(&task_id)
&& entry.status == TaskStatus::Running
{
let now = Instant::now();
@@ -271,7 +387,7 @@ impl TaskManager {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true,
};
if should_report {
if should_report || progress.is_some_and(|value| value >= 1.0) {
entry.progress = progress;
entry.message = message;
entry.last_progress_report = Some(now);
@@ -281,58 +397,157 @@ impl TaskManager {
/// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap();
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.progress)
self.get(task_id).and_then(|task| task.progress)
}
/// Return a snapshot of all tasks for UI display.
/// Return active tasks plus ten recent finished tasks for status surfaces.
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap();
let mut snapshots = tasks
.iter()
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
let mut active = state
.tasks
.values()
.filter(|task| task.finished_at.is_none())
.chain(
tasks
.iter()
.rev()
.filter(|task| task.finished_at.is_some())
.take(RECENT_FINISHED_LIMIT),
)
.map(|task| TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
created_at: task.created_at,
})
.collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.created_at);
snapshots
active.sort_by_key(|task| (task.status != TaskStatus::Running, task.created_at));
let active_groups = active
.iter()
.filter_map(|task| task.group_id.as_deref())
.collect::<HashSet<_>>();
let mut finished = state
.tasks
.values()
.filter(|task| task.finished_at.is_some())
.collect::<Vec<_>>();
finished.sort_by_key(|task| std::cmp::Reverse(task.finished_at));
let recent_ids = finished
.iter()
.take(RECENT_FINISHED_LIMIT)
.map(|task| task.id)
.collect::<HashSet<_>>();
active
.into_iter()
.chain(finished.into_iter().filter(|task| {
recent_ids.contains(&task.id)
|| task
.group_id
.as_deref()
.is_some_and(|group| active_groups.contains(group))
}))
.map(Self::snapshot)
.collect()
}
/// Promote the next queued task to running if capacity allows.
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
while tasks
/// Return one retained task by id.
pub fn get(&self, task_id: TaskId) -> Option<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state.tasks.get(&task_id).map(Self::snapshot)
}
/// Return every retained task, newest first.
pub fn all(&self) -> Vec<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.order
.iter()
.rev()
.filter_map(|id| state.tasks.get(id).map(Self::snapshot))
.collect()
}
/// Return running tasks in start order.
pub fn running(&self) -> Vec<TaskSnapshot> {
let mut state = self.state.lock().unwrap();
Self::prune_expired(&mut state);
state
.order
.iter()
.filter_map(|id| state.tasks.get(id))
.filter(|task| task.status == TaskStatus::Running)
.count()
< max_concurrent
{
let Some(task) = tasks
.iter_mut()
.find(|task| task.status == TaskStatus::Pending)
else {
.map(Self::snapshot)
.collect()
}
fn worker_exited(&self, task_id: TaskId) {
let mut state = self.state.lock().unwrap();
let released = if let Some(task) = state.tasks.get_mut(&task_id) {
if task.cancel_flag.load(Ordering::Acquire) {
task.worker_started = false;
drop(state);
self.notify_changed();
return;
}
let released = task.worker_active;
task.worker_active = false;
task.worker_started = false;
released
} else {
false
};
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
}
fn promote_next(state: &mut TaskState, max_concurrent: usize) {
while state.worker_count < max_concurrent {
let Some(id) = state.pending.pop_front() else {
break;
};
let Some(task) = state.tasks.get_mut(&id) else {
continue;
};
if task.status != TaskStatus::Pending {
continue;
}
task.status = TaskStatus::Running;
task.worker_active = true;
state.worker_count += 1;
}
}
fn snapshot(task: &TaskEntry) -> TaskSnapshot {
TaskSnapshot {
id: task.id,
label: task.label.clone(),
group_id: task.group_id.clone(),
group_name: task.group_name.clone(),
status: task.status.clone(),
progress: task.progress,
message: task.message.clone(),
cancellation_requested: task.cancel_flag.load(Ordering::Acquire),
created_at: task.created_at,
}
}
fn prune_expired(state: &mut TaskState) {
let cutoff = Instant::now() - FINISHED_TASK_TTL;
Self::remove_where(state, |task| {
task.finished_at
.is_some_and(|finished_at| finished_at <= cutoff)
&& !task.worker_active
});
}
fn remove_where(state: &mut TaskState, predicate: impl Fn(&TaskEntry) -> bool) {
let removed = state
.tasks
.values()
.filter(|task| predicate(task))
.map(|task| task.id)
.collect::<HashSet<_>>();
state.tasks.retain(|id, _| !removed.contains(id));
state.order.retain(|id| !removed.contains(id));
state.pending.retain(|id| !removed.contains(id));
}
fn notify_changed(&self) {
self.state_changed.notify_all();
self.async_changed.notify_waiters();
}
}
impl Default for TaskManager {
@@ -399,7 +614,7 @@ mod tests {
}
#[test]
fn cancel_sets_flag() {
fn cancelling_unstarted_task_settles_immediately() {
let mgr = TaskManager::default();
let id = mgr.submit("upload");
// Task is auto-started (Running)
@@ -409,6 +624,35 @@ mod tests {
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
}
#[tokio::test]
async fn cancelling_started_task_stays_running_until_worker_stops() {
let mgr = Arc::new(TaskManager::new(1));
let id = mgr.submit("upload");
let worker = mgr.admit(id).await.unwrap();
mgr.cancel(id);
let snapshot = mgr.get(id).unwrap();
assert_eq!(snapshot.status, TaskStatus::Running);
assert!(snapshot.cancellation_requested);
drop(worker);
mgr.fail(id, "operation cancelled".into());
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
}
#[tokio::test]
async fn completed_work_wins_a_late_cancellation_request() {
let mgr = Arc::new(TaskManager::new(1));
let id = mgr.submit("atomic update");
let worker = mgr.admit(id).await.unwrap();
mgr.cancel(id);
drop(worker);
mgr.complete(id);
assert_eq!(mgr.status(id), Some(TaskStatus::Completed));
}
#[test]
fn complete_and_fail() {
let mgr = TaskManager::default();
@@ -444,8 +688,12 @@ mod tests {
// After b fails: d promoted to running
{
let mut tasks = mgr.tasks.lock().unwrap();
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
let mut state = mgr.state.lock().unwrap();
for task in state
.tasks
.values_mut()
.filter(|task| task.finished_at.is_some())
{
task.finished_at =
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
}
@@ -469,6 +717,34 @@ mod tests {
assert_eq!(mgr.snapshots().len(), 10);
}
#[test]
fn snapshots_retain_every_active_task_and_complete_active_groups() {
let mgr = TaskManager::new(20);
let finished_group_member = mgr.submit_grouped("finished", "group", "Group");
mgr.complete(finished_group_member);
for index in 0..12 {
mgr.submit_grouped(&format!("active {index}"), "group", "Group");
}
for index in 0..12 {
let id = mgr.submit(&format!("history {index}"));
mgr.complete(id);
}
let snapshots = mgr.snapshots();
assert_eq!(
snapshots
.iter()
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.count(),
12
);
assert!(
snapshots
.iter()
.any(|task| task.id == finished_group_member)
);
}
#[test]
fn clear_completed_preserves_active_tasks() {
let mgr = TaskManager::new(2);
@@ -555,4 +831,70 @@ mod tests {
assert_eq!(mgr.progress(id), Some(0.5));
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
}
#[tokio::test]
async fn cancellation_holds_capacity_until_worker_exits() {
let mgr = Arc::new(TaskManager::new(1));
let running = mgr.submit("running");
let queued = mgr.submit("queued");
let worker = mgr.admit(running).await.unwrap();
mgr.cancel(running);
assert_eq!(mgr.status(running), Some(TaskStatus::Running));
assert!(mgr.get(running).unwrap().cancellation_requested);
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
drop(worker);
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
mgr.fail(running, "operation cancelled".into());
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
}
#[tokio::test]
async fn panicking_worker_guard_releases_capacity() {
let mgr = Arc::new(TaskManager::new(1));
let panicking = mgr.submit("panicking");
let queued = mgr.submit("queued");
let worker = mgr.admit(panicking).await.unwrap();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _worker = worker;
panic!("boom");
}));
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
}
#[test]
fn terminal_progress_bypasses_throttle() {
let mgr = TaskManager::new(1);
let id = mgr.submit("work");
mgr.report_progress(id, Some(0.5), Some("working".into()));
mgr.report_progress(id, Some(1.0), Some("done".into()));
let task = mgr
.snapshots()
.into_iter()
.find(|task| task.id == id)
.unwrap();
assert_eq!(task.progress, Some(1.0));
assert_eq!(task.message.as_deref(), Some("done"));
}
#[test]
fn clear_completed_keeps_other_terminal_results() {
let mgr = TaskManager::new(3);
let completed = mgr.submit("completed");
let failed = mgr.submit("failed");
let cancelled = mgr.submit("cancelled");
mgr.complete(completed);
mgr.fail(failed, "failed".into());
mgr.cancel(cancelled);
mgr.clear_completed();
assert_eq!(mgr.status(completed), None);
assert!(matches!(mgr.status(failed), Some(TaskStatus::Failed(_))));
assert_eq!(mgr.status(cancelled), Some(TaskStatus::Cancelled));
}
}

View File

@@ -17,6 +17,8 @@ pub struct TemplateRebuildReport {
pub errors: Vec<String>,
}
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild templates from the filesystem into the database.
///
/// Walks the `templates/` directory for `*.liquid` files, parses each via
@@ -26,6 +28,15 @@ pub fn rebuild_templates_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<TemplateRebuildReport> {
rebuild_templates_from_filesystem_with_progress(conn, data_dir, project_id, None)
}
pub fn rebuild_templates_from_filesystem_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<TemplateRebuildReport> {
let mut report = TemplateRebuildReport::default();
let templates_dir = data_dir.join("templates");
@@ -34,17 +45,23 @@ pub fn rebuild_templates_from_filesystem(
return Ok(report);
}
for entry in WalkDir::new(&templates_dir)
let files = WalkDir::new(&templates_dir)
.into_iter()
.filter_map(|e| e.ok())
{
.filter(|entry| entry.path().is_file())
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("liquid"))
.collect::<Vec<_>>();
for (index, entry) in files.iter().enumerate() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("liquid") {
continue;
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("?");
if let Some(ref callback) = on_item
&& !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
}
match rebuild_single_template(conn, data_dir, project_id, path) {

View File

@@ -6,8 +6,8 @@ use crate::db::DbConnection as Connection;
use walkdir::WalkDir;
use crate::db::queries;
use crate::engine::EngineResult;
use crate::engine::generation::has_published_snapshot;
use crate::engine::{EngineError, EngineResult};
use crate::model::Post;
use crate::render::{build_canonical_post_path, build_site_route_manifest};
@@ -25,11 +25,27 @@ pub fn validate_site(
data_dir: &Path,
project_id: &str,
) -> EngineResult<SiteValidationReport> {
validate_site_with_progress(conn, data_dir, project_id, |_, _| true)
}
pub fn validate_site_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
mut on_progress: impl FnMut(usize, usize) -> bool,
) -> EngineResult<SiteValidationReport> {
const PHASES: usize = 4;
if !on_progress(0, PHASES) {
return Err(EngineError::Cancelled);
}
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(conn, project_id)?;
let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts)
.map_err(|error| crate::engine::EngineError::Parse(error.to_string()))?;
if !on_progress(1, PHASES) {
return Err(EngineError::Cancelled);
}
crate::engine::generation::refresh_validation_sitemap(
conn,
&output_dir,
@@ -43,6 +59,9 @@ pub fn validate_site(
.into_iter()
.map(|page| page.relative_path)
.collect::<HashSet<_>>();
if !on_progress(2, PHASES) {
return Err(EngineError::Cancelled);
}
let mut actual = HashSet::new();
let mut zero_byte = HashSet::new();
@@ -67,6 +86,9 @@ pub fn validate_site(
.into_iter()
.map(|file| (file.relative_path, file.updated_at))
.collect::<HashMap<_, _>>();
if !on_progress(3, PHASES) {
return Err(EngineError::Cancelled);
}
let mut stale_pages = stale_post_paths(
data_dir,
&output_dir,
@@ -83,6 +105,10 @@ pub fn validate_site(
stale_pages.sort();
stale_pages.dedup();
if !on_progress(PHASES, PHASES) {
return Err(EngineError::Cancelled);
}
Ok(SiteValidationReport {
missing_pages,
extra_pages,

View File

@@ -5,7 +5,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection;
use crate::db::queries::{post as post_q, post_translation};
use crate::engine::EngineResult;
use crate::engine::{EngineError, EngineResult};
use crate::model::PostStatus;
/// Normalize a language code for comparison (lowercase, strip region).
@@ -53,7 +53,7 @@ pub struct TranslationValidationReport {
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Validate all translations with optional per-item progress.
pub fn validate_translations_with_progress(
@@ -75,8 +75,10 @@ pub fn validate_translations_with_progress(
let post_count = posts.len();
for (i, post) in posts.iter().enumerate() {
if let Some(ref cb) = on_item {
cb(i + 1, post_count, &post.title);
if let Some(ref cb) = on_item
&& !cb(i + 1, post_count, &post.title)
{
return Err(EngineError::Cancelled);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
@@ -170,8 +172,10 @@ pub fn validate_translations_with_progress(
let path = entry.path();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Some(ref cb) = on_item {
cb(i + 1, fs_total, stem);
if let Some(ref cb) = on_item
&& !cb(i + 1, fs_total, stem)
{
return Err(EngineError::Cancelled);
}
checked_fs_files += 1;

View File

@@ -21,6 +21,7 @@ pub use routes::{
render_starter_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map,
};
pub(crate) use site::count_site_render_pages_from_context;
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context,

View File

@@ -391,12 +391,7 @@ pub fn prepare_site_render_context(
let linked_media_by_post_id =
build_linked_media_by_post_id(&posts, &linked_media_by_source_post);
let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
let menu_items = build_menu_items(
data_dir,
&language,
&main_language,
&category_settings,
)?;
let menu_items = build_menu_items(data_dir, &language, &main_language, &category_settings)?;
let canonical_post_path_by_slug =
canonical_post_path_by_slug(&posts, &language, &main_language);
let taxonomy = build_taxonomy_context(&posts, &tags);
@@ -590,6 +585,39 @@ pub fn build_site_render_artifacts_from_context(
Ok(artifacts)
}
pub(crate) fn count_site_render_pages_from_context(
context: &SiteRenderContext,
section: GenerationSection,
requested_paths: Option<&HashSet<String>>,
) -> usize {
context
.languages
.iter()
.map(|language_context| {
let expanded = requested_paths.map(|requested| {
expand_requested_aggregate_paths(
requested,
&language_context.posts,
&language_context.routes,
&language_context.language,
&context.metadata,
)
});
language_context
.routes
.iter()
.filter(|route| {
classify_generated_path(&route.relative_path, &context.metadata)
== Some(section)
&& expanded
.as_ref()
.is_none_or(|requested| requested.contains(&route.relative_path))
})
.count()
})
.sum()
}
pub fn build_preview_response(
conn: &Connection,
data_dir: &Path,

View File

@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine as _;
use chrono::{SecondsFormat, TimeZone, Utc};
@@ -1215,33 +1216,39 @@ impl CoreHost {
"get" => {
let id = task_id_arg(args, 0)?;
manager
.snapshots()
.into_iter()
.find(|task| task.id == id)
.get(id)
.map(public_task)
.transpose()
.map(|value| value.unwrap_or(Value::Null))
}
"get_all" => public_tasks(manager.snapshots()),
"get_running" => public_tasks(
manager
.snapshots()
.into_iter()
.filter(|task| task.status == TaskStatus::Running)
.collect(),
),
"get_all" => public_tasks(manager.all()),
"get_running" => public_tasks(manager.running()),
"status_snapshot" => {
let tasks = manager.snapshots();
let active = tasks
.iter()
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.collect::<Vec<_>>();
let running_task_message = active.first().map(|task| {
if task.status == TaskStatus::Pending {
format!("Queued: {}", task.label)
} else if let Some(message) =
task.message.as_deref().filter(|value| !value.is_empty())
{
format!("{}: {message}", task.label)
} else {
task.label.clone()
}
});
Ok(json!({
"active_count": tasks.iter().filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)).count(),
"active_count": active.len(),
"running_count": manager.running_count(), "pending_count": manager.pending_count(),
"running_task_message": running_task_message,
"running_task_overflow": active.len().saturating_sub(1),
"tasks": public_tasks(tasks)?,
}))
}
"cancel" => {
manager.cancel(task_id_arg(args, 0)?);
Ok(Value::Bool(true))
}
"cancel" => Ok(Value::Bool(manager.cancel(task_id_arg(args, 0)?))),
"clear_completed" => {
manager.clear_completed();
Ok(Value::Bool(true))
@@ -1355,10 +1362,17 @@ impl CoreHost {
}
let manager = self.task_manager.clone();
let task_id = self.task_id;
engine::publishing::upload_site(
let cancel_flag = manager
.as_ref()
.zip(task_id)
.and_then(|(manager, task_id)| manager.cancellation_flag(task_id))
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
let progress_cancel_flag = Arc::clone(&cancel_flag);
engine::publishing::upload_site_cancellable(
&self.data_dir,
&self.private_cache_dir,
&preferences,
cancel_flag.as_ref(),
move |current, total, _| {
if let (Some(manager), Some(task_id)) = (&manager, task_id) {
manager.report_progress(
@@ -1367,6 +1381,7 @@ impl CoreHost {
Some("uploading site".into()),
);
}
!progress_cancel_flag.load(Ordering::Acquire)
},
)?;
match (&self.task_manager, self.task_id) {
@@ -1659,6 +1674,8 @@ fn public_tag(value: Tag) -> HostResult<Value> {
}
fn public_task(value: TaskSnapshot) -> HostResult<Value> {
let cancellable = matches!(value.status, TaskStatus::Pending | TaskStatus::Running)
&& !value.cancellation_requested;
let (status, error) = match value.status {
TaskStatus::Pending => ("pending", None),
TaskStatus::Running => ("running", None),
@@ -1668,6 +1685,9 @@ fn public_task(value: TaskSnapshot) -> HostResult<Value> {
};
Ok(
json!({"id":value.id.to_string(),"name":value.label,"status":status,
"group_id":value.group_id,"group_name":value.group_name,
"cancellable":cancellable,
"cancellation_requested":value.cancellation_requested,
"progress":value.progress,"message":value.message.or(error)}),
)
}

View File

@@ -395,6 +395,10 @@ fn field_description(field: &str) -> &'static str {
"updated" => "Whether the Git network command completed successfully.",
"progress" => "Completion value reported by the task.",
"message" => "Latest user-facing task message.",
"cancellable" => "Whether a cancellation request can still be submitted.",
"cancellation_requested" => "Whether cooperative cancellation is in progress.",
"group_id" => "Identifier shared by tasks in one workflow.",
"group_name" => "Human-readable workflow name.",
"created_at" => "Creation timestamp.",
"updated_at" => "Last-update timestamp.",
"language" | "main_language" => "BCP 47 language code.",

View File

@@ -408,6 +408,8 @@ impl ProtocolError {
}
fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
let cancellable = matches!(snapshot.status, TaskStatus::Pending | TaskStatus::Running)
&& !snapshot.cancellation_requested;
let (status, failure) = match snapshot.status {
TaskStatus::Pending => ("pending", None),
TaskStatus::Running => ("running", None),
@@ -418,6 +420,10 @@ fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
RemoteTask {
id: snapshot.id,
label: snapshot.label,
group_id: snapshot.group_id,
group_name: snapshot.group_name,
cancellation_requested: snapshot.cancellation_requested,
cancellable,
status: status.to_owned(),
progress: snapshot.progress,
message: snapshot.message.or(failure),
@@ -610,7 +616,11 @@ mod tests {
let fixture = Fixture::new();
let mut session = fixture.session();
let _ = session.pending();
let task = fixture.host.tasks().submit("Generate site");
let task =
fixture
.host
.tasks()
.submit_grouped("Generate site", "generation-1", "Generate Site");
fixture
.host
.tasks()
@@ -618,7 +628,11 @@ mod tests {
let update = session.pending();
assert!(matches!(
update.as_slice(),
[ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5)
[ServerMessage::Tasks { tasks, .. }]
if tasks[0].progress == Some(0.5)
&& tasks[0].group_id.as_deref() == Some("generation-1")
&& tasks[0].group_name.as_deref() == Some("Generate Site")
&& tasks[0].cancellable
));
assert!(
session

View File

@@ -57,7 +57,12 @@ pub enum ServerMessage {
pub struct RemoteTask {
pub id: u64,
pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: String,
pub progress: Option<f32>,
pub message: Option<String>,
#[serde(default)]
pub cancellation_requested: bool,
pub cancellable: bool,
}

View File

@@ -2,12 +2,14 @@ use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use bds_core::db::Database;
use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager};
use bds_core::engine::{self, domain_events};
use bds_core::i18n::{UiLocale, normalize_language};
use bds_core::model::metadata::ProjectMetadata;
@@ -600,7 +602,7 @@ impl TuiApp {
fn queue_task(
&mut self,
label: &str,
work: impl FnOnce() -> Result<BackgroundResult> + Send + 'static,
work: impl FnOnce(Arc<TaskManager>, TaskId) -> Result<BackgroundResult> + Send + 'static,
) {
let tasks = self.host.tasks();
let task_id = tasks.submit(label);
@@ -608,22 +610,32 @@ impl TuiApp {
self.started_task_ids.insert(task_id);
self.status = self.tr_with("tui.taskRunning", &[("label", label)]);
std::thread::spawn(move || {
if !tasks.wait_until_runnable(task_id) {
let Some(worker) = tasks.admit_blocking(task_id) else {
return;
}
match work() {
};
let work_tasks = Arc::clone(&tasks);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
work(work_tasks, task_id)
}))
.map_err(|_| anyhow!("background task panicked"))
.and_then(|result| result);
drop(worker);
match result {
Ok(result) => {
tasks.complete(task_id);
let _ = sender.send(result);
}
Err(error) => {
let cancelled = tasks.is_cancelled(task_id);
let message = error.to_string();
tasks.fail(task_id, message.clone());
let _ = sender.send(BackgroundResult {
status: message,
panel: None,
reload: false,
});
if !cancelled {
let _ = sender.send(BackgroundResult {
status: message,
panel: None,
reload: false,
});
}
}
}
});
@@ -636,15 +648,19 @@ impl TuiApp {
}
match task.status {
engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => {
self.status = match task.progress {
Some(progress) => self.tr_with(
"tui.taskProgress",
&[
("label", &task.label),
("percent", &format!("{:.0}", progress * 100.0)),
],
),
None => task.label,
self.status = if task.cancellation_requested {
self.tr_with("tui.taskCancelling", &[("label", &task.label)])
} else {
match task.progress {
Some(progress) => self.tr_with(
"tui.taskProgress",
&[
("label", &task.label),
("percent", &format!("{:.0}", progress * 100.0)),
],
),
None => task.label,
}
};
}
engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => {
@@ -1550,7 +1566,10 @@ impl TuiApp {
let message = prompt.value;
let locale = self.locale;
let label = self.tr("tui.taskGitCommit");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
if tasks.is_cancelled(task_id) {
return Err(anyhow!("operation cancelled"));
}
let output = engine::git::GitEngine::new(&data_dir)
.commit_all(&message)?
.output;
@@ -2730,10 +2749,12 @@ impl TuiApp {
let data_dir = self.data_dir()?.to_owned();
let locale = self.locale;
let label = self.tr("tui.taskGitPull");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let mut output = Vec::new();
let result = engine::git::GitEngine::new(&data_dir)
.pull(|| false, |line| output.push(line.text))?;
let result = engine::git::GitEngine::new(&data_dir).pull(
|| tasks.is_cancelled(task_id),
|line| output.push(line.text),
)?;
output.push(result.output);
Ok(BackgroundResult {
status: bds_core::i18n::translate_with(
@@ -2759,10 +2780,12 @@ impl TuiApp {
let data_dir = self.data_dir()?.to_owned();
let locale = self.locale;
let label = self.tr("tui.taskGitPush");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let mut output = Vec::new();
let result = engine::git::GitEngine::new(&data_dir)
.push(|| false, |line| output.push(line.text))?;
let result = engine::git::GitEngine::new(&data_dir).push(
|| tasks.is_cancelled(task_id),
|line| output.push(line.text),
)?;
output.push(result.output);
Ok(BackgroundResult {
status: bds_core::i18n::translate_with(
@@ -2807,13 +2830,26 @@ impl TuiApp {
"metadata-diff" => {
let locale = self.locale;
let label = self.tr("tui.commandMetadataDiff");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let db = Database::open(&database_path)?;
let report = engine::metadata_diff::compute_metadata_diff_with_progress(
db.conn(),
&data_dir,
&project_id,
|current, total| {
tasks.report_progress(
task_id,
Some(current as f32 / total.max(1) as f32),
Some(bds_core::i18n::translate(locale, "tui.commandMetadataDiff")),
);
!tasks.is_cancelled(task_id)
},
)?;
Ok(BackgroundResult {
status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"),
panel: Some(Panel::Report {
title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"),
body: metadata_report_body(db.conn(), &data_dir, &project_id, locale)?,
body: metadata_report_body_from_report(&report, locale),
action: ReportAction::MetadataDiff,
}),
reload: false,
@@ -2823,13 +2859,26 @@ impl TuiApp {
"validate-site" => {
let locale = self.locale;
let label = self.tr("tui.commandValidateSite");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let db = Database::open(&database_path)?;
let report = engine::validate_site::validate_site_with_progress(
db.conn(),
&data_dir,
&project_id,
|current, total| {
tasks.report_progress(
task_id,
Some(current as f32 / total.max(1) as f32),
Some(bds_core::i18n::translate(locale, "tui.commandValidateSite")),
);
!tasks.is_cancelled(task_id)
},
)?;
Ok(BackgroundResult {
status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"),
panel: Some(Panel::Report {
title: bds_core::i18n::translate(locale, "menu.item.validateSite"),
body: site_validation_body(db.conn(), &data_dir, &project_id, locale)?,
body: site_validation_body_from_report(&report, locale),
action: ReportAction::SiteValidation,
}),
reload: false,
@@ -2839,18 +2888,86 @@ impl TuiApp {
"force-render" => {
let locale = self.locale;
let label = self.tr("tui.commandForceRender");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let db = Database::open(&database_path)?;
let metadata = engine::meta::read_project_json(&data_dir)?;
let posts = published_sources(db.conn(), &data_dir, &project_id)?;
let report = engine::generation::generate_starter_site_forced(
let posts =
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)?;
let total_posts = posts.len();
let mut sources = Vec::new();
for (index, post) in posts.into_iter().enumerate() {
tasks.report_progress(
task_id,
Some((index + 1) as f32 / total_posts.max(1) as f32 * 0.1),
Some(post.title.clone()),
);
if tasks.is_cancelled(task_id) {
return Err(anyhow!("operation cancelled"));
}
if let Some(source) =
engine::generation::load_published_post_source(&data_dir, post)?
{
sources.push(source);
}
}
let prepared = engine::generation::prepare_site_generation(
db.conn(),
&data_dir.join("html"),
&data_dir,
&project_id,
&metadata,
&posts,
metadata.main_language.as_deref().unwrap_or("en"),
&sources,
)?;
let output_dir = data_dir.join("html");
let mut report = engine::generation::GenerationReport::default();
for (index, section) in engine::generation::GenerationSection::ALL
.into_iter()
.enumerate()
{
let progress_tasks = Arc::clone(&tasks);
let cancel_tasks = Arc::clone(&tasks);
report.append(
engine::generation::render_prepared_site_section_with_progress(
db.conn(),
&output_dir,
&project_id,
&prepared,
section,
true,
&|_| {},
move |current, total, path| {
progress_tasks.report_progress(
task_id,
Some(
0.1 + (index as f32
+ current as f32 / total.max(1) as f32)
/ 6.0
* 0.9,
),
Some(path.to_string()),
);
},
move || cancel_tasks.is_cancelled(task_id),
)?,
);
}
let progress_tasks = Arc::clone(&tasks);
let cancel_tasks = Arc::clone(&tasks);
report.append(
engine::generation::build_site_search_index_forced_with_progress(
db.conn(),
&output_dir,
&project_id,
&metadata,
move |current, total, path| {
progress_tasks.report_progress(
task_id,
Some(0.85 + current as f32 / total.max(1) as f32 * 0.15),
Some(path.to_string()),
);
},
move || cancel_tasks.is_cancelled(task_id),
)?,
);
Ok(BackgroundResult {
status: bds_core::i18n::translate_with(
locale,
@@ -2865,12 +2982,21 @@ impl TuiApp {
"rebuild-database" => {
let locale = self.locale;
let label = self.tr("tui.commandRebuildDatabase");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let db = Database::open(&database_path)?;
let report = engine::rebuild::rebuild_from_filesystem(
let progress_tasks = Arc::clone(&tasks);
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(),
&data_dir,
&project_id,
Some(Arc::new(move |progress, event| {
progress_tasks.report_progress(
task_id,
Some(progress),
Some(event.localized(locale)),
);
!progress_tasks.is_cancelled(task_id)
})),
)?;
let count = report.posts_created
+ report.posts_updated
@@ -2894,9 +3020,21 @@ impl TuiApp {
"reindex-search" => {
let locale = self.locale;
let label = self.tr("tui.commandReindexSearch");
self.queue_task(&label, move || {
self.queue_task(&label, move |tasks, task_id| {
let db = Database::open(&database_path)?;
let report = engine::search::reindex_project(db.conn(), &project_id, None)?;
let progress_tasks = Arc::clone(&tasks);
let report = engine::search::reindex_project(
db.conn(),
&project_id,
Some(Box::new(move |current, total, name| {
progress_tasks.report_progress(
task_id,
Some(current as f32 / total.max(1) as f32),
Some(name.to_string()),
);
!progress_tasks.is_cancelled(task_id)
})),
)?;
Ok(BackgroundResult {
status: bds_core::i18n::translate_with(
locale,
@@ -3027,6 +3165,13 @@ fn metadata_report_body(
locale: UiLocale,
) -> Result<String> {
let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?;
Ok(metadata_report_body_from_report(&report, locale))
}
fn metadata_report_body_from_report(
report: &engine::metadata_diff::DiffReport,
locale: UiLocale,
) -> String {
let mut lines = report
.diffs
.iter()
@@ -3058,7 +3203,7 @@ fn metadata_report_body(
"tui.noMetadataDifferences",
));
}
Ok(lines.join("\n"))
lines.join("\n")
}
fn site_validation_body(
@@ -3068,7 +3213,14 @@ fn site_validation_body(
locale: UiLocale,
) -> Result<String> {
let report = engine::validate_site::validate_site(conn, data_dir, project_id)?;
Ok(bds_core::i18n::translate_with(
Ok(site_validation_body_from_report(&report, locale))
}
fn site_validation_body_from_report(
report: &engine::validate_site::SiteValidationReport,
locale: UiLocale,
) -> String {
bds_core::i18n::translate_with(
locale,
"tui.siteValidationReport",
&[
@@ -3076,7 +3228,7 @@ fn site_validation_body(
("extra", &report.extra_pages.join("\n")),
("stale", &report.stale_pages.join("\n")),
],
))
)
}
impl TuiApp {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ impl BdsApp {
let on_progress: engine::rebuild::ProgressFn =
Arc::new(move |pct, event| {
tm.report_progress(tid, Some(pct), Some(event.localized(locale)));
!tm.is_cancelled(tid)
});
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(),
@@ -42,14 +43,28 @@ impl BdsApp {
"engine.calendarStarted",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts")));
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
.map_err(|e| e.to_string())?;
tm.report_progress(
tid,
Some(0.90),
Some(t(locale, "engine.writingCalendar")),
);
engine::calendar::regenerate_calendar_with_progress(
db.conn(),
&data_dir,
&project_id,
|current, total, name| {
tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
)),
);
!tm.is_cancelled(tid)
},
)
.map_err(|e| e.to_string())?;
Ok("done".to_string())
},
)
@@ -59,24 +74,39 @@ impl BdsApp {
TabType::TranslationValidation,
"tabBar.translationValidation",
);
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
if self.active_project.is_none() || self.data_dir.is_none() {
return Task::none();
};
}
self.translation_validation_state.is_running = true;
self.translation_validation_state.error_message = None;
let db_path = self.db_path.clone();
let project_id = project.id.clone();
let data_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?;
let main_lang = meta.main_language.as_deref().unwrap_or("en");
let blog_langs = meta.blog_languages.clone();
let on_item: engine::validate_translations::ItemProgressFn =
Box::new(move |_current, _total, _name| {});
let locale = self.ui_locale;
self.spawn_result_task(
"menu.item.validateTranslations",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?;
let main_lang = meta.main_language.as_deref().unwrap_or("en");
let blog_langs = meta.blog_languages.clone();
let progress_tm = Arc::clone(&tm);
let on_item: engine::validate_translations::ItemProgressFn =
Box::new(move |current, total, name| {
progress_tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
)),
);
!progress_tm.is_cancelled(tid)
});
let report =
engine::validate_translations::validate_translations_with_progress(
db.conn(),
&data_dir,
@@ -85,16 +115,31 @@ impl BdsApp {
main_lang,
Some(on_item),
)
.map_err(|e| e.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
.map_err(|e| e.to_string())?;
tm.report_progress(
tid,
Some(1.0),
Some(tw(
locale,
"engine.validateTranslationsComplete",
&[
("dbIssues", &report.db_issues.len().to_string()),
("fsIssues", &report.fs_issues.len().to_string()),
],
)),
);
Ok(report)
},
Message::TranslationValidationLoaded,
|task_id, result| Message::TranslationValidationLoaded { task_id, result },
)
}
Message::TranslationValidationLoaded(result) => {
Message::TranslationValidationLoaded { task_id, result } => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
self.translation_validation_state.is_running = false;
if cancelled {
return Task::none();
}
match result {
Ok(report) => {
self.translation_validation_state.report = Some(report);
@@ -149,8 +194,13 @@ impl BdsApp {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
self.start_metadata_diff()
}
Message::MetadataDiffLoaded(result) => {
Message::MetadataDiffLoaded { task_id, result } => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
self.metadata_diff_state.is_running = false;
if cancelled {
return Task::none();
}
match result {
Ok(report) => {
self.metadata_diff_state.report = Some(report);
@@ -170,36 +220,38 @@ impl BdsApp {
else {
return Task::none();
};
let (Some(project), Some(data_dir)) =
(self.active_project.as_ref(), self.data_dir.as_ref())
else {
if self.active_project.is_none() || self.data_dir.is_none() {
return Task::none();
};
}
self.metadata_diff_state.is_repairing = true;
let db_path = self.db_path.clone();
let project_id = project.id.clone();
let data_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::metadata_diff::repair_metadata_diff_item(
db.conn(),
&data_dir,
&project_id,
direction,
&item,
)
.map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
let locale = self.ui_locale;
self.spawn_result_task(
"metadataDiff.repair",
move |db_path, project_id, data_dir, tm, tid| {
tm.report_progress(tid, Some(0.0), Some(t(locale, "metadataDiff.repair")));
if tm.is_cancelled(tid) {
return Err("operation cancelled".into());
}
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::metadata_diff::repair_metadata_diff_item(
db.conn(),
&data_dir,
&project_id,
direction,
&item,
)
.map_err(|error| error.to_string())
},
Message::MetadataDiffItemRepaired,
|task_id, result| Message::MetadataDiffItemRepaired { task_id, result },
)
}
Message::MetadataDiffItemRepaired(result) => {
Message::MetadataDiffItemRepaired { task_id, result } => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
self.metadata_diff_state.is_repairing = false;
if cancelled {
return Task::none();
}
match result {
Ok(()) => {
self.notify(
@@ -214,6 +266,62 @@ impl BdsApp {
}
}
}
Message::ImportMetadataOrphan(index) => {
let Some(orphan) = self
.metadata_diff_state
.report
.as_ref()
.and_then(|report| report.orphans.get(index))
.cloned()
else {
return Task::none();
};
self.metadata_diff_state.is_repairing = true;
let locale = self.ui_locale;
self.spawn_result_task(
"metadataDiff.importOrphan",
move |db_path, project_id, data_dir, tm, tid| {
tm.report_progress(
tid,
Some(0.0),
Some(t(locale, "metadataDiff.importOrphan")),
);
if tm.is_cancelled(tid) {
return Err("operation cancelled".into());
}
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::metadata_diff::import_orphan_file(
db.conn(),
&data_dir,
&project_id,
&orphan,
)
.map_err(|error| error.to_string())
},
|task_id, result| Message::MetadataOrphanImported { task_id, result },
)
}
Message::MetadataOrphanImported { task_id, result } => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
self.metadata_diff_state.is_repairing = false;
if cancelled {
return Task::none();
}
match result {
Ok(()) => {
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "metadataDiff.orphanImported"),
);
self.start_metadata_diff()
}
Err(error) => {
self.notify(ToastLevel::Error, &error);
Task::none()
}
}
}
Message::RunSiteValidation => self.start_site_validation(),
Message::ApplySiteValidation => self.apply_site_validation(),
Message::EngineTaskDone {
@@ -223,15 +331,14 @@ impl BdsApp {
result,
} => {
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
let refresh_semantic_tags = !cancelled
let cancellation_requested = self.task_manager.is_cancelled(task_id);
let refresh_semantic_tags = !cancellation_requested
&& result.is_ok()
&& matches!(
operation,
"embeddings.indexing" | "menu.item.rebuildEmbeddingIndex"
);
match &result {
_ if cancelled => {}
Ok(detail) => {
self.task_manager.complete(task_id);
if operation == "engine.rebuildStarted" {
@@ -258,12 +365,14 @@ impl BdsApp {
}
Err(err) => {
self.task_manager.fail(task_id, err.clone());
let message = tw(
self.ui_locale,
"common.operationFailed",
&[("operation", &label), ("error", err)],
);
self.notify(ToastLevel::Error, &message);
if !cancellation_requested {
let message = tw(
self.ui_locale,
"common.operationFailed",
&[("operation", &label), ("error", err)],
);
self.notify(ToastLevel::Error, &message);
}
}
}
if search_rebuild_finished {
@@ -293,12 +402,41 @@ impl BdsApp {
self.refresh_task_snapshots();
Task::batch([sidebar_task, semantic_task])
}
Message::SiteGenerationPrepared {
task_id,
validation,
force,
result,
} => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
if cancelled {
if validation.is_some() {
self.site_validation_state.is_applying = false;
}
return Task::none();
}
match result {
Ok((prepared, counts)) => {
self.queue_prepared_site_generation(validation, force, prepared, counts)
}
Err(error) => {
if validation.is_some() {
self.site_validation_state.is_applying = false;
self.site_validation_state.error_message = Some(error.clone());
}
self.notify(ToastLevel::Error, &error);
Task::none()
}
}
}
Message::SiteGenerationSectionDone {
group_id,
task_id,
result,
} => {
if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) {
if self.task_manager.is_cancelled(task_id) {
self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
return Task::none();
}
@@ -335,21 +473,54 @@ impl BdsApp {
}
}
let should_index =
let next_phase =
self.site_generation_workflows
.get(&group_id)
.is_some_and(|workflow| {
workflow.index_task_id.is_none()
.and_then(|workflow| {
(workflow.calendar_task_id.is_none()
&& workflow.index_task_id.is_none()
&& workflow.render_task_ids.iter().all(|task_id| {
self.task_manager.status(*task_id)
== Some(TaskStatus::Completed)
})
}))
.then_some(workflow.calendar_needed)
});
self.refresh_task_snapshots();
if should_index {
self.queue_site_search_index(&group_id)
} else {
Task::none()
match next_phase {
Some(true) => self.queue_site_calendar(&group_id),
Some(false) => self.queue_site_search_index(&group_id),
None => Task::none(),
}
}
Message::SiteGenerationCalendarDone {
group_id,
task_id,
result,
} => {
if self.task_manager.is_cancelled(task_id) {
self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
return Task::none();
}
match result {
Ok(()) => {
self.task_manager.complete(task_id);
self.refresh_task_snapshots();
self.queue_site_search_index(&group_id)
}
Err(error) => {
self.task_manager.fail(task_id, error.clone());
self.task_manager.cancel_group(&group_id);
if let Some(workflow) = self.site_generation_workflows.remove(&group_id)
&& workflow.kind == SiteGenerationKind::Validation
{
self.site_validation_state.is_applying = false;
self.site_validation_state.error_message = Some(error.clone());
}
self.notify(ToastLevel::Error, &error);
self.refresh_task_snapshots();
Task::none()
}
}
}
Message::SiteGenerationIndexDone {
@@ -357,7 +528,8 @@ impl BdsApp {
task_id,
result,
} => {
if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) {
if self.task_manager.is_cancelled(task_id) {
self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
return Task::none();
}
@@ -415,8 +587,13 @@ impl BdsApp {
}
}
}
Message::SiteValidationLoaded(result) => {
Message::SiteValidationLoaded { task_id, result } => {
let cancelled = self.finish_result_task(task_id, &result);
self.refresh_task_snapshots();
self.site_validation_state.is_running = false;
if cancelled {
return Task::none();
}
self.site_validation_state.has_run = true;
match result {
Ok(report) => {

View File

@@ -259,10 +259,11 @@ impl BdsApp {
Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let _worker = worker;
let engine = GitEngine::new(&data_dir);
let old_head = (operation == GitOperation::Pull)
.then(|| engine.head())

View File

@@ -14,35 +14,40 @@ fn shorten_task_url(url: &str) -> String {
}
impl BdsApp {
pub(super) fn finish_result_task<T>(
&self,
task_id: TaskId,
result: &Result<T, String>,
) -> bool {
let cancellation_requested = self.task_manager.is_cancelled(task_id);
match result {
Ok(_) => self.task_manager.complete(task_id),
Err(error) => self.task_manager.fail(task_id, error.clone()),
}
cancellation_requested && result.is_err()
}
pub(super) fn refresh_task_snapshots(&mut self) {
self.task_snapshots = self
.task_manager
.snapshots()
.into_iter()
.map(|snapshot| {
let status_str = match &snapshot.status {
TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"),
TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"),
TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"),
TaskStatus::Failed(error) => {
tw(self.ui_locale, "tasks.statusFailed", &[("error", error)])
}
TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"),
};
TaskSnapshot {
id: snapshot.id,
label: snapshot.label,
group_id: snapshot.group_id,
group_name: snapshot.group_name,
status: status_str,
progress: snapshot.progress,
message: snapshot.message,
is_cancellable: matches!(
snapshot.status,
TaskStatus::Pending | TaskStatus::Running
),
}
.map(|snapshot| TaskSnapshot {
id: snapshot.id,
source: crate::state::navigation::TaskSource::Local,
label: snapshot.label,
group_id: snapshot.group_id,
group_name: snapshot.group_name,
status: snapshot.status.clone(),
progress: snapshot.progress,
message: snapshot.message,
cancellation_requested: snapshot.cancellation_requested,
is_cancellable: matches!(
snapshot.status,
TaskStatus::Pending | TaskStatus::Running
) && !snapshot.cancellation_requested,
})
.chain(self.remote_task_snapshots.iter().cloned())
.collect();
}
@@ -69,7 +74,7 @@ impl BdsApp {
} else {
SiteGenerationKind::Full
};
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
let Some(data_dir) = &self.data_dir else {
if kind == SiteGenerationKind::Validation {
self.site_validation_state.is_applying = false;
}
@@ -79,6 +84,16 @@ impl BdsApp {
);
return Task::none();
};
if self.active_project.is_none() {
if kind == SiteGenerationKind::Validation {
self.site_validation_state.is_applying = false;
}
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "engine.generateSiteNoProject"),
);
return Task::none();
}
let metadata = match engine::meta::read_project_json(data_dir) {
Ok(metadata) => metadata,
Err(error) => {
@@ -107,37 +122,122 @@ impl BdsApp {
return Task::none();
}
let sections = validation.as_ref().map_or_else(
|| engine::generation::GenerationSection::ALL.to_vec(),
|validation| engine::generation::sections_from_validation_report(validation, &metadata),
);
if sections.is_empty() {
return Task::none();
}
let task_validation = validation.clone();
let locale = self.ui_locale;
self.spawn_result_task(
"engine.prepareGeneration",
move |db_path, project_id, data_dir, tm, tid| {
tm.report_progress(
tid,
Some(0.0),
Some(t(locale, "engine.preparingGeneration")),
);
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&data_dir)
.map_err(|error| error.to_string())?;
if tm.is_cancelled(tid) {
return Err("operation cancelled".into());
}
let posts =
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
.map_err(|error| error.to_string())?
.into_iter()
.filter(engine::generation::has_published_snapshot)
.collect::<Vec<_>>();
let total_posts = posts.len();
let mut sources = Vec::with_capacity(total_posts);
for (index, post) in posts.into_iter().enumerate() {
tm.report_progress(
tid,
Some(0.05 + (index + 1) as f32 / total_posts.max(1) as f32 * 0.35),
Some(t(locale, "engine.loadingPosts")),
);
if tm.is_cancelled(tid) {
return Err("operation cancelled".into());
}
if let Some(source) =
engine::generation::load_published_post_source(&data_dir, post)
.map_err(|error| error.to_string())?
{
sources.push(source);
}
}
tm.report_progress(
tid,
Some(0.45),
Some(t(locale, "engine.preparingGeneration")),
);
let prepared = Arc::new(
engine::generation::prepare_site_generation(
db.conn(),
&data_dir,
&project_id,
&metadata,
&sources,
)
.map_err(|error| error.to_string())?,
);
if tm.is_cancelled(tid) {
return Err("operation cancelled".into());
}
let sections = task_validation.as_ref().map_or_else(
|| engine::generation::GenerationSection::ALL.to_vec(),
|report| engine::generation::sections_from_validation_report(report, &metadata),
);
let counts = sections
.into_iter()
.map(|section| {
(
section,
engine::generation::prepared_section_page_count(
&prepared,
task_validation.as_ref(),
section,
),
)
})
.collect();
tm.report_progress(tid, Some(1.0), Some(t(locale, "engine.generationPrepared")));
Ok((prepared, counts))
},
move |task_id, result| Message::SiteGenerationPrepared {
task_id,
validation: validation.clone(),
force,
result,
},
)
}
pub(super) fn queue_prepared_site_generation(
&mut self,
validation: Option<engine::validate_site::SiteValidationReport>,
force: bool,
prepared: Arc<engine::generation::PreparedSiteGeneration>,
page_estimates: HashMap<engine::generation::GenerationSection, usize>,
) -> Task<Message> {
let kind = if validation.is_some() {
SiteGenerationKind::Validation
} else if force {
SiteGenerationKind::Forced
} else {
SiteGenerationKind::Full
};
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
return Task::none();
};
let sections = page_estimates.keys().copied().collect::<Vec<_>>();
let calendar_needed = kind == SiteGenerationKind::Validation
&& sections.iter().any(|section| {
matches!(
section,
engine::generation::GenerationSection::Core
| engine::generation::GenerationSection::Date
)
});
let project_id = project.id.clone();
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let published_posts = match self.db.as_ref().and_then(|db| {
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id).ok()
}) {
Some(posts) => posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
.collect::<Vec<_>>(),
None => return Task::none(),
};
let page_estimates = match bds_core::render::estimate_site_render_pages(
&data_dir,
&metadata,
&published_posts,
) {
Ok(estimates) => estimates,
Err(error) => {
self.notify(ToastLevel::Error, &error.to_string());
return Task::none();
}
};
let group_id = format!("site-generation:{}", Uuid::new_v4());
let group_name = t(
self.ui_locale,
@@ -150,7 +250,10 @@ impl BdsApp {
let mut render_task_ids = Vec::new();
let mut tasks = Vec::new();
for section in sections {
for section in engine::generation::GenerationSection::ALL
.into_iter()
.filter(|section| sections.contains(section))
{
let label = t(self.ui_locale, generation_section_label_key(section));
self.add_output(&label);
let page_work = page_estimates[&section];
@@ -173,14 +276,20 @@ impl BdsApp {
let task_data_dir = data_dir.clone();
let task_group_id = group_id.clone();
let task_validation = validation.clone();
let task_prepared = Arc::clone(&prepared);
let locale = self.ui_locale;
tasks.push(Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
let _worker = worker;
run_site_generation_section(
task_db_path,
task_project_id,
task_data_dir,
task_prepared,
task_manager,
task_id,
section,
@@ -210,6 +319,8 @@ impl BdsApp {
data_dir,
group_name,
render_task_ids,
calendar_needed,
calendar_task_id: None,
index_task_id: None,
report: engine::generation::GenerationReport::default(),
},
@@ -218,6 +329,75 @@ impl BdsApp {
Task::batch(tasks)
}
pub(super) fn queue_site_calendar(&mut self, group_id: &str) -> Task<Message> {
let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else {
return Task::none();
};
let label = t(self.ui_locale, "engine.calendarStarted");
let task_id = self
.task_manager
.submit_grouped(&label, group_id, &workflow.group_name);
if let Some(workflow) = self.site_generation_workflows.get_mut(group_id) {
workflow.calendar_task_id = Some(task_id);
}
self.refresh_task_snapshots();
let task_manager = Arc::clone(&self.task_manager);
let task_group_id = group_id.to_string();
let locale = self.ui_locale;
Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
let _worker = worker;
task_manager.report_progress(
task_id,
Some(0.0),
Some(t(locale, "engine.loadingPosts")),
);
let db =
Database::open(&workflow.db_path).map_err(|error| error.to_string())?;
engine::calendar::regenerate_calendar_with_progress(
db.conn(),
&workflow.data_dir,
&workflow.project_id,
|current, total, name| {
task_manager.report_progress(
task_id,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"engine.checkingItem",
&[
("current", &current.to_string()),
("total", &total.to_string()),
("name", name),
],
)),
);
!task_manager.is_cancelled(task_id)
},
)
.map_err(|error| error.to_string())?;
task_manager.report_progress(
task_id,
Some(1.0),
Some(t(locale, "engine.writingCalendar")),
);
Ok(())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::SiteGenerationCalendarDone {
group_id: task_group_id.clone(),
task_id,
result,
},
)
}
pub(super) fn queue_site_search_index(&mut self, group_id: &str) -> Task<Message> {
let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else {
return Task::none();
@@ -237,10 +417,11 @@ impl BdsApp {
let task_group_id = group_id.to_string();
Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let _worker = worker;
let db =
Database::open(&workflow.db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&workflow.data_dir)
@@ -312,7 +493,7 @@ impl BdsApp {
if workflow.kind == SiteGenerationKind::Validation {
self.site_validation_state.is_applying = false;
}
self.add_output(&t(self.ui_locale, "engine.generationCancelled"));
self.add_output(&t(self.ui_locale, "engine.generationCancellationRequested"));
self.refresh_task_snapshots();
true
}
@@ -337,7 +518,10 @@ impl BdsApp {
let task_id = self.task_manager.submit(&label);
self.search_index_rebuild_running = true;
self.search_index_rebuild_task_id = Some(task_id);
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding);
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding {
task_id,
cancellation_requested: false,
});
self.refresh_task_snapshots();
self.sync_menu_state();
@@ -346,10 +530,11 @@ impl BdsApp {
let task_manager = Arc::clone(&self.task_manager);
Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let _worker = worker;
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let progress_manager = Arc::clone(&task_manager);
let on_item: engine::search::ItemProgressFn =
@@ -373,6 +558,7 @@ impl BdsApp {
Some(progress),
Some(message),
);
!progress_manager.is_cancelled(task_id)
});
let report = engine::search::rebuild_search_index(db.conn(), Some(on_item))
.map_err(|error| error.to_string())?;
@@ -403,6 +589,49 @@ impl BdsApp {
self.spawn_engine_task_in_group(label_key, None, work)
}
pub(super) fn spawn_result_task<T, F, M>(
&mut self,
label_key: &'static str,
work: F,
message: M,
) -> Task<Message>
where
T: Send + 'static,
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<T, String>
+ Send
+ 'static,
M: Fn(TaskId, Result<T, String>) -> Message + Send + Sync + 'static,
{
let (Some(project_id), Some(data_dir)) = (
self.active_project
.as_ref()
.map(|project| project.id.clone()),
self.data_dir.clone(),
) else {
return Task::none();
};
let label = t(self.ui_locale, label_key);
self.add_output(&label);
let task_id = self.task_manager.submit(&label);
self.refresh_task_snapshots();
let task_manager = Arc::clone(&self.task_manager);
let db_path = self.db_path.clone();
Task::perform(
async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
let _worker = worker;
work(db_path, project_id, data_dir, task_manager, task_id)
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| message(task_id, result),
)
}
pub(super) fn spawn_grouped_engine_task<F>(
&mut self,
label_key: &'static str,
@@ -453,10 +682,11 @@ impl BdsApp {
Task::perform(
async move {
let Some(worker) = tm.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || {
if !tm.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let _worker = worker;
work(db_path, project_id, data_dir, tm, task_id)
})
.await
@@ -490,6 +720,7 @@ fn run_site_generation_section(
db_path: PathBuf,
project_id: String,
data_dir: PathBuf,
prepared: Arc<engine::generation::PreparedSiteGeneration>,
task_manager: Arc<TaskManager>,
task_id: TaskId,
section: engine::generation::GenerationSection,
@@ -498,9 +729,6 @@ fn run_site_generation_section(
locale: UiLocale,
expected_pages: usize,
) -> Result<engine::generation::GenerationReport, String> {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
task_manager.report_progress(
task_id,
Some(if expected_pages == 0 { 1.0 } else { 0.0 }),
@@ -511,26 +739,6 @@ fn run_site_generation_section(
)),
);
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
.map_err(|error| error.to_string())?;
let sources = posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
.map(|post| engine::generation::load_published_post_source(&data_dir, post))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| error.to_string())?
.into_iter()
.flatten()
.collect::<Vec<_>>();
let prepared = engine::generation::prepare_site_generation(
db.conn(),
&data_dir,
&project_id,
&metadata,
&sources,
)
.map_err(|error| error.to_string())?;
let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
let render_manager = Arc::clone(&task_manager);
@@ -565,7 +773,7 @@ fn run_site_generation_section(
db.conn(),
&output_dir,
&project_id,
&prepared,
prepared.as_ref(),
&validation,
section,
on_page,
@@ -576,7 +784,7 @@ fn run_site_generation_section(
db.conn(),
&output_dir,
&project_id,
&prepared,
prepared.as_ref(),
section,
force,
&on_rendered,

View File

@@ -107,15 +107,23 @@ impl PanelTab {
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
pub id: u64,
pub source: TaskSource,
pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: String,
pub status: bds_core::engine::task::TaskStatus,
pub progress: Option<f32>,
pub message: Option<String>,
pub cancellation_requested: bool,
pub is_cancellable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskSource {
Local,
Remote,
}
/// A single line of output shown in the panel.
#[derive(Debug, Clone)]
pub struct OutputEntry {

View File

@@ -386,7 +386,10 @@ fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static
)
.padding(6),
button(text(t(locale, "common.cancel")).size(11))
.on_press(Message::CancelTask(run.task_id))
.on_press(Message::CancelTask(
crate::state::navigation::TaskSource::Local,
run.task_id,
))
.padding([4, 7])
.style(inputs::secondary_button),
]

View File

@@ -101,19 +101,33 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M
));
}
if !report.orphans.is_empty() {
let rows = report
.orphans
.iter()
.fold(column!().spacing(4), |column, orphan| {
column.push(
let rows = report.orphans.iter().enumerate().fold(
column!().spacing(8),
|column, (index, orphan)| {
let mut item = row![
text(tw(
locale,
"metadataDiff.orphan",
&[("reason", &orphan.reason), ("path", &orphan.file_path)],
))
.size(12),
)
});
Space::with_width(Length::Fill),
]
.align_y(iced::Alignment::Center);
if orphan.reason == "file_without_db_entry" {
item = item.push(
button(text(t(locale, "metadataDiff.importOrphan")).size(12))
.on_press_maybe(
(!state.is_repairing)
.then_some(Message::ImportMetadataOrphan(index)),
)
.style(inputs::secondary_button)
.padding([5, 10]),
);
}
column.push(item)
},
);
content = content.push(inputs::card(
column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8),
));

View File

@@ -64,7 +64,10 @@ pub enum ModalState {
on_confirm: ConfirmAction,
},
SearchIndexRepair,
SearchIndexRebuilding,
SearchIndexRebuilding {
task_id: bds_core::engine::task::TaskId,
cancellation_requested: bool,
},
FindReplace {
query: String,
replacement: String,
@@ -552,8 +555,11 @@ pub fn view(
.into()
}
ModalState::SearchIndexRebuilding => container(
column![
ModalState::SearchIndexRebuilding {
task_id,
cancellation_requested,
} => {
let mut content = column![
text(t(locale, "searchIndexRepair.rebuildingTitle"))
.size(16)
.shaping(Shaping::Advanced)
@@ -563,12 +569,31 @@ pub fn view(
.size(13)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
]
.padding(20),
)
.width(Length::Fixed(420.0))
.style(modal_box_style)
.into(),
];
content = if cancellation_requested {
content.push(Space::with_height(16.0)).push(
text(t(locale, "tasks.status.cancelling"))
.size(13)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
)
} else {
content.push(Space::with_height(16.0)).push(row![
Space::with_width(Length::Fill),
button(text(t(locale, "tasks.cancelTask")).size(13))
.on_press(Message::CancelTask(
crate::state::navigation::TaskSource::Local,
task_id,
))
.padding([6, 16])
.style(cancel_button_style),
])
};
container(content.padding(20))
.width(Length::Fixed(420.0))
.style(modal_box_style)
.into()
}
ModalState::PostInsertLink {
post_id: _post_id,

View File

@@ -3,17 +3,28 @@ use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::engine::task::TaskStatus;
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::i18n::{t, tw};
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
use crate::state::tabs::{Tab, TabType};
use crate::views::post_editor::ResolvedPostLink;
use std::collections::HashSet;
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> {
let status = match &snapshot.status {
TaskStatus::Pending => t(locale, "tasks.statusPending"),
TaskStatus::Running if snapshot.cancellation_requested => {
t(locale, "tasks.statusCancelling")
}
TaskStatus::Running => t(locale, "tasks.statusRunning"),
TaskStatus::Completed => t(locale, "tasks.statusCompleted"),
TaskStatus::Failed(error) => tw(locale, "tasks.statusFailed", &[("error", error)]),
TaskStatus::Cancelled => t(locale, "tasks.statusCancelled"),
};
let progress = snapshot
.progress
.map(|value| format!(" ({:.0}%)", value * 100.0))
@@ -25,7 +36,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
.unwrap_or_default();
let label = text(format!(
"{}{}{}{}",
snapshot.label, snapshot.status, progress, phase
snapshot.label, status, progress, phase
))
.size(11)
.shaping(Shaping::Advanced)
@@ -34,7 +45,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
if snapshot.is_cancellable {
content = content.push(Space::with_width(Length::Fill)).push(
button(text(t(locale, "tasks.cancelTask")).size(11))
.on_press(Message::CancelTask(snapshot.id))
.on_press(Message::CancelTask(snapshot.source, snapshot.id))
.padding([3, 8])
.style(inputs::secondary_button),
);
@@ -46,12 +57,9 @@ fn group_progress(members: &[&TaskSnapshot]) -> Option<f32> {
(!members.is_empty()).then(|| {
members
.iter()
.map(|member| {
if member.is_cancellable {
member.progress.unwrap_or(0.0)
} else {
1.0
}
.map(|member| match member.status {
TaskStatus::Completed | TaskStatus::Failed(_) | TaskStatus::Cancelled => 1.0,
TaskStatus::Pending | TaskStatus::Running => member.progress.unwrap_or(0.0),
})
.sum::<f32>()
/ members.len() as f32
@@ -219,7 +227,7 @@ pub fn view(
.padding(8)
.into()
} else {
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
let visible = task_snapshots.iter().collect::<Vec<_>>();
let mut rendered_groups = HashSet::new();
let mut items: Vec<Element<'static, Message>> = Vec::new();
for snapshot in &visible {
@@ -441,16 +449,19 @@ mod progress_tests {
fn group_progress_includes_pending_tasks_as_zero_like_bds2() {
let complete = TaskSnapshot {
id: 1,
source: crate::state::navigation::TaskSource::Local,
label: String::new(),
group_id: None,
group_name: None,
status: String::new(),
status: TaskStatus::Completed,
progress: Some(1.0),
message: None,
cancellation_requested: false,
is_cancellable: false,
};
let pending = TaskSnapshot {
id: 2,
status: TaskStatus::Pending,
progress: None,
is_cancellable: true,
..complete.clone()

View File

@@ -2,10 +2,11 @@ use iced::widget::text::Shaping;
use iced::widget::{Space, button, container, row, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::task::TaskStatus;
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::tw;
use crate::i18n::{t, tw};
use crate::state::navigation::TaskSnapshot;
use crate::views::project_selector;
@@ -131,23 +132,38 @@ pub fn view(
let project_name = active_project_name.unwrap_or("\u{2014}");
let project_trigger = project_selector::trigger_button(project_name);
let running: Vec<&TaskSnapshot> = task_snapshots
let active: Vec<&TaskSnapshot> = task_snapshots
.iter()
.filter(|t| t.status == "running")
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.collect();
let task_indicator: Element<'static, Message> = if !running.is_empty() {
let first = &running[0];
let first = active
.iter()
.find(|task| task.status == TaskStatus::Running)
.or_else(|| active.first());
let task_indicator: Element<'static, Message> = if let Some(first) = first {
let progress_str = first
.progress
.map(|p| format!(" {:.0}%", p * 100.0))
.unwrap_or_default();
let phase_str = first.message.as_deref().unwrap_or("");
let extra = if running.len() > 1 {
format!(" (+{})", running.len() - 1)
let extra = if active.len() > 1 {
format!(" (+{})", active.len() - 1)
} else {
String::new()
};
let display = if phase_str.is_empty() {
let display = if first.cancellation_requested {
format!(
"{}: {}{progress_str}{extra}",
t(locale, "tasks.statusCancelling"),
first.label
)
} else if first.status == TaskStatus::Pending {
format!(
"{}: {}{extra}",
t(locale, "tasks.statusPending"),
first.label
)
} else if phase_str.is_empty() {
format!("{}{progress_str}{extra}", first.label)
} else {
format!("{phase_str}{progress_str}{extra}")