From 5ad8eb2295d6a676216de9f14fd6a447d4c1078d Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Sat, 29 Nov 2025 08:27:16 +0900 Subject: [PATCH 1/9] wip --- src/bin/patto-preview.rs | 97 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 1669295..4cb7cba 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -16,11 +16,12 @@ use patto::{ }; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{hash_map::Entry, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use tokio::fs; -use tokio::sync::oneshot; +use std::time::Duration; +use tokio::{fs, sync::oneshot, task::JoinHandle}; +use tokio::time::{sleep, Instant}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ DidChangeTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, InitializeResult, @@ -71,10 +72,20 @@ struct AppState { line_trackers: Arc>>, } +const CONTENT_UPDATE_DEBOUNCE_MS: u64 = 50; +const CONTENT_UPDATE_MAX_WAIT_MS: u64 = 300; + +struct PendingContentUpdate { + latest_text: String, + first_pending_at: Instant, + scheduled_flush: Option>, +} + struct PreviewLspBackend { client: Client, repository: Arc, shutdown_tx: Mutex>>, + pending_updates: Arc>>, } impl PreviewLspBackend { @@ -87,6 +98,7 @@ impl PreviewLspBackend { client, repository, shutdown_tx: Mutex::new(shutdown_tx), + pending_updates: Arc::new(Mutex::new(HashMap::new())), } } @@ -119,8 +131,85 @@ impl PreviewLspBackend { return; } - self.repository.handle_live_file_change(path, text).await; + self.queue_live_content_update(path, text).await; } + + async fn queue_live_content_update(&self, path: PathBuf, text: String) { + let pending_updates = self.pending_updates.clone(); + let repository = self.repository.clone(); + let flush_text = { + let mut pending = pending_updates.lock().unwrap(); + match pending.entry(path.clone()) { + Entry::Vacant(entry) => { + let mut pending_entry = PendingContentUpdate { + latest_text: text.clone(), + first_pending_at: Instant::now(), + scheduled_flush: None, + }; + pending_entry.scheduled_flush = Some(spawn_flush_task( + pending_updates.clone(), + repository.clone(), + path.clone(), + )); + entry.insert(pending_entry); + None + } + Entry::Occupied(mut occupied) => { + let mut flush_now = false; + { + let pending_entry = occupied.get_mut(); + pending_entry.latest_text = text; + + if pending_entry.first_pending_at.elapsed() + >= Duration::from_millis(CONTENT_UPDATE_MAX_WAIT_MS) + { + flush_now = true; + if let Some(handle) = pending_entry.scheduled_flush.take() { + handle.abort(); + } + } else { + if let Some(handle) = pending_entry.scheduled_flush.take() { + handle.abort(); + } + pending_entry.scheduled_flush = Some(spawn_flush_task( + pending_updates.clone(), + repository.clone(), + path.clone(), + )); + } + } + + if flush_now { + Some(occupied.remove().latest_text) + } else { + None + } + } + } + }; + + if let Some(text) = flush_text { + self.repository.handle_live_file_change(path, text).await; + } + } +} + +fn spawn_flush_task( + pending_updates: Arc>>, + repository: Arc, + path: PathBuf, +) -> JoinHandle<()> { + tokio::spawn(async move { + sleep(Duration::from_millis(CONTENT_UPDATE_DEBOUNCE_MS)).await; + let text = { + let mut guard = pending_updates.lock().unwrap(); + guard.remove(&path).map(|entry| entry.latest_text) + }; + + if let Some(text) = text { + repository.handle_live_file_change(path, text).await; + } + }) } #[tower_lsp::async_trait] From e5af951ace8356de905c0f94948d26d459e7c5c3 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Mon, 1 Dec 2025 22:11:01 +0900 Subject: [PATCH 2/9] Extend preview debounce timing and switch to generation-based flush scheduling to avoid redundant live updates --- src/bin/patto-preview.rs | 62 ++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 4cb7cba..e10b0a1 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -20,7 +20,7 @@ use std::collections::{hash_map::Entry, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::{fs, sync::oneshot, task::JoinHandle}; +use tokio::{fs, sync::oneshot}; use tokio::time::{sleep, Instant}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ @@ -72,13 +72,13 @@ struct AppState { line_trackers: Arc>>, } -const CONTENT_UPDATE_DEBOUNCE_MS: u64 = 50; -const CONTENT_UPDATE_MAX_WAIT_MS: u64 = 300; +const CONTENT_UPDATE_DEBOUNCE_MS: u64 = 200; +const CONTENT_UPDATE_MAX_WAIT_MS: u64 = 500; struct PendingContentUpdate { latest_text: String, first_pending_at: Instant, - scheduled_flush: Option>, + generation: u64, } struct PreviewLspBackend { @@ -141,47 +141,44 @@ impl PreviewLspBackend { let mut pending = pending_updates.lock().unwrap(); match pending.entry(path.clone()) { Entry::Vacant(entry) => { - let mut pending_entry = PendingContentUpdate { + entry.insert(PendingContentUpdate { latest_text: text.clone(), first_pending_at: Instant::now(), - scheduled_flush: None, - }; - pending_entry.scheduled_flush = Some(spawn_flush_task( + generation: 0, + }); + schedule_debounce_flush( pending_updates.clone(), repository.clone(), path.clone(), - )); - entry.insert(pending_entry); + 0, + ); None } Entry::Occupied(mut occupied) => { let mut flush_now = false; - { + let scheduled_generation = { let pending_entry = occupied.get_mut(); pending_entry.latest_text = text; + pending_entry.generation = pending_entry.generation.wrapping_add(1); if pending_entry.first_pending_at.elapsed() >= Duration::from_millis(CONTENT_UPDATE_MAX_WAIT_MS) { flush_now = true; - if let Some(handle) = pending_entry.scheduled_flush.take() { - handle.abort(); - } - } else { - if let Some(handle) = pending_entry.scheduled_flush.take() { - handle.abort(); - } - pending_entry.scheduled_flush = Some(spawn_flush_task( - pending_updates.clone(), - repository.clone(), - path.clone(), - )); } - } + + pending_entry.generation + }; if flush_now { Some(occupied.remove().latest_text) } else { + schedule_debounce_flush( + pending_updates.clone(), + repository.clone(), + path.clone(), + scheduled_generation, + ); None } } @@ -194,22 +191,31 @@ impl PreviewLspBackend { } } -fn spawn_flush_task( +fn schedule_debounce_flush( pending_updates: Arc>>, repository: Arc, path: PathBuf, -) -> JoinHandle<()> { + generation: u64, +) { tokio::spawn(async move { sleep(Duration::from_millis(CONTENT_UPDATE_DEBOUNCE_MS)).await; let text = { let mut guard = pending_updates.lock().unwrap(); - guard.remove(&path).map(|entry| entry.latest_text) + if guard + .get(&path) + .map(|entry| entry.generation == generation) + .unwrap_or(false) + { + guard.remove(&path).map(|entry| entry.latest_text) + } else { + None + } }; if let Some(text) = text { repository.handle_live_file_change(path, text).await; } - }) + }); } #[tower_lsp::async_trait] From 03e5d7c28c3dcf293d2a7777e907649c3e7d74f6 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Tue, 16 Dec 2025 10:37:10 +0900 Subject: [PATCH 3/9] debouce preview on the client side as well --- patto-preview-next/src/components/Preview.jsx | 16 ++- patto-preview-next/src/lib/store.js | 130 ++++++++++++++++-- 2 files changed, 134 insertions(+), 12 deletions(-) diff --git a/patto-preview-next/src/components/Preview.jsx b/patto-preview-next/src/components/Preview.jsx index f7ffa3a..17b7547 100644 --- a/patto-preview-next/src/components/Preview.jsx +++ b/patto-preview-next/src/components/Preview.jsx @@ -1,11 +1,12 @@ import parse from 'html-react-parser'; -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useLayoutEffect, useRef } from 'react'; import styles from './Preview.module.css'; import { useHtmlTransformer, escapeInvalidTags } from '../lib/useHtmlTransformer'; import TwoHopLinks from './TwoHopLinks.jsx'; import BackLinks from './BackLinks.jsx'; import 'highlight.js/styles/github.min.css'; import { MathJaxContext, MathJax } from 'better-react-mathjax'; +import { usePattoStore } from '../lib/store'; /** * MathJax configuration for LaTeX rendering @@ -38,6 +39,19 @@ const mathJaxConfig = { export default function Preview({ html, anchor, onSelectFile, currentNote, backLinks, twoHopLinks }) { // Get memoized transform options from hook const transformOptions = useHtmlTransformer(onSelectFile); + const markRenderComplete = usePattoStore(state => state.markRenderComplete); + const prevHtmlRef = useRef(html); + + // Mark render complete after DOM updates (for adaptive throttling) + useLayoutEffect(() => { + if (html && html !== prevHtmlRef.current) { + // Use requestAnimationFrame to measure after paint + requestAnimationFrame(() => { + markRenderComplete(); + }); + prevHtmlRef.current = html; + } + }, [html, markRenderComplete]); /** * Enhanced anchor scrolling with retry mechanism diff --git a/patto-preview-next/src/lib/store.js b/patto-preview-next/src/lib/store.js index ca3aa76..fb3200f 100644 --- a/patto-preview-next/src/lib/store.js +++ b/patto-preview-next/src/lib/store.js @@ -43,6 +43,18 @@ export const ConnectionState = { RECONNECTING: 'reconnecting', }; +/** + * Adaptive throttle state for render-time-aware update batching. + * Tracks render performance and skips intermediate updates when client is slow. + */ +const createAdaptiveThrottle = () => ({ + renderTimeEma: 16, // Exponential moving average of render time (ms), start at ~60fps + lastRenderStart: 0, // Timestamp when last render started + pendingUpdate: null, // Queued update when throttled + throttleTimeout: null, // Timeout for processing pending update + isRendering: false, // Whether we're currently in a render cycle +}); + /** * Zustand store for patto preview application. * Combines data state, UI state, and WebSocket connection management. @@ -71,14 +83,74 @@ export const usePattoStore = create((set, get) => ({ _retryCount: 0, _retryTimeout: null, + // === Adaptive Throttle State === + _throttle: createAdaptiveThrottle(), + // === Actions === + /** + * Mark render start - call this before rendering preview content + */ + markRenderStart: () => { + const { _throttle } = get(); + _throttle.lastRenderStart = performance.now(); + _throttle.isRendering = true; + }, + + /** + * Mark render complete - call this after rendering preview content + * Updates the exponential moving average of render time + */ + markRenderComplete: () => { + const { _throttle } = get(); + if (_throttle.lastRenderStart > 0) { + const renderTime = performance.now() - _throttle.lastRenderStart; + // EMA with alpha=0.3 for smoothing + _throttle.renderTimeEma = 0.3 * renderTime + 0.7 * _throttle.renderTimeEma; + _throttle.isRendering = false; + } + }, + + /** + * Get adaptive throttle delay based on render performance + * Returns delay in ms (1.5x the EMA, bounded 8-500ms) + */ + _getThrottleDelay: () => { + const { _throttle } = get(); + return Math.min(500, Math.max(8, _throttle.renderTimeEma * 1.5)); + }, + + /** + * Process a FILE_CHANGED update (possibly throttled) + */ + _processFileChanged: (data) => { + const { currentNote, _throttle, markRenderStart } = get(); + const isCurrentFile = data.path === currentNote; + + if (isCurrentFile) { + markRenderStart(); + } + + set(state => ({ + previewHtml: isCurrentFile ? (data.html || '') : state.previewHtml, + files: state.files.includes(data.path) + ? state.files + : [...state.files, data.path], + fileMetadata: { + ...state.fileMetadata, + [data.path]: data.metadata, + }, + })); + + _throttle.pendingUpdate = null; + }, + /** * Handle incoming WebSocket messages */ handleMessage: (message) => { const { type, data } = message; - const { currentNote } = get(); + const { currentNote, _throttle, _processFileChanged, _getThrottleDelay } = get(); switch (type) { case MessageTypes.FILE_LIST: @@ -90,16 +162,52 @@ export const usePattoStore = create((set, get) => ({ case MessageTypes.FILE_CHANGED: { const isCurrentFile = data.path === currentNote; - set(state => ({ - previewHtml: isCurrentFile ? (data.html || '') : state.previewHtml, - files: state.files.includes(data.path) - ? state.files - : [...state.files, data.path], - fileMetadata: { - ...state.fileMetadata, - [data.path]: data.metadata, - }, - })); + + // For non-current files, process immediately (cheap update) + if (!isCurrentFile) { + set(state => ({ + files: state.files.includes(data.path) + ? state.files + : [...state.files, data.path], + fileMetadata: { + ...state.fileMetadata, + [data.path]: data.metadata, + }, + })); + break; + } + + // For current file: use adaptive throttling + // If we're still rendering or within throttle window, queue the update + if (_throttle.isRendering || _throttle.throttleTimeout) { + // Replace pending update with latest (drop intermediate updates) + _throttle.pendingUpdate = data; + + // Schedule processing if not already scheduled + if (!_throttle.throttleTimeout) { + const delay = _getThrottleDelay(); + _throttle.throttleTimeout = setTimeout(() => { + _throttle.throttleTimeout = null; + const pending = _throttle.pendingUpdate; + if (pending) { + _processFileChanged(pending); + } + }, delay); + } + } else { + // Process immediately + _processFileChanged(data); + + // Set up throttle window to batch rapid subsequent updates + const delay = _getThrottleDelay(); + _throttle.throttleTimeout = setTimeout(() => { + _throttle.throttleTimeout = null; + const pending = _throttle.pendingUpdate; + if (pending) { + _processFileChanged(pending); + } + }, delay); + } break; } From fbb3e7256afda4cdf863c4db55836b4cd3ad6218 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 18 Dec 2025 15:49:02 +0900 Subject: [PATCH 4/9] fmt --- src/bin/patto-preview.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index e10b0a1..5261714 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -20,8 +20,8 @@ use std::collections::{hash_map::Entry, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::{fs, sync::oneshot}; use tokio::time::{sleep, Instant}; +use tokio::{fs, sync::oneshot}; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{ DidChangeTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, InitializeResult, From 74ef1061f57b7331dbb74acf5724532737a80108 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 18 Dec 2025 19:33:06 +0900 Subject: [PATCH 5/9] feat(preview): implement adaptive debounce based on parse time Add ContentDebouncer that adjusts debounce delay based on previous parse/render time for each file: - Debounce = parse_time * 2.0 (clamped to 5-500ms range) - Default: 20ms for files not yet parsed - Max wait: 2000ms to ensure responsiveness Slower files (larger/complex) get longer debounce to avoid overwhelming the system, while faster files get quicker feedback. The debouncer is shared between LSP backend and WebSocket handler to track parse times across the system. --- src/bin/patto-preview.rs | 228 ++++++++++++++++++++++++--------------- 1 file changed, 139 insertions(+), 89 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 5261714..228cf75 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -70,22 +70,108 @@ struct Args { struct AppState { repository: Arc, line_trackers: Arc>>, + debouncer: Arc>, } -const CONTENT_UPDATE_DEBOUNCE_MS: u64 = 200; -const CONTENT_UPDATE_MAX_WAIT_MS: u64 = 500; +// ============================================================================ +// Content Debouncer - Batches content updates with adaptive timing +// ============================================================================ +// +// Adjusts debounce delay based on previous parse/render time: +// - Slower files get longer debounce to avoid overwhelming the system +// - Faster files get shorter debounce for quicker feedback +// - Always flushes within max_wait to ensure responsiveness + +mod debounce { + use super::*; + + const DEBOUNCE_MIN_MS: u64 = 5; + const DEBOUNCE_MAX_MS: u64 = 500; + const DEBOUNCE_DEFAULT_MS: u64 = 20; + const MAX_WAIT_MS: u64 = 2000; + // Debounce = parse_time * multiplier (clamped to range) + const PARSE_TIME_MULTIPLIER: f64 = 2.0; + + /// Tracks pending content for a single file + struct PendingUpdate { + text: String, + first_pending_at: Instant, + generation: u64, + } + + /// Debounces content updates for multiple files based on parse time + pub struct ContentDebouncer { + pending: HashMap, + last_parse_time_ms: HashMap, + } + + impl ContentDebouncer { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + last_parse_time_ms: HashMap::new(), + } + } + + /// Record how long parsing took for a file (call after parse completes) + pub fn record_parse_time(&mut self, path: &PathBuf, duration_ms: u64) { + self.last_parse_time_ms.insert(path.clone(), duration_ms); + } + + /// Compute debounce time based on last parse duration + fn debounce_for(&self, path: &PathBuf) -> u64 { + self.last_parse_time_ms + .get(path) + .map(|&ms| ((ms as f64 * PARSE_TIME_MULTIPLIER) as u64).clamp(DEBOUNCE_MIN_MS, DEBOUNCE_MAX_MS)) + .unwrap_or(DEBOUNCE_DEFAULT_MS) + } + + /// Queue a content update. Returns (text_to_flush_now, generation, debounce_ms). + pub fn queue(&mut self, path: &PathBuf, text: String) -> (Option, u64, u64) { + let now = Instant::now(); + let debounce_ms = self.debounce_for(path); + + match self.pending.entry(path.clone()) { + Entry::Vacant(entry) => { + entry.insert(PendingUpdate { + text, + first_pending_at: now, + generation: 0, + }); + (None, 0, debounce_ms) + } + Entry::Occupied(mut entry) => { + let pending = entry.get_mut(); + pending.text = text; + pending.generation = pending.generation.wrapping_add(1); + + // Flush immediately if waiting too long + if pending.first_pending_at.elapsed() >= Duration::from_millis(MAX_WAIT_MS) { + let text = entry.remove().text; + (Some(text), 0, 0) + } else { + (None, pending.generation, debounce_ms) + } + } + } + } -struct PendingContentUpdate { - latest_text: String, - first_pending_at: Instant, - generation: u64, + /// Take pending text if generation matches (called after debounce delay). + pub fn take_if_ready(&mut self, path: &PathBuf, generation: u64) -> Option { + if self.pending.get(path).map(|p| p.generation) == Some(generation) { + self.pending.remove(path).map(|p| p.text) + } else { + None + } + } + } } struct PreviewLspBackend { client: Client, repository: Arc, shutdown_tx: Mutex>>, - pending_updates: Arc>>, + debouncer: Arc>, } impl PreviewLspBackend { @@ -93,12 +179,13 @@ impl PreviewLspBackend { client: Client, repository: Arc, shutdown_tx: Option>, + debouncer: Arc>, ) -> Self { Self { client, repository, shutdown_tx: Mutex::new(shutdown_tx), - pending_updates: Arc::new(Mutex::new(HashMap::new())), + debouncer, } } @@ -135,89 +222,28 @@ impl PreviewLspBackend { } async fn queue_live_content_update(&self, path: PathBuf, text: String) { - let pending_updates = self.pending_updates.clone(); + let debouncer = self.debouncer.clone(); let repository = self.repository.clone(); - let flush_text = { - let mut pending = pending_updates.lock().unwrap(); - match pending.entry(path.clone()) { - Entry::Vacant(entry) => { - entry.insert(PendingContentUpdate { - latest_text: text.clone(), - first_pending_at: Instant::now(), - generation: 0, - }); - schedule_debounce_flush( - pending_updates.clone(), - repository.clone(), - path.clone(), - 0, - ); - None - } - Entry::Occupied(mut occupied) => { - let mut flush_now = false; - let scheduled_generation = { - let pending_entry = occupied.get_mut(); - pending_entry.latest_text = text; - pending_entry.generation = pending_entry.generation.wrapping_add(1); - - if pending_entry.first_pending_at.elapsed() - >= Duration::from_millis(CONTENT_UPDATE_MAX_WAIT_MS) - { - flush_now = true; - } - pending_entry.generation - }; + let (flush_now, generation, debounce_ms) = + debouncer.lock().unwrap().queue(&path, text); - if flush_now { - Some(occupied.remove().latest_text) - } else { - schedule_debounce_flush( - pending_updates.clone(), - repository.clone(), - path.clone(), - scheduled_generation, - ); - None - } + if let Some(text) = flush_now { + // Max wait exceeded, flush immediately + repository.handle_live_file_change(path, text).await; + } else { + // Schedule delayed flush + tokio::spawn(async move { + sleep(Duration::from_millis(debounce_ms)).await; + let text = debouncer.lock().unwrap().take_if_ready(&path, generation); + if let Some(text) = text { + repository.handle_live_file_change(path, text).await; } - } - }; - - if let Some(text) = flush_text { - self.repository.handle_live_file_change(path, text).await; + }); } } } -fn schedule_debounce_flush( - pending_updates: Arc>>, - repository: Arc, - path: PathBuf, - generation: u64, -) { - tokio::spawn(async move { - sleep(Duration::from_millis(CONTENT_UPDATE_DEBOUNCE_MS)).await; - let text = { - let mut guard = pending_updates.lock().unwrap(); - if guard - .get(&path) - .map(|entry| entry.generation == generation) - .unwrap_or(false) - { - guard.remove(&path).map(|entry| entry.latest_text) - } else { - None - } - }; - - if let Some(text) = text { - repository.handle_live_file_change(path, text).await; - } - }); -} - #[tower_lsp::async_trait] impl LanguageServer for PreviewLspBackend { async fn initialize(&self, _: InitializeParams) -> LspResult { @@ -263,7 +289,11 @@ impl LanguageServer for PreviewLspBackend { } } -async fn start_preview_lsp_server(repository: Arc, port: u16) -> std::io::Result<()> { +async fn start_preview_lsp_server( + repository: Arc, + port: u16, + debouncer: Arc>, +) -> std::io::Result<()> { let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; eprintln!("Preview LSP server listening on 127.0.0.1:{}", port); @@ -272,10 +302,11 @@ async fn start_preview_lsp_server(repository: Arc, port: u16) -> std match listener.accept().await { Ok((stream, addr)) => { let repo = repository.clone(); + let debouncer = debouncer.clone(); tokio::spawn(async move { let (reader, writer) = tokio::io::split(stream); let (service, socket) = LspService::new(|client| { - PreviewLspBackend::new(client, repo.clone(), None) + PreviewLspBackend::new(client, repo.clone(), None, debouncer.clone()) }); Server::new(reader, writer, socket).serve(service).await; eprintln!("Preview LSP connection {} closed", addr); @@ -291,7 +322,10 @@ async fn start_preview_lsp_server(repository: Arc, port: u16) -> std Ok(()) } -fn start_preview_lsp_stdio(repository: Arc) -> oneshot::Receiver<()> { +fn start_preview_lsp_stdio( + repository: Arc, + debouncer: Arc>, +) -> oneshot::Receiver<()> { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let (tx, rx) = oneshot::channel(); @@ -307,7 +341,7 @@ fn start_preview_lsp_stdio(repository: Arc) -> oneshot::Receiver<()> tokio::spawn(async move { let (service, socket) = LspService::new(move |client| { let sender = shutdown_tx.lock().unwrap().take(); - PreviewLspBackend::new(client, repository, sender) + PreviewLspBackend::new(client, repository.clone(), sender, debouncer.clone()) }); Server::new(stdin, stdout, socket).serve(service).await; if let Some(tx) = shutdown_tx_server.lock().unwrap().take() { @@ -441,11 +475,15 @@ async fn main() { std::process::exit(1); } + // Create shared debouncer for adaptive timing based on parse speed + let debouncer = Arc::new(Mutex::new(debounce::ContentDebouncer::new())); + // Create repository and app state let repository = Arc::new(Repository::new(dir.clone())); let state = AppState { repository: repository.clone(), line_trackers: Arc::new(Mutex::new(HashMap::new())), + debouncer: debouncer.clone(), }; // Start file watcher in a separate task @@ -458,9 +496,9 @@ async fn main() { let mut shutdown_signal = None; if args.preview_lsp_stdio { - shutdown_signal = Some(start_preview_lsp_stdio(repository.clone())); + shutdown_signal = Some(start_preview_lsp_stdio(repository.clone(), debouncer.clone())); } else if let Some(lsp_port) = args.preview_lsp_port { - if let Err(e) = start_preview_lsp_server(repository.clone(), lsp_port).await { + if let Err(e) = start_preview_lsp_server(repository.clone(), lsp_port, debouncer.clone()).await { eprintln!("Failed to start preview LSP server: {}", e); } } @@ -956,8 +994,12 @@ async fn render_patto_to_html( // Get or create line tracker for this file let line_trackers = Arc::clone(&state.line_trackers); + let debouncer = Arc::clone(&state.debouncer); + let file_path_for_debounce = file_path_buf.clone(); let html_output = tokio::task::spawn_blocking(move || { + let start = Instant::now(); + // Get or create line tracker for this file let mut trackers = line_trackers.lock().unwrap(); let line_tracker = trackers.entry(file_path_buf.clone()).or_insert_with(|| { @@ -975,6 +1017,14 @@ async fn render_patto_to_html( let renderer = HtmlRenderer::new(HtmlRendererOptions {}); let _ = renderer.format(&result.ast, &mut html_output); + + // Record parse time for adaptive debounce + let parse_time_ms = start.elapsed().as_millis() as u64; + debouncer + .lock() + .unwrap() + .record_parse_time(&file_path_for_debounce, parse_time_ms); + html_output }) .await; From 8f38506b69285c50a3cb77eb7e2e562eeae5a7b0 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 18 Dec 2025 20:29:21 +0900 Subject: [PATCH 6/9] perf(preview): use lightweight updates during live editing Add handle_live_file_change_lightweight() that only broadcasts content without recalculating backlinks and two-hop links. This significantly reduces CPU overhead for large files (~9000 lines) during rapid typing. The full link calculation (handle_live_file_change) is still available for use after idle periods or on file save. --- src/bin/patto-preview.rs | 8 ++++---- src/repository.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 228cf75..51ddf46 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -229,15 +229,15 @@ impl PreviewLspBackend { debouncer.lock().unwrap().queue(&path, text); if let Some(text) = flush_now { - // Max wait exceeded, flush immediately - repository.handle_live_file_change(path, text).await; + // Max wait exceeded, flush immediately with lightweight update + repository.handle_live_file_change_lightweight(path, text); } else { - // Schedule delayed flush + // Schedule delayed flush with lightweight update tokio::spawn(async move { sleep(Duration::from_millis(debounce_ms)).await; let text = debouncer.lock().unwrap().take_if_ready(&path, generation); if let Some(text) = text { - repository.handle_live_file_change(path, text).await; + repository.handle_live_file_change_lightweight(path, text); } }); } diff --git a/src/repository.rs b/src/repository.rs index 47dc93c..17cfb4d 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -628,6 +628,32 @@ impl Repository { .send(RepositoryMessage::TwoHopLinksChanged(path, two_hop_links)); } + /// Lightweight live file change - only broadcasts content without recalculating links. + /// Use this for rapid typing; call handle_live_file_change after idle for full update. + pub fn handle_live_file_change_lightweight(&self, path: PathBuf, content: String) { + if !path.starts_with(&self.root_dir) { + return; + } + + let metadata = self.collect_file_metadata(&path).unwrap_or_else(|_| { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + FileMetadata { + modified: now, + created: now, + link_count: 0, + } + }); + + let _ = self.tx.send(RepositoryMessage::FileChanged( + path, + metadata, + content, + )); + } + /// Start filesystem watcher for the repository pub async fn start_watcher(&self) -> Result<(), Box> { let (tx, mut rx) = mpsc::channel(100); From 0879cdcc9e802e8daa0d31e56e38171e4a09b98d Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 18 Dec 2025 20:32:41 +0900 Subject: [PATCH 7/9] fmt --- src/bin/patto-preview.rs | 17 ++++++++++++----- src/repository.rs | 8 +++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 51ddf46..a22e6b3 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -122,7 +122,10 @@ mod debounce { fn debounce_for(&self, path: &PathBuf) -> u64 { self.last_parse_time_ms .get(path) - .map(|&ms| ((ms as f64 * PARSE_TIME_MULTIPLIER) as u64).clamp(DEBOUNCE_MIN_MS, DEBOUNCE_MAX_MS)) + .map(|&ms| { + ((ms as f64 * PARSE_TIME_MULTIPLIER) as u64) + .clamp(DEBOUNCE_MIN_MS, DEBOUNCE_MAX_MS) + }) .unwrap_or(DEBOUNCE_DEFAULT_MS) } @@ -225,8 +228,7 @@ impl PreviewLspBackend { let debouncer = self.debouncer.clone(); let repository = self.repository.clone(); - let (flush_now, generation, debounce_ms) = - debouncer.lock().unwrap().queue(&path, text); + let (flush_now, generation, debounce_ms) = debouncer.lock().unwrap().queue(&path, text); if let Some(text) = flush_now { // Max wait exceeded, flush immediately with lightweight update @@ -496,9 +498,14 @@ async fn main() { let mut shutdown_signal = None; if args.preview_lsp_stdio { - shutdown_signal = Some(start_preview_lsp_stdio(repository.clone(), debouncer.clone())); + shutdown_signal = Some(start_preview_lsp_stdio( + repository.clone(), + debouncer.clone(), + )); } else if let Some(lsp_port) = args.preview_lsp_port { - if let Err(e) = start_preview_lsp_server(repository.clone(), lsp_port, debouncer.clone()).await { + if let Err(e) = + start_preview_lsp_server(repository.clone(), lsp_port, debouncer.clone()).await + { eprintln!("Failed to start preview LSP server: {}", e); } } diff --git a/src/repository.rs b/src/repository.rs index 17cfb4d..9f96cc5 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -647,11 +647,9 @@ impl Repository { } }); - let _ = self.tx.send(RepositoryMessage::FileChanged( - path, - metadata, - content, - )); + let _ = self + .tx + .send(RepositoryMessage::FileChanged(path, metadata, content)); } /// Start filesystem watcher for the repository From 8bd2fde09e01be1ad313dc7a73e93ed97f5f1494 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Fri, 19 Dec 2025 10:00:38 +0900 Subject: [PATCH 8/9] Fix debounce logic for more fluent update in preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - First keystroke → flush immediately, update last_flush_at - Subsequent keystrokes within debounce period → update pending text, schedule ONE timer (only on first pending) - Timer fires → flush latest text (whatever was last typed), clear pending - Next keystroke after timer fires → if debounce period passed, flush immediately again --- src/bin/patto-preview.rs | 90 +++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index a22e6b3..6f050ad 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -85,24 +85,24 @@ struct AppState { mod debounce { use super::*; - const DEBOUNCE_MIN_MS: u64 = 5; + const DEBOUNCE_MIN_MS: u64 = 50; const DEBOUNCE_MAX_MS: u64 = 500; - const DEBOUNCE_DEFAULT_MS: u64 = 20; - const MAX_WAIT_MS: u64 = 2000; + const DEBOUNCE_DEFAULT_MS: u64 = 100; // Debounce = parse_time * multiplier (clamped to range) const PARSE_TIME_MULTIPLIER: f64 = 2.0; /// Tracks pending content for a single file struct PendingUpdate { text: String, - first_pending_at: Instant, - generation: u64, + /// Whether a timer is already scheduled for this pending update + timer_scheduled: bool, } /// Debounces content updates for multiple files based on parse time pub struct ContentDebouncer { pending: HashMap, last_parse_time_ms: HashMap, + last_flush_at: HashMap, } impl ContentDebouncer { @@ -110,6 +110,7 @@ mod debounce { Self { pending: HashMap::new(), last_parse_time_ms: HashMap::new(), + last_flush_at: HashMap::new(), } } @@ -129,40 +130,54 @@ mod debounce { .unwrap_or(DEBOUNCE_DEFAULT_MS) } - /// Queue a content update. Returns (text_to_flush_now, generation, debounce_ms). - pub fn queue(&mut self, path: &PathBuf, text: String) -> (Option, u64, u64) { + /// Queue a content update. Returns (text_to_flush_now, should_schedule_timer, debounce_ms). + /// Flushes immediately if debounce period has passed since last flush, otherwise debounces. + pub fn queue(&mut self, path: &PathBuf, text: String) -> (Option, bool, u64) { let now = Instant::now(); let debounce_ms = self.debounce_for(path); - match self.pending.entry(path.clone()) { - Entry::Vacant(entry) => { - entry.insert(PendingUpdate { - text, - first_pending_at: now, - generation: 0, - }); - (None, 0, debounce_ms) - } - Entry::Occupied(mut entry) => { - let pending = entry.get_mut(); - pending.text = text; - pending.generation = pending.generation.wrapping_add(1); - - // Flush immediately if waiting too long - if pending.first_pending_at.elapsed() >= Duration::from_millis(MAX_WAIT_MS) { - let text = entry.remove().text; - (Some(text), 0, 0) - } else { - (None, pending.generation, debounce_ms) + // Check if enough time passed since last flush - if so, flush immediately + let should_flush_now = self + .last_flush_at + .get(path) + .map(|&t| now.duration_since(t) >= Duration::from_millis(debounce_ms)) + .unwrap_or(true); // No previous flush = flush now + + if should_flush_now { + self.last_flush_at.insert(path.clone(), now); + self.pending.remove(path); // Clear any pending + (Some(text), false, debounce_ms) + } else { + // Update pending text, only schedule timer if not already scheduled + let should_schedule = match self.pending.entry(path.clone()) { + Entry::Vacant(entry) => { + entry.insert(PendingUpdate { + text, + timer_scheduled: true, + }); + true } - } + Entry::Occupied(mut entry) => { + let pending = entry.get_mut(); + pending.text = text; + // Don't schedule another timer if one is already pending + if pending.timer_scheduled { + false + } else { + pending.timer_scheduled = true; + true + } + } + }; + (None, should_schedule, debounce_ms) } } - /// Take pending text if generation matches (called after debounce delay). - pub fn take_if_ready(&mut self, path: &PathBuf, generation: u64) -> Option { - if self.pending.get(path).map(|p| p.generation) == Some(generation) { - self.pending.remove(path).map(|p| p.text) + /// Take pending text (called after debounce delay). Always takes latest. + pub fn take_pending(&mut self, path: &PathBuf) -> Option { + if let Some(pending) = self.pending.remove(path) { + self.last_flush_at.insert(path.clone(), Instant::now()); + Some(pending.text) } else { None } @@ -228,21 +243,22 @@ impl PreviewLspBackend { let debouncer = self.debouncer.clone(); let repository = self.repository.clone(); - let (flush_now, generation, debounce_ms) = debouncer.lock().unwrap().queue(&path, text); + let (flush_now, should_schedule, debounce_ms) = debouncer.lock().unwrap().queue(&path, text); if let Some(text) = flush_now { - // Max wait exceeded, flush immediately with lightweight update + // Immediate flush - enough time passed since last flush repository.handle_live_file_change_lightweight(path, text); - } else { - // Schedule delayed flush with lightweight update + } else if should_schedule { + // Schedule delayed flush (only one timer per batch) tokio::spawn(async move { sleep(Duration::from_millis(debounce_ms)).await; - let text = debouncer.lock().unwrap().take_if_ready(&path, generation); + let text = debouncer.lock().unwrap().take_pending(&path); if let Some(text) = text { repository.handle_live_file_change_lightweight(path, text); } }); } + // else: timer already scheduled, it will pick up the latest text } } From 2529d1d42771f7db3f538ab1534c4aaa9aa4b99b Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Mon, 5 Jan 2026 19:35:14 +0900 Subject: [PATCH 9/9] fmt --- src/bin/patto-preview.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 6f050ad..e0f2442 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -243,7 +243,8 @@ impl PreviewLspBackend { let debouncer = self.debouncer.clone(); let repository = self.repository.clone(); - let (flush_now, should_schedule, debounce_ms) = debouncer.lock().unwrap().queue(&path, text); + let (flush_now, should_schedule, debounce_ms) = + debouncer.lock().unwrap().queue(&path, text); if let Some(text) = flush_now { // Immediate flush - enough time passed since last flush