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

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}")