Skip to content

Commit 3b30d9a

Browse files
committed
fix: register cancel flags for image/webpage/document compares, fix progress bar and cache invalidation
- Document, image, and webpage compare endpoints now register cancel flags so the Stop button can interrupt them. After the core compare returns, the result is discarded if cancelled (returns {cancelled:true}). Refactored the cancel+progress registration into a CancellableRequest struct to avoid clippy type_complexity. - Folder compare progress bar no longer stalls near 50%: the walk emits Discovered events per-side (~2x for similar trees), so the compare-phase total now uses discovered_total/2 as the estimate with .max() self- correction for asymmetric trees. - folder_op_execute_bridge_response now invalidates folder_compare_cache after mutating the filesystem, preventing stale reads if a subsequent /folder/query runs before the GUI triggers a recompare.
1 parent 725eb68 commit 3b30d9a

3 files changed

Lines changed: 115 additions & 14 deletions

File tree

apps/linsync-gui/src/bridge.rs

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,10 @@ pub(crate) fn bridge_response_with_token(
308308
Ok(p) => p,
309309
Err(err) => return bridge_error(400, "Bad Request", &err),
310310
};
311-
let (request_id, progress) =
312-
register_progress_request(&params, state, "extracting", 3, "Extracting text");
311+
let req =
312+
register_cancellable_request(&params, state, "extracting", 3, "Extracting text");
313313
set_progress(
314-
&progress,
314+
&req.progress,
315315
"extracting",
316316
1,
317317
3,
@@ -322,8 +322,19 @@ pub(crate) fn bridge_response_with_token(
322322
query,
323323
&profile.document,
324324
);
325+
// If the user hit Stop during the (potentially slow) plugin
326+
// extraction, discard the result and report cancellation.
327+
if req.is_cancelled() {
328+
remove_cancellable_request(&req, state);
329+
return http_response(
330+
200,
331+
"OK",
332+
"application/json",
333+
br#"{"cancelled":true}"#.to_vec(),
334+
);
335+
}
325336
set_progress(
326-
&progress,
337+
&req.progress,
327338
"finalizing",
328339
2,
329340
3,
@@ -356,8 +367,8 @@ pub(crate) fn bridge_response_with_token(
356367
}
357368
}
358369
}
359-
set_progress(&progress, "done", 3, 3, String::new());
360-
remove_progress_request(request_id.as_deref(), state);
370+
set_progress(&req.progress, "done", 3, 3, String::new());
371+
remove_cancellable_request(&req, state);
361372
http_response(200, "OK", "application/json", body.into_bytes())
362373
}
363374
"/profiles/list" => profiles_list_bridge_response(paths),
@@ -374,8 +385,21 @@ pub(crate) fn bridge_response_with_token(
374385
Ok(p) => p,
375386
Err(err) => return bridge_error(400, "Bad Request", &err),
376387
};
388+
let req =
389+
register_cancellable_request(&params, state, "comparing", 1, "Comparing images");
377390
let (mut body, result) =
378391
linsync::image_compare_bridge_response_with_profile(query, &profile.image);
392+
// If the user hit Stop during the compare, discard the result.
393+
if req.is_cancelled() {
394+
remove_cancellable_request(&req, state);
395+
return http_response(
396+
200,
397+
"OK",
398+
"application/json",
399+
br#"{"cancelled":true}"#.to_vec(),
400+
);
401+
}
402+
remove_cancellable_request(&req, state);
379403
let result_for_tab = result.clone();
380404
let overlay_path = serde_json::from_str::<serde_json::Value>(&body)
381405
.ok()
@@ -420,10 +444,10 @@ pub(crate) fn bridge_response_with_token(
420444
Ok(p) => p,
421445
Err(err) => return bridge_error(400, "Bad Request", &err),
422446
};
423-
let (request_id, progress) =
424-
register_progress_request(&params, state, "fetching", 3, "Fetching webpages");
447+
let req =
448+
register_cancellable_request(&params, state, "fetching", 3, "Fetching webpages");
425449
set_progress(
426-
&progress,
450+
&req.progress,
427451
"fetching",
428452
1,
429453
3,
@@ -434,8 +458,19 @@ pub(crate) fn bridge_response_with_token(
434458
paths,
435459
&profile.webpage,
436460
);
461+
// If the user hit Stop during the (potentially slow) fetch/render,
462+
// discard the result and report cancellation.
463+
if req.is_cancelled() {
464+
remove_cancellable_request(&req, state);
465+
return http_response(
466+
200,
467+
"OK",
468+
"application/json",
469+
br#"{"cancelled":true}"#.to_vec(),
470+
);
471+
}
437472
set_progress(
438-
&progress,
473+
&req.progress,
439474
"finalizing",
440475
2,
441476
3,
@@ -457,8 +492,8 @@ pub(crate) fn bridge_response_with_token(
457492
state,
458493
);
459494
}
460-
set_progress(&progress, "done", 3, 3, String::new());
461-
remove_progress_request(request_id.as_deref(), state);
495+
set_progress(&req.progress, "done", 3, 3, String::new());
496+
remove_cancellable_request(&req, state);
462497
http_response(200, "OK", "application/json", body.into_bytes())
463498
}
464499
"/compare/webpage/clear-cache" => {
@@ -3869,6 +3904,13 @@ pub(crate) fn folder_op_execute_bridge_response(
38693904
linsync_core::PermanentDeleteConfirmation::NotConfirmed
38703905
};
38713906
let outcomes = execute_folder_operation_plan(&plan, &paths.data_dir, use_trash, confirmation);
3907+
// The filesystem changed — invalidate the cached folder compare so the
3908+
// next /folder/query doesn't serve pre-operation results. (The GUI
3909+
// normally calls requestCompare after execute, which also clears it,
3910+
// but this is defense-in-depth against stale-cache reads.)
3911+
if let Ok(mut state) = state.lock() {
3912+
state.folder_compare_cache = None;
3913+
}
38723914
let body = folder_outcomes_to_json(&plan, &outcomes).to_string();
38733915
http_response(200, "OK", "application/json", body.into_bytes())
38743916
}

apps/linsync-gui/src/main.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,61 @@ fn remove_progress_request(request_id: Option<&str>, state: &Arc<Mutex<GuiBridge
515515
}
516516
}
517517

518+
/// A registered cancellable compare request: holds the request id (for
519+
/// cleanup), a should_cancel closure (polled by the compare), and a progress
520+
/// tracker (updated by the compare, read by `/progress`).
521+
struct CancellableRequest {
522+
request_id: Option<String>,
523+
should_cancel: Box<dyn Fn() -> bool>,
524+
progress: Option<Arc<Mutex<CompareProgress>>>,
525+
}
526+
527+
impl CancellableRequest {
528+
/// Polls the cancellation flag. Returns `false` when no `request_id`
529+
/// was supplied (nothing to cancel).
530+
fn is_cancelled(&self) -> bool {
531+
(self.should_cancel)()
532+
}
533+
}
534+
535+
/// Register both a progress tracker and a cancellation flag for a compare
536+
/// request. Call [`CancellableRequest::remove`] (or
537+
/// [`remove_cancellable_request`]) when the compare finishes or is cancelled.
538+
fn register_cancellable_request(
539+
params: &[(String, String)],
540+
state: &Arc<Mutex<GuiBridgeState>>,
541+
phase: &str,
542+
total: usize,
543+
message: &str,
544+
) -> CancellableRequest {
545+
let (request_id, progress) = register_progress_request(params, state, phase, total, message);
546+
let should_cancel: Box<dyn Fn() -> bool> = if let Some(id) = &request_id {
547+
let flag = Arc::new(AtomicBool::new(false));
548+
if let Ok(mut state) = state.lock() {
549+
state.compare_cancels.insert(id.clone(), Arc::clone(&flag));
550+
}
551+
Box::new(move || flag.load(Ordering::Relaxed))
552+
} else {
553+
Box::new(|| false)
554+
};
555+
CancellableRequest {
556+
request_id,
557+
should_cancel,
558+
progress,
559+
}
560+
}
561+
562+
/// Remove the cancel flag and progress tracker registered by
563+
/// [`register_cancellable_request`].
564+
fn remove_cancellable_request(req: &CancellableRequest, state: &Arc<Mutex<GuiBridgeState>>) {
565+
if let Some(id) = &req.request_id
566+
&& let Ok(mut state) = state.lock()
567+
{
568+
state.compare_cancels.remove(id);
569+
}
570+
remove_progress_request(req.request_id.as_deref(), state);
571+
}
572+
518573
const GUI_HISTORY_LIMIT: usize = 16;
519574

520575
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -2233,7 +2288,11 @@ fn folder_tab_cancellable(
22332288
{
22342289
p.phase = "comparing".to_owned();
22352290
p.current = compared_count;
2236-
p.total = discovered_total.max(compared_count);
2291+
// Discovered fires per-side (left walk + right walk), so
2292+
// the unique entry count is roughly half. Use that as the
2293+
// compare-phase total estimate; .max() self-corrects for
2294+
// asymmetric trees where one side dominates.
2295+
p.total = (discovered_total / 2).max(compared_count);
22372296
p.message = relative_path.display().to_string();
22382297
}
22392298
}
@@ -2243,7 +2302,7 @@ fn folder_tab_cancellable(
22432302
{
22442303
p.phase = "done".to_owned();
22452304
p.current = compared_count;
2246-
p.total = discovered_total.max(compared_count);
2305+
p.total = compared_count;
22472306
p.message.clear();
22482307
}
22492308
}

tests/fixtures/document/simple.odt

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)