@@ -2736,6 +2736,93 @@ pub fn emit_app_state(app: &AppHandle) {
27362736 persist_debouncer ( ) . schedule ( snapshot) ;
27372737}
27382738
2739+ /// Coalesce many emits into one. The first call schedules a worker
2740+ /// thread that sleeps `EMIT_COALESCE_MS` then invokes
2741+ /// `emit_app_state` with the *current* AppStateStore — so any state
2742+ /// mutations that happen during the window are picked up by the
2743+ /// single eventual emit. Subsequent calls during the window are
2744+ /// no-ops at the emit boundary; the snapshot is read fresh from the
2745+ /// store at flush time, not from any cached value.
2746+ ///
2747+ /// Use this for high-frequency background sources (5 s git poll, PR /
2748+ /// port refresh, agent runtime hooks, control protocol bookkeeping)
2749+ /// where multiple emits can pile up within a single frame and the
2750+ /// frontend would otherwise pay the JSON-serialise cost N times for
2751+ /// what coalesces into one re-render anyway (the renderer already
2752+ /// debounces 16 ms in `use-app-state.ts`).
2753+ ///
2754+ /// Do NOT use for user-action paths (split, swap, activate, tab
2755+ /// switch, command palette) — those should fire `emit_app_state`
2756+ /// directly so the UI updates without any added latency.
2757+ ///
2758+ /// Mirrors the `PersistDebouncer` shape but with a 16 ms quiet
2759+ /// window (one frame) and an emit instead of a disk write. Stores
2760+ /// the latest AppHandle clone so the worker can call back into the
2761+ /// Tauri runtime once the timer elapses.
2762+ pub fn schedule_emit_app_state ( app : & AppHandle ) {
2763+ emit_debouncer ( ) . schedule ( app) ;
2764+ }
2765+
2766+ const EMIT_COALESCE_MS : u64 = 16 ;
2767+
2768+ struct EmitDebouncer {
2769+ pending : Arc < AtomicBool > ,
2770+ app : Arc < Mutex < Option < AppHandle > > > ,
2771+ }
2772+
2773+ impl EmitDebouncer {
2774+ fn new ( ) -> Self {
2775+ Self {
2776+ pending : Arc :: new ( AtomicBool :: new ( false ) ) ,
2777+ app : Arc :: new ( Mutex :: new ( None ) ) ,
2778+ }
2779+ }
2780+
2781+ /// Stash the AppHandle (always overwriting — the latest clone is
2782+ /// what the worker will use) and, if no worker is in flight,
2783+ /// spawn one that sleeps then emits.
2784+ fn schedule ( & self , app : & AppHandle ) {
2785+ {
2786+ let mut guard = self . app . lock ( ) . unwrap ( ) ;
2787+ * guard = Some ( app. clone ( ) ) ;
2788+ }
2789+
2790+ // If a worker is already counting down, nothing more to do.
2791+ // It will pick up the latest `app` reference at flush time.
2792+ if self . pending . swap ( true , Ordering :: AcqRel ) {
2793+ return ;
2794+ }
2795+
2796+ let pending = Arc :: clone ( & self . pending ) ;
2797+ let app_slot = Arc :: clone ( & self . app ) ;
2798+
2799+ std:: thread:: spawn ( move || {
2800+ std:: thread:: sleep ( Duration :: from_millis ( EMIT_COALESCE_MS ) ) ;
2801+
2802+ // Take the AppHandle and clear the pending flag while
2803+ // still holding the mutex. This ensures no second worker
2804+ // can slip through the pending.swap guard between the
2805+ // flag clear and the emit. Any schedule() arriving AFTER
2806+ // this point spawns a fresh worker for the next window.
2807+ let app = {
2808+ let mut guard = app_slot. lock ( ) . unwrap ( ) ;
2809+ pending. store ( false , Ordering :: Release ) ;
2810+ guard. take ( )
2811+ } ;
2812+
2813+ if let Some ( app) = app {
2814+ emit_app_state ( & app) ;
2815+ }
2816+ } ) ;
2817+ }
2818+ }
2819+
2820+ static EMIT_DEBOUNCER : std:: sync:: OnceLock < EmitDebouncer > = std:: sync:: OnceLock :: new ( ) ;
2821+
2822+ fn emit_debouncer ( ) -> & ' static EmitDebouncer {
2823+ EMIT_DEBOUNCER . get_or_init ( EmitDebouncer :: new)
2824+ }
2825+
27392826pub fn load_persisted_state ( ) -> Option < AppStateSnapshot > {
27402827 let path = persisted_layout_path ( ) ?;
27412828 let contents = fs:: read_to_string ( path) . ok ( ) ?;
0 commit comments