From 8bc905510dafac271b766edc231a0271a0262a8e Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 7 Mar 2026 19:57:09 +0800 Subject: [PATCH 1/4] Add a proof-of-concept for a long-running function While it's using `gix::Progress`, it could also use anything else for that. There might also be alternatives that offer checks for cancellation as part of the progress, but I found it better to make this explcit. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- crates/but-api/src/lib.rs | 224 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/crates/but-api/src/lib.rs b/crates/but-api/src/lib.rs index e2f6b5d4ada..1bca1dfbe59 100644 --- a/crates/but-api/src/lib.rs +++ b/crates/but-api/src/lib.rs @@ -38,3 +38,227 @@ pub mod platform; pub mod schema; pub mod panic_capture; + +/// A module for proof-of-concepts +pub mod poc { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, + }; + + /// Either the actual data that is more and more complete, or increments that can be merged + /// into the actual data by the receiver. + /// Sending all data whenever it changes is probably better. + pub struct Data(pub usize); + + /// Set `duration` to decide how long the function should run, without blocking the caller. + /// IRL the duration would be determined by the amount of work to be done. + /// + /// Use `progress` to see fine-grained progress information, either flat or as [`gix::NestedProgress`] + /// so that a progress tree can be built for complex progress ['visualisations'](https://asciinema.org/a/315956). + /// Please note that the underlying implementation, [`Prodash`](https://github.com/GitoxideLabs/prodash?tab=readme-ov-file), + /// also provides renderers. However, there are other crates as well and there is no reason these shouldn't be used if + /// these seem fitter. + /// + /// Use `should_interrupt` which should become `true` if the function should stop processing. + /// It must live outside of `scope`, like any other value borrowed by a scoped thread. + /// + /// Return a receiver for the data (incremental or increasingly complete, depending on what's more suitable). + /// + /// # Cancellation + /// + /// The task can be stopped in two ways: + /// + /// - by dropping the receiver + /// - this is easy, but has the disadvantage that the sender only stops once it tries to send the next result and fails + /// doing so. + /// - by setting `should_interrupt` to `true` + /// - this mechanism is fine-grained, and the callee is expected to pull the value often, so it will respond + /// swiftly to cancellation requests. + /// + /// # `[but_api]` Integration + /// + /// This function can't be `[but_api]` annotated until it learns how to deal with `duration` and more importantly, + /// `progress`, the return channel and how to wire up `should_interrupt`. If we want, any of these could serve as markers + /// to indicate long-runnning functions + /// + /// # Why not `async`? + /// + /// Our computations are not IO bound but compute bound, so there is no benefit to `async` or `tokio`. + /// And I really, really want to avoid all the issues we will be getting when `async` is used, `but-ctx` and `gix::Repository` + /// do not like `await` and require workarounds. + /// + /// At first, the integration should be implemented by hand (i.e. for NAPI) before it's generalised. + pub fn long_running_non_blocking_scoped_thread<'scope, 'env>( + scope: &'scope thread::Scope<'scope, 'env>, + duration: Duration, + progres: impl gix::Progress + 'env, + should_interrupt: &'env AtomicBool, + ) -> std::sync::mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + scope.spawn(move || run_long_running_worker(duration, progres, should_interrupt, tx)); + rx + } + + /// Like [`long_running_non_blocking_scoped_thread()`], but uses a regular thread and an owned + /// cancellation flag so the task can outlive the current stack frame. + pub fn long_running_non_blocking_thread( + duration: Duration, + progres: impl gix::Progress + 'static, + should_interrupt: Arc, + ) -> std::sync::mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || run_long_running_worker(duration, progres, &should_interrupt, tx)); + rx + } + + fn run_long_running_worker( + duration: Duration, + mut progres: impl gix::Progress, + should_interrupt: &AtomicBool, + tx: mpsc::Sender>, + ) { + const UPDATE_INTERVAL: Duration = Duration::from_millis(100); + const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(20); + + let total_steps = usize::max( + 1, + duration + .as_millis() + .div_ceil(UPDATE_INTERVAL.as_millis()) + .try_into() + .unwrap_or(usize::MAX), + ); + let start = Instant::now(); + + progres.init(Some(total_steps), gix::progress::steps()); + progres.set_name("proof of concept task".into()); + + for step in 1..=total_steps { + let scheduled_at = start + duration.mul_f64(step as f64 / total_steps as f64); + while let Some(remaining) = scheduled_at.checked_duration_since(Instant::now()) { + if should_interrupt.load(Ordering::Relaxed) { + progres.fail(format!("interrupted at step {}/{}", step - 1, total_steps)); + return; + } + thread::sleep(remaining.min(INTERRUPT_POLL_INTERVAL)); + } + + progres.set(step); + if tx.send(Ok(Data(step))).is_err() { + progres.info("receiver dropped".into()); + return; + } + } + + progres.done("completed".into()); + progres.show_throughput(start); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, + }; + + use super::poc; + + #[test] + fn long_running_non_blocking_scoped_thread_returns_before_work_completes() { + let should_interrupt = AtomicBool::new(false); + + thread::scope(|scope| { + let start = Instant::now(); + let rx = poc::long_running_non_blocking_scoped_thread( + scope, + Duration::from_millis(50), + gix::progress::Discard, + &should_interrupt, + ); + + assert!(start.elapsed() < Duration::from_millis(25)); + assert!(matches!( + rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let values = rx + .into_iter() + .collect::>>() + .expect("proof-of-concept task should complete"); + + assert_eq!(values.last().map(|data| data.0), Some(1)); + }); + } + + #[test] + fn long_running_non_blocking_scoped_thread_stops_when_interrupted() { + let should_interrupt = AtomicBool::new(false); + + thread::scope(|scope| { + let rx = poc::long_running_non_blocking_scoped_thread( + scope, + Duration::from_millis(200), + gix::progress::Discard, + &should_interrupt, + ); + should_interrupt.store(true, Ordering::Relaxed); + + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) + )); + }); + } + + #[test] + fn long_running_non_blocking_thread_returns_before_work_completes() { + let should_interrupt = Arc::new(AtomicBool::new(false)); + let start = Instant::now(); + let rx = poc::long_running_non_blocking_thread( + Duration::from_millis(50), + gix::progress::Discard, + should_interrupt, + ); + + assert!(start.elapsed() < Duration::from_millis(25)); + assert!(matches!( + rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let values = rx + .into_iter() + .collect::>>() + .expect("proof-of-concept task should complete"); + + assert_eq!(values.last().map(|data| data.0), Some(1)); + } + + #[test] + fn long_running_non_blocking_thread_stops_when_interrupted() { + let should_interrupt = Arc::new(AtomicBool::new(false)); + let rx = poc::long_running_non_blocking_thread( + Duration::from_millis(200), + gix::progress::Discard, + should_interrupt.clone(), + ); + should_interrupt.store(true, Ordering::Relaxed); + + assert!(matches!( + rx.recv_timeout(Duration::from_secs(1)), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) + )); + } +} From 15d15f9e3c5d8fde196de97c7d0c4cd668032ffb Mon Sep 17 00:00:00 2001 From: "chatgpt-codex-connector[bot]" <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:18:25 +0800 Subject: [PATCH 2/4] Show the long-running function call via `but-testing`. That's particularly interesting for the Progress visualisation and instantiation, as our 'renderer' would be a forwarder of sorts to pass the progress state on to the frontend. Co-authored-by: Sebastian Thiel --- Cargo.lock | 19 ++++++++ crates/but-testing/Cargo.toml | 5 ++- crates/but-testing/src/args.rs | 8 +++- crates/but-testing/src/command/mod.rs | 1 + crates/but-testing/src/command/poc.rs | 63 +++++++++++++++++++++++++++ crates/but-testing/src/main.rs | 3 ++ 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 crates/but-testing/src/command/poc.rs diff --git a/Cargo.lock b/Cargo.lock index 0f90116b9ce..ca2c4c54ffa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,7 @@ name = "but-testing" version = "0.0.0" dependencies = [ "anyhow", + "but-api", "but-core", "but-ctx", "but-db", @@ -1580,7 +1581,9 @@ dependencies = [ "gitbutler-reference", "gitbutler-stack", "gix", + "humantime", "itertools", + "prodash", "serde_json", "tokio", "tracing", @@ -2424,6 +2427,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crosstermion" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ae462f0b868614980d59df41e217a77648de7ad7cf8b2a407155659896d889" +dependencies = [ + "crossterm", + "nu-ansi-term", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -4387,7 +4400,9 @@ dependencies = [ "gix-worktree-state", "gix-worktree-stream", "nonempty", + "parking_lot", "serde", + "signal-hook 0.4.3", "smallvec", "thiserror 2.0.18", ] @@ -8135,7 +8150,11 @@ version = "31.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ + "crosstermion", + "is-terminal", + "jiff", "parking_lot", + "unicode-width", ] [[package]] diff --git a/crates/but-testing/Cargo.toml b/crates/but-testing/Cargo.toml index fb07bb3bc30..fe72aee2515 100644 --- a/crates/but-testing/Cargo.toml +++ b/crates/but-testing/Cargo.toml @@ -42,15 +42,18 @@ gitbutler-reference.workspace = true gitbutler-commit = { workspace = true, optional = true, features = ["testing"] } clap = { workspace = true, features = ["env"] } -gix.workspace = true +gix = { workspace = true, features = ["interrupt"] } anyhow.workspace = true itertools.workspace = true +humantime = "2.3.0" tracing-forest.workspace = true tracing-subscriber.workspace = true tracing.workspace = true dirs-next = "2.0.0" serde_json.workspace = true tokio.workspace = true +but-api = { path = "../but-api" } +prodash = { version = "31.0.0", default-features = false, features = ["progress-tree", "render-line", "render-line-autoconfigure", "render-line-crossterm"] } [lints] workspace = true diff --git a/crates/but-testing/src/args.rs b/crates/but-testing/src/args.rs index 4398ae2b72a..e17a6340646 100644 --- a/crates/but-testing/src/args.rs +++ b/crates/but-testing/src/args.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use gitbutler_reference::RemoteRefname; use gitbutler_stack::StackId; @@ -269,6 +269,12 @@ pub enum Subcommands { /// the short-name of the reference to delete. short_name: String, }, + /// Run the proof-of-concept long-running operation with progress rendering and interrupt handling. + LongRunning { + /// How long the proof-of-concept task should run, like `5s`, `250ms`, or `1m`. + #[arg(short = 'd', long, default_value = "5s", value_parser = humantime::parse_duration)] + duration: Duration, + }, } #[cfg(test)] diff --git a/crates/but-testing/src/command/mod.rs b/crates/but-testing/src/command/mod.rs index f2f3a5b1260..edee3e97908 100644 --- a/crates/but-testing/src/command/mod.rs +++ b/crates/but-testing/src/command/mod.rs @@ -43,6 +43,7 @@ use gitbutler_branch_actions::BranchListingFilter; use crate::command::discard_change::IndicesOrHeaders; pub mod diff; +pub mod poc; pub mod project; pub mod assignment { diff --git a/crates/but-testing/src/command/poc.rs b/crates/but-testing/src/command/poc.rs new file mode 100644 index 00000000000..a912a049f04 --- /dev/null +++ b/crates/but-testing/src/command/poc.rs @@ -0,0 +1,63 @@ +use std::{io, sync::Arc, thread, time::Duration}; + +use anyhow::{Result, bail}; + +const DEFAULT_FRAME_RATE: f32 = 60.0; + +pub fn long_running(duration: Duration, trace: bool) -> Result<()> { + #[allow(unsafe_code)] + let _interrupt_handler = unsafe { + // SAFETY: The closure only calls a function that in turn sets an atomic. No memory handling is triggered. + gix::interrupt::init_handler(1, gix::interrupt::trigger)?.auto_deregister() + }; + + let progress = progress_tree(trace); + let renderer = setup_line_renderer(&progress); + let result = thread::scope(|scope| -> Result> { + let rx = but_api::poc::long_running_non_blocking_scoped_thread( + scope, + duration, + progress.add_child("long-running"), + &gix::interrupt::IS_INTERRUPTED, + ); + let mut last = None; + for data in rx { + last = Some(data?.0); + } + Ok(last) + }); + renderer.shutdown_and_wait(); + + let last = result?; + if gix::interrupt::is_triggered() { + bail!("Interrupted"); + } + if let Some(last) = last { + println!("Completed {last} step(s)."); + } + Ok(()) +} + +fn progress_tree(trace: bool) -> Arc { + prodash::tree::root::Options { + message_buffer_capacity: if trace { 10_000 } else { 200 }, + ..Default::default() + } + .into() +} + +fn setup_line_renderer(progress: &Arc) -> prodash::render::line::JoinHandle { + prodash::render::line( + io::stderr(), + Arc::downgrade(progress), + prodash::render::line::Options { + frames_per_second: DEFAULT_FRAME_RATE, + initial_delay: Some(Duration::from_millis(500)), + timestamp: true, + throughput: true, + hide_cursor: true, + ..prodash::render::line::Options::default() + } + .auto_configure(prodash::render::line::StreamKind::Stderr), + ) +} diff --git a/crates/but-testing/src/main.rs b/crates/but-testing/src/main.rs index 1a3ff7535a0..323e946e8b8 100644 --- a/crates/but-testing/src/main.rs +++ b/crates/but-testing/src/main.rs @@ -43,6 +43,9 @@ async fn main() -> Result<()> { switch_to_workspace.to_owned(), ), args::Subcommands::RemoveProject { project_name } => command::project::remove(project_name), + args::Subcommands::LongRunning { duration } => { + command::poc::long_running(*duration, args.trace > 0) + } args::Subcommands::RemoveReference { permit_empty_stacks, keep_metadata, From fb7f075a495878fe3affe282c62a4feb076d75d2 Mon Sep 17 00:00:00 2001 From: estib Date: Thu, 12 Mar 2026 16:09:14 +0100 Subject: [PATCH 3/4] estib: napify long running process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building a POC on top of byron’s POC: - Export a function from Rust to Node that takes in a duration in ms and a callback. - The function returns immediately, doesn’t block. - The callback get’s event payloads from the Rust side. - A normal abort signal can be used to abort the process at anytime. --- crates/but-api/src/lib.rs | 161 ++++++++++++++++++++++ packages/but-sdk/src/generated/index.d.ts | 39 ++++++ packages/but-sdk/src/generated/index.js | 5 +- packages/but-sdk/src/test.ts | 113 ++++++++++++--- packages/but-sdk/tsconfig.json | 2 +- 5 files changed, 301 insertions(+), 19 deletions(-) diff --git a/crates/but-api/src/lib.rs b/crates/but-api/src/lib.rs index 1bca1dfbe59..779a656cd29 100644 --- a/crates/but-api/src/lib.rs +++ b/crates/but-api/src/lib.rs @@ -51,6 +51,30 @@ pub mod poc { time::{Duration, Instant}, }; + #[cfg(feature = "napi")] + use std::{ + collections::HashMap, + sync::{Mutex, OnceLock, atomic::AtomicU32}, + }; + + #[cfg(feature = "napi")] + use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; + + #[cfg(feature = "napi")] + static NEXT_TASK_ID: AtomicU32 = AtomicU32::new(1); + #[cfg(feature = "napi")] + static TASK_INTERRUPTS: OnceLock>>> = OnceLock::new(); + + #[cfg(feature = "napi")] + fn task_interrupts() -> &'static Mutex>> { + TASK_INTERRUPTS.get_or_init(|| Mutex::new(HashMap::new())) + } + + #[cfg(feature = "napi")] + fn emit_event(callback: &ThreadsafeFunction, event: LongRunningEvent) { + let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking); + } + /// Either the actual data that is more and more complete, or increments that can be merged /// into the actual data by the receiver. /// Sending all data whenever it changes is probably better. @@ -105,6 +129,143 @@ pub mod poc { rx } + /// Kinds of events emitted to JavaScript while a long-running task executes. + #[cfg(feature = "napi")] + #[napi_derive::napi(string_enum)] + pub enum LongRunningEventKind { + /// The task produced an intermediate progress update. + Progress, + /// The task finished successfully. + Done, + /// The task stopped because interruption was requested. + Cancelled, + /// The task failed with an error. + Error, + } + + /// Event payload for Node callbacks. + #[cfg(feature = "napi")] + #[napi_derive::napi(object)] + pub struct LongRunningEvent { + /// The id of the task that emitted this event. + pub task_id: u32, + /// The event category. + pub kind: LongRunningEventKind, + /// The latest completed step if available. + pub step: Option, + /// An optional error message for failed tasks. + pub message: Option, + } + + /// Start a long-running task and stream progress to `callback` via a ThreadsafeFunction. + /// + /// Returns the task id, which can be used to interrupt processing with + /// [`long_running_cancel_tsfn()`]. + #[cfg(feature = "napi")] + #[napi_derive::napi(js_name = "longRunningStartTsfn")] + pub fn long_running_start_tsfn( + duration_ms: u32, + callback: ThreadsafeFunction, + ) -> napi::Result { + let task_id = NEXT_TASK_ID.fetch_add(1, Ordering::Relaxed); + let should_interrupt = Arc::new(AtomicBool::new(false)); + + let mut tasks = task_interrupts().lock().map_err(|_| { + napi::Error::new( + napi::Status::GenericFailure, + "task registry is poisoned".to_string(), + ) + })?; + tasks.insert(task_id, should_interrupt.clone()); + drop(tasks); + + let rx = long_running_non_blocking_thread( + Duration::from_millis(u64::from(duration_ms)), + gix::progress::Discard, + should_interrupt.clone(), + ); + + thread::spawn(move || { + let mut last_step = None; + let mut failed = false; + + for result in rx { + match result { + Ok(data) => { + let step = u32::try_from(data.0).unwrap_or(u32::MAX); + last_step = Some(step); + emit_event( + &callback, + LongRunningEvent { + task_id, + kind: LongRunningEventKind::Progress, + step: Some(step), + message: None, + }, + ); + } + Err(err) => { + failed = true; + emit_event( + &callback, + LongRunningEvent { + task_id, + kind: LongRunningEventKind::Error, + step: last_step, + message: Some(format!("{err:#}")), + }, + ); + break; + } + } + } + + if !failed { + let kind = if should_interrupt.load(Ordering::Relaxed) { + LongRunningEventKind::Cancelled + } else { + LongRunningEventKind::Done + }; + emit_event( + &callback, + LongRunningEvent { + task_id, + kind, + step: last_step, + message: None, + }, + ); + } + + if let Ok(mut tasks) = task_interrupts().lock() { + tasks.remove(&task_id); + } + }); + + Ok(task_id) + } + + /// Interrupt a task started with [`long_running_start_tsfn()`]. + /// + /// Returns `true` if interruption was requested successfully. + #[cfg(feature = "napi")] + #[napi_derive::napi(js_name = "longRunningCancelTsfn")] + pub fn long_running_cancel_tsfn(task_id: u32) -> napi::Result { + let tasks = task_interrupts().lock().map_err(|_| { + napi::Error::new( + napi::Status::GenericFailure, + "task registry is poisoned".to_string(), + ) + })?; + + if let Some(flag) = tasks.get(&task_id) { + flag.store(true, Ordering::Relaxed); + return Ok(true); + } + + Ok(false) + } + /// Like [`long_running_non_blocking_scoped_thread()`], but uses a regular thread and an owned /// cancellation flag so the task can outlive the current stack frame. pub fn long_running_non_blocking_thread( diff --git a/packages/but-sdk/src/generated/index.d.ts b/packages/but-sdk/src/generated/index.d.ts index 78c97503613..153b742418a 100644 --- a/packages/but-sdk/src/generated/index.d.ts +++ b/packages/but-sdk/src/generated/index.d.ts @@ -81,6 +81,45 @@ export declare function listBranchesNapi(projectId: string, filter: BranchListin export declare function listProjectsNapi(openedProjects: Array): Promise> +/** + * Interrupt a task started with [`long_running_start_tsfn()`]. + * + * Returns `true` if interruption was requested successfully. + */ +export declare function longRunningCancelTsfn(taskId: number): boolean + +/** Event payload for Node callbacks. */ +export interface LongRunningEvent { + /** The id of the task that emitted this event. */ + taskId: number + /** The event category. */ + kind: LongRunningEventKind + /** The latest completed step if available. */ + step?: number + /** An optional error message for failed tasks. */ + message?: string +} + +/** Kinds of events emitted to JavaScript while a long-running task executes. */ +export declare const enum LongRunningEventKind { + /** The task produced an intermediate progress update. */ + Progress = 'Progress', + /** The task finished successfully. */ + Done = 'Done', + /** The task stopped because interruption was requested. */ + Cancelled = 'Cancelled', + /** The task failed with an error. */ + Error = 'Error' +} + +/** + * Start a long-running task and stream progress to `callback` via a ThreadsafeFunction. + * + * Returns the task id, which can be used to interrupt processing with + * [`long_running_cancel_tsfn()`]. + */ +export declare function longRunningStartTsfn(durationMs: number, callback: ((err: Error | null, arg: LongRunningEvent) => any)): number + /** * Provide a unified diff for `change`, but fail if `change` is a [type-change](but_core::ModeFlags::TypeChange) * or if it involves a change to a [submodule](gix::object::Kind::Commit). diff --git a/packages/but-sdk/src/generated/index.js b/packages/but-sdk/src/generated/index.js index 5ea9508d69c..ff006357601 100644 --- a/packages/but-sdk/src/generated/index.js +++ b/packages/but-sdk/src/generated/index.js @@ -579,7 +579,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { applyNapi, assignHunkNapi, branchDetailsNapi, branchDiffNapi, changesInWorktreeNapi, commitAmendNapi, commitCreateNapi, commitDetailsWithLineStatsNapi, commitInsertBlankNapi, commitMoveChangesBetweenNapi, commitMoveNapi, commitMoveToBranchNapi, commitRewordNapi, commitUncommitChangesNapi, headInfoNapi, listBranchesNapi, listProjectsNapi, treeChangeDiffsNapi, unapplyStackNapi } = nativeBinding +const { applyNapi, assignHunkNapi, branchDetailsNapi, branchDiffNapi, changesInWorktreeNapi, commitAmendNapi, commitCreateNapi, commitDetailsWithLineStatsNapi, commitInsertBlankNapi, commitMoveChangesBetweenNapi, commitMoveNapi, commitMoveToBranchNapi, commitRewordNapi, commitUncommitChangesNapi, headInfoNapi, listBranchesNapi, listProjectsNapi, longRunningCancelTsfn, LongRunningEventKind, longRunningStartTsfn, treeChangeDiffsNapi, unapplyStackNapi } = nativeBinding export { applyNapi } export { assignHunkNapi } export { branchDetailsNapi } @@ -597,5 +597,8 @@ export { commitUncommitChangesNapi } export { headInfoNapi } export { listBranchesNapi } export { listProjectsNapi } +export { longRunningCancelTsfn } +export { LongRunningEventKind } +export { longRunningStartTsfn } export { treeChangeDiffsNapi } export { unapplyStackNapi } diff --git a/packages/but-sdk/src/test.ts b/packages/but-sdk/src/test.ts index 74b1a181ec9..c3d23d16a13 100644 --- a/packages/but-sdk/src/test.ts +++ b/packages/but-sdk/src/test.ts @@ -1,23 +1,102 @@ /* eslint-disable no-console */ -import { listProjectsNapi, stackDetailsNapi, stacksNapi } from "./generated/index.js"; +import { + longRunningCancelTsfn, + LongRunningEventKind, + longRunningStartTsfn, +} from "./generated/index.js"; +function isNumber(something: unknown): something is number { + return typeof something === "number"; +} + +/** + * So what's the deal here? + * + * We're testing running a long-running process being triggered by JS but not blocking it. + * + * The process emits back events that we can read and react to. + * + * It can be stopped and handled gracefully. + */ +async function runLongRunning({ + durationMs, + signal, + onProgress, +}: { + durationMs: number; + signal?: AbortSignal; + onProgress?: (step: number) => void; +}) { + return await new Promise<{ lastStep: number }>((resolve, reject) => { + let taskId = 0; + let lastStep = 0; + + taskId = longRunningStartTsfn(durationMs, (err, event) => { + if (err) { + reject(err); + return; + } + + if (isNumber(event.step)) { + lastStep = event.step; + } + + if (event.kind === LongRunningEventKind.Progress && isNumber(event.step)) { + onProgress?.(event.step); + return; + } + + if (event.kind === LongRunningEventKind.Done) { + resolve({ lastStep }); + return; + } + + if (event.kind === LongRunningEventKind.Cancelled) { + reject(new Error("yep. interrupted")); + return; + } + + if (event.kind === LongRunningEventKind.Error) { + reject(new Error(event.message ?? "unknown error")); + } + }); + + console.log("start long running process, but don't block"); + + if (signal) { + console.log("there is a signal"); + if (signal.aborted) { + console.log("signal is aborted"); + longRunningCancelTsfn(taskId); + } else { + signal.addEventListener( + "abort", + () => { + console.log("signal abort event triggered"); + longRunningCancelTsfn(taskId); + }, + { once: true }, + ); + } + } + }); +} async function main() { - const projects = await listProjectsNapi([]); - console.log(projects); - - if (projects.length === 0) { - console.log("No projects found"); - } - - const project = projects.at(0); - if (!project) throw new Error("The world is wrong"); - - const stacks = await stacksNapi(project.id, null); - for (const stack of stacks) { - const details = await stackDetailsNapi(project.id, stack.id); - console.log("This are the details for stack with id: " + stack.id); - console.log(details); - } + const abortController = new AbortController(); + setTimeout(() => { + // Abort after a second + console.log("after waiting a second, interrupt."); + abortController.abort(); + }, 1000); + + const result = await runLongRunning({ + durationMs: 5000, + signal: abortController.signal, + onProgress: (step: number) => console.log(`step ${step}`), + }).catch((e) => `probably interrupt error: ${e}`); + + console.log("\nresult"); + console.log(result); } main(); diff --git a/packages/but-sdk/tsconfig.json b/packages/but-sdk/tsconfig.json index 0e2ef652800..0d5710f0307 100644 --- a/packages/but-sdk/tsconfig.json +++ b/packages/but-sdk/tsconfig.json @@ -15,7 +15,7 @@ "declaration": true, "declarationMap": true, "verbatimModuleSyntax": false, - "isolatedModules": true, + "isolatedModules": false, "outDir": "dist", "rootDir": "src" }, From 39f3a0d4cd7a8cf06396c926af2cc401476cfac2 Mon Sep 17 00:00:00 2001 From: estib Date: Thu, 12 Mar 2026 17:27:43 +0100 Subject: [PATCH 4/4] estib: electron-react concept Hook up the creation of long-lasting tasks in the FE --- apps/lite/electron/src/ipc.ts | 20 ++- apps/lite/electron/src/main.ts | 37 +++++ apps/lite/electron/src/model/longRunning.ts | 155 ++++++++++++++++++++ apps/lite/electron/src/preload.cts | 33 ++++- apps/lite/package.json | 5 +- apps/lite/ui/src/hooks.ts | 69 +++++++++ apps/lite/ui/src/main.tsx | 7 +- apps/lite/ui/src/router.tsx | 146 ++++++++++++++++-- pnpm-lock.yaml | 18 +++ 9 files changed, 466 insertions(+), 24 deletions(-) create mode 100644 apps/lite/electron/src/model/longRunning.ts create mode 100644 apps/lite/ui/src/hooks.ts diff --git a/apps/lite/electron/src/ipc.ts b/apps/lite/electron/src/ipc.ts index 399c7f523ea..c84a2c81744 100644 --- a/apps/lite/electron/src/ipc.ts +++ b/apps/lite/electron/src/ipc.ts @@ -1,10 +1,22 @@ import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk"; +export type LongRunningTaskStatus = "running" | "cancelling" | "done" | "cancelled" | "error"; + +export interface LongRunningTaskSnapshot { + taskId: number; + durationMs: number; + step: number; + status: LongRunningTaskStatus; + message?: string; +} + export interface LiteElectronApi { - ping(input: string): Promise; - getVersion(): Promise; listProjects(): Promise; headInfo(projectId: string): Promise; + listLongRunningTasks(): Promise; + startLongRunningTask(durationMs: number): Promise; + cancelLongRunningTask(taskId: number): Promise; + onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void; } export const liteIpcChannels = { @@ -12,4 +24,8 @@ export const liteIpcChannels = { getVersion: "lite:get-version", listProjects: "projects:list", headInfo: "workspace:head-info", + listLongRunningTasks: "long-running:list", + startLongRunningTask: "long-running:start", + cancelLongRunningTask: "long-running:cancel", + longRunningTaskEvent: "long-running:event", } as const; diff --git a/apps/lite/electron/src/main.ts b/apps/lite/electron/src/main.ts index 2027b38b7e7..74acfcf2698 100644 --- a/apps/lite/electron/src/main.ts +++ b/apps/lite/electron/src/main.ts @@ -1,4 +1,10 @@ import { liteIpcChannels } from "#electron/ipc"; +import { + cancelLongRunningTask, + listLongRunningTasks, + startLongRunningTask, + subscribeLongRunningTaskEvents, +} from "#electron/model/longRunning"; import { listProjects } from "#electron/model/projects"; import { headInfo } from "#electron/model/workspace"; import { app, BrowserWindow, ipcMain } from "electron"; @@ -16,6 +22,37 @@ function registerIpcHandlers(): void { ipcMain.handle(liteIpcChannels.getVersion, async (): Promise => { return await Promise.resolve(app.getVersion()); }); + + // Returns all known task snapshots for initial renderer hydration. + ipcMain.handle( + liteIpcChannels.listLongRunningTasks, + async (): Promise> => { + return await Promise.resolve(listLongRunningTasks()); + }, + ); + + // Starts a new non-blocking task in Rust and returns its task id. + ipcMain.handle( + liteIpcChannels.startLongRunningTask, + async (_event, durationMs: number): Promise => { + return await Promise.resolve(startLongRunningTask(durationMs)); + }, + ); + + // Requests cancellation for an existing task id. + ipcMain.handle( + liteIpcChannels.cancelLongRunningTask, + async (_event, taskId: number): Promise => { + return await Promise.resolve(cancelLongRunningTask(taskId)); + }, + ); + + // Pushes incremental task snapshot updates from main to all renderer windows. + subscribeLongRunningTaskEvents((event) => { + for (const browserWindow of BrowserWindow.getAllWindows()) { + browserWindow.webContents.send(liteIpcChannels.longRunningTaskEvent, event); + } + }); } async function createMainWindow(): Promise { diff --git a/apps/lite/electron/src/model/longRunning.ts b/apps/lite/electron/src/model/longRunning.ts new file mode 100644 index 00000000000..af8d9bd6f3d --- /dev/null +++ b/apps/lite/electron/src/model/longRunning.ts @@ -0,0 +1,155 @@ +import { + longRunningCancelTsfn, + LongRunningEventKind, + longRunningStartTsfn, +} from "@gitbutler/but-sdk"; +import type { LongRunningTaskSnapshot } from "#electron/ipc"; + +const MAX_DURATION_MS = 600000; +type LongRunningTaskListener = (event: LongRunningTaskSnapshot) => void; + +const activeTaskIds = new Set(); +const tasks = new Map(); +const listeners = new Set(); + +/** + * Starts a non-blocking task in Rust and tracks task snapshots for renderer consumption. + */ +export function startLongRunningTask(durationMs: number): number { + if (!Number.isInteger(durationMs) || durationMs < 1 || durationMs > MAX_DURATION_MS) { + throw new Error("durationMs must be an integer between 1 and 600000."); + } + + let taskId = 0; + + taskId = longRunningStartTsfn(durationMs, (err, event) => { + const currentSnapshot = tasks.get(taskId); + if (!currentSnapshot) { + return; + } + + if (err) { + activeTaskIds.delete(taskId); + const nextSnapshot: LongRunningTaskSnapshot = { + ...currentSnapshot, + status: "error", + message: err.message ?? "unknown error", + }; + setTaskSnapshot(nextSnapshot); + return; + } + + if (!activeTaskIds.has(taskId)) { + return; + } + + const snapshotWithStep: LongRunningTaskSnapshot = { + ...currentSnapshot, + step: typeof event.step === "number" ? event.step : currentSnapshot.step, + }; + + if (event.kind === LongRunningEventKind.Progress) { + setTaskSnapshot({ + ...snapshotWithStep, + status: "running", + message: undefined, + }); + return; + } + + if (event.kind === LongRunningEventKind.Done) { + activeTaskIds.delete(taskId); + setTaskSnapshot({ + ...snapshotWithStep, + status: "done", + message: undefined, + }); + return; + } + + if (event.kind === LongRunningEventKind.Cancelled) { + activeTaskIds.delete(taskId); + setTaskSnapshot({ + ...snapshotWithStep, + status: "cancelled", + message: undefined, + }); + return; + } + + activeTaskIds.delete(taskId); + setTaskSnapshot({ + ...snapshotWithStep, + status: "error", + message: event.message ?? "unknown error", + }); + }); + + activeTaskIds.add(taskId); + const initialSnapshot: LongRunningTaskSnapshot = { + taskId, + durationMs, + step: 0, + status: "running", + }; + setTaskSnapshot(initialSnapshot); + + return taskId; +} + +/** + * Requests cancellation for a task and marks it as cancelling until the Rust callback emits a terminal state. + */ +export function cancelLongRunningTask(taskId: number): boolean { + if (!activeTaskIds.has(taskId)) { + return false; + } + + const snapshot = tasks.get(taskId); + if (snapshot) { + const nextSnapshot: LongRunningTaskSnapshot = { + ...snapshot, + status: "cancelling", + message: undefined, + }; + setTaskSnapshot(nextSnapshot); + } + + const cancelled = longRunningCancelTsfn(taskId); + if (!cancelled && snapshot) { + const nextSnapshot: LongRunningTaskSnapshot = { + ...snapshot, + status: "error", + message: "Task could not be cancelled (already finished).", + }; + setTaskSnapshot(nextSnapshot); + } + + return cancelled; +} + +/** + * Returns all known task snapshots, including terminal ones, newest first. + */ +export function listLongRunningTasks(): LongRunningTaskSnapshot[] { + return [...tasks.values()].sort((left, right) => right.taskId - left.taskId); +} + +export function subscribeLongRunningTaskEvents(listener: LongRunningTaskListener): () => void { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +function setTaskSnapshot(snapshot: LongRunningTaskSnapshot): void { + tasks.set(snapshot.taskId, snapshot); + emitLongRunningTaskEvent(snapshot); +} + +function emitLongRunningTaskEvent(event: LongRunningTaskSnapshot): void { + for (const listener of listeners) { + listener(event); + } +} diff --git a/apps/lite/electron/src/preload.cts b/apps/lite/electron/src/preload.cts index 4dfb7bac818..52d0298297f 100644 --- a/apps/lite/electron/src/preload.cts +++ b/apps/lite/electron/src/preload.cts @@ -1,20 +1,37 @@ -import { contextBridge, ipcRenderer } from "electron"; -import type { LiteElectronApi } from "#electron/ipc"; +import { + type LiteElectronApi, + type LongRunningTaskSnapshot, +} from "#electron/ipc"; +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk"; const api: LiteElectronApi = { - async ping(input: string): Promise { - return await ipcRenderer.invoke("lite:ping", input); - }, - async getVersion(): Promise { - return await ipcRenderer.invoke("lite:get-version"); - }, async listProjects(): Promise { return await ipcRenderer.invoke("projects:list"); }, async headInfo(projectId: string): Promise { return await ipcRenderer.invoke("workspace:head-info", projectId); }, + async listLongRunningTasks(): Promise { + return await ipcRenderer.invoke("long-running:list"); + }, + async startLongRunningTask(durationMs: number): Promise { + return await ipcRenderer.invoke("long-running:start", durationMs); + }, + async cancelLongRunningTask(taskId: number): Promise { + return await ipcRenderer.invoke("long-running:cancel", taskId); + }, + onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void { + function eventListener(_event: IpcRendererEvent, taskEvent: LongRunningTaskSnapshot): void { + listener(taskEvent); + } + + ipcRenderer.on("long-running:event", eventListener); + + return () => { + ipcRenderer.removeListener("long-running:event", eventListener); + }; + }, }; contextBridge.exposeInMainWorld("lite", api); diff --git a/apps/lite/package.json b/apps/lite/package.json index 8106a3a0b17..a24eb5c38de 100644 --- a/apps/lite/package.json +++ b/apps/lite/package.json @@ -33,10 +33,11 @@ } }, "dependencies": { + "@gitbutler/but-sdk": "workspace:*", + "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.163.2", "react": "^19.2.4", - "react-dom": "^19.2.4", - "@gitbutler/but-sdk": "workspace:*" + "react-dom": "^19.2.4" }, "devDependencies": { "@types/node": "^22.19.11", diff --git a/apps/lite/ui/src/hooks.ts b/apps/lite/ui/src/hooks.ts new file mode 100644 index 00000000000..59a91512dc5 --- /dev/null +++ b/apps/lite/ui/src/hooks.ts @@ -0,0 +1,69 @@ +import { LongRunningTaskSnapshot } from "#electron/ipc"; +import { QueryClient, useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; + +const LONG_RUNNING_TASKS_QUERY_KEY = ["long-running-tasks"] as const; + +/** + * List the tasks and subscribe to updates of it. + */ +export function useTasks(queryClient: QueryClient) { + const tasksQuery = useQuery({ + queryKey: LONG_RUNNING_TASKS_QUERY_KEY, + queryFn: async (): Promise => { + return await window.lite.listLongRunningTasks(); + }, + initialData: [], + }); + + useEffect(() => { + // This unsubscribes on unmount + return window.lite.onLongRunningTaskEvent((event) => { + queryClient.setQueryData( + LONG_RUNNING_TASKS_QUERY_KEY, + (currentTasks: LongRunningTaskSnapshot[] = []) => { + const hasTask = currentTasks.some((task) => task.taskId === event.taskId); + if (!hasTask) { + return sortTasksByIdDesc([event, ...currentTasks]); + } + + return sortTasksByIdDesc( + currentTasks.map((task) => { + if (task.taskId !== event.taskId) { + return task; + } + + return event; + }) + ); + } + ); + }); + }, [queryClient]); + + return tasksQuery; +} + + +/** + * Hook for starting and cancelling tasks. + */ +export function useTaskMutations() { + const startTaskMutation = useMutation({ + mutationFn: async (durationMs: number): Promise => { + return await window.lite.startLongRunningTask(durationMs); + }, + }); + + const cancelTaskMutation = useMutation({ + mutationFn: async (taskId: number): Promise => { + return await window.lite.cancelLongRunningTask(taskId); + }, + }); + return { startTaskMutation, cancelTaskMutation }; +} + + +function sortTasksByIdDesc(tasks: LongRunningTaskSnapshot[]): LongRunningTaskSnapshot[] { + return [...tasks].sort((left, right) => right.taskId - left.taskId); +} \ No newline at end of file diff --git a/apps/lite/ui/src/main.tsx b/apps/lite/ui/src/main.tsx index b68d69754e6..fdf2a34bc7d 100644 --- a/apps/lite/ui/src/main.tsx +++ b/apps/lite/ui/src/main.tsx @@ -1,4 +1,5 @@ import { router } from "@/router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -9,8 +10,12 @@ if (!rootElement) { } const root = createRoot(rootElement); +const queryClient = new QueryClient(); + root.render( - + + + , ); diff --git a/apps/lite/ui/src/router.tsx b/apps/lite/ui/src/router.tsx index 5df6334bd13..3fe0b923b34 100644 --- a/apps/lite/ui/src/router.tsx +++ b/apps/lite/ui/src/router.tsx @@ -1,5 +1,9 @@ import { Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import type { ProjectForFrontend } from "@gitbutler/but-sdk"; +import { useState } from "react"; +import { useTaskMutations, useTasks } from "@/hooks"; + function RootLayout(): React.JSX.Element { return ( @@ -26,11 +30,114 @@ const indexRoute = createRoute({ function HomePage(): React.JSX.Element { const { projects } = indexRoute.useLoaderData(); + const [durationMsInput, setDurationMsInput] = useState("5000"); + const [createError, setCreateError] = useState(null); + const queryClient = useQueryClient(); + + const tasksQuery = useTasks(queryClient); + + const { startTaskMutation, cancelTaskMutation } = useTaskMutations(); + + /** + * Validates the duration input and creates a new long-running task via IPC. + */ + async function handleCreateTask(): Promise { + const durationMs = toDuration(durationMsInput); + if (durationMs === null) { + setCreateError("Duration must be an integer between 1 and 600000 ms."); + return; + } + + setCreateError(null); + + try { + await startTaskMutation.mutateAsync(durationMs); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Failed to start task."); + } + } + + /** + * Requests cancellation for a task by id and refreshes snapshot state for the list. + */ + async function handleCancelTask(taskId: number): Promise { + try { + const cancelled = await cancelTaskMutation.mutateAsync(taskId); + if (!cancelled) { + setCreateError("Task could not be cancelled (already finished)."); + } + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Failed to cancel task."); + } + } + + const tasks = tasksQuery.data; + return (

Electron + Vite + TanStack Router scaffold is ready.

Projects list

+ +

Long-running tasks

+
+ { + setDurationMsInput(event.target.value); + }} + placeholder="Duration in ms" + /> + +
+ + {createError ?

{createError}

: null} + {tasksQuery.isError ?

Failed to load task snapshots.

: null} + + {tasks.length === 0 ? ( +

No tasks started yet.

+ ) : ( +
+ {tasks.map((task) => ( +
+

+ Task #{task.taskId} +

+

Duration: {task.durationMs} ms

+

Step: {task.step}

+

Status: {task.status}

+ {task.message ?

{task.message}

: null} + +
+ ))} +
+ )}
); } @@ -39,20 +146,24 @@ interface ProjectsListProps { projects: ProjectForFrontend[]; } -function ProjectsList(props: ProjectsListProps) { - if (props.projects.length === 0) { - return

no projects :(

; +const routeTree = rootRoute.addChildren([indexRoute]); +function toDuration(durationMsInput: string): number | null { + if (!/^\d+$/.test(durationMsInput)) { + return null; } - return ( -
- {props.projects.map((project) => ( -

{project.title}

- ))} -
- ); + + const durationMs = Number(durationMsInput); + if (!Number.isInteger(durationMs)) { + return null; + } + + if (durationMs < 1 || durationMs > 600000) { + return null; + } + + return durationMs; } -const routeTree = rootRoute.addChildren([indexRoute]); export const router = createRouter({ routeTree }); @@ -61,3 +172,16 @@ declare module "@tanstack/react-router" { router: typeof router; } } + +function ProjectsList(props: ProjectsListProps) { + if (props.projects.length === 0) { + return

no projects :(

; + } + return ( +
+ {props.projects.map((project) => ( +

{project.title}

+ ))} +
+ ); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97e43bd5d1..9bdc7bd1edb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: '@gitbutler/but-sdk': specifier: workspace:* version: link:../../packages/but-sdk + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) '@tanstack/react-router': specifier: ^1.163.2 version: 1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3750,6 +3753,14 @@ packages: resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} engines: {node: '>=20.19'} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-router@1.163.2': resolution: {integrity: sha512-1LosUlpL2mRMWxUZXmkEg5+Br5P5j9TrLngqRgHVbZoFkjnbcj1x9fQN2OVLrBv9Npw97NRsHeJljnAH/c7oSw==} engines: {node: '>=20.19'} @@ -11970,6 +11981,13 @@ snapshots: '@tanstack/history@1.161.4': {} + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + '@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4