From fc7600c93a02bc2e392622f13f96db5ea047698e Mon Sep 17 00:00:00 2001 From: Quorafind Date: Sun, 19 Oct 2025 16:39:41 +0800 Subject: [PATCH] feat(bulk-operations): optimize view updates to prevent list flashing - Add BATCH_OPERATION_START/COMPLETE events - Pause view updates during batch operations - Refresh view once after batch operation completes - Add payload type definitions for type safety --- .../fluent/managers/FluentDataManager.ts | 42 ++++++++++++ src/components/features/task/view/content.ts | 64 ++++++++--------- src/dataflow/api/WriteAPI.ts | 45 +++++++----- src/dataflow/events/Events.ts | 17 +++++ src/pages/FluentTaskView.ts | 68 +++++++++---------- 5 files changed, 153 insertions(+), 83 deletions(-) diff --git a/src/components/features/fluent/managers/FluentDataManager.ts b/src/components/features/fluent/managers/FluentDataManager.ts index 76731451..8ccd4dd9 100644 --- a/src/components/features/fluent/managers/FluentDataManager.ts +++ b/src/components/features/fluent/managers/FluentDataManager.ts @@ -24,6 +24,9 @@ export class FluentDataManager extends Component { private onLoadingStateChanged?: (isLoading: boolean) => void; private onUpdateNeeded?: (source: string) => void; + // Batch operation state flag + private isBatchOperating = false; + constructor( private plugin: TaskProgressBarPlugin, private getCurrentViewId: () => string, @@ -269,6 +272,15 @@ export class FluentDataManager extends Component { // Add debounced view update to prevent rapid successive refreshes const debouncedViewUpdate = debounce(async () => { console.log("[FluentData] debouncedViewUpdate triggered"); + + // Skip update during batch operation to prevent list flashing + if (this.isBatchOperating) { + console.log( + "[FluentData] Skipping update during batch operation", + ); + return; + } + if (!this.isInitializing()) { // Load tasks and notify parent await this.loadTasks(false); @@ -289,6 +301,36 @@ export class FluentDataManager extends Component { isDataflowEnabled(this.plugin) && this.plugin.dataflowOrchestrator ) { + // Listen for batch operation start + this.registerEvent( + on(this.plugin.app, Events.BATCH_OPERATION_START, (payload) => { + console.log( + `[FluentData] Batch operation started: ${payload.count} tasks`, + ); + this.isBatchOperating = true; + }), + ); + + // Listen for batch operation complete + this.registerEvent( + on( + this.plugin.app, + Events.BATCH_OPERATION_COMPLETE, + async (payload) => { + console.log( + `[FluentData] Batch operation complete: ${payload.successCount} succeeded, ${payload.failCount} failed`, + ); + this.isBatchOperating = false; + + // Immediately refresh view after batch operation (skip debounce) + if (!this.isInitializing()) { + await this.loadTasks(false); + this.onUpdateNeeded?.("batch-operation-complete"); + } + }, + ), + ); + // Listen for cache ready event this.registerEvent( on(this.plugin.app, Events.CACHE_READY, async () => { diff --git a/src/components/features/task/view/content.ts b/src/components/features/task/view/content.ts index 5d070127..faebd6cb 100644 --- a/src/components/features/task/view/content.ts +++ b/src/components/features/task/view/content.ts @@ -55,6 +55,8 @@ export class ContentComponent extends Component { private pendingForceRefresh: boolean = false; // Track if a force refresh is pending private pendingVisibilityRetry: boolean = false; // Queue a retry when container becomes visible private visibilityRetryCount: number = 0; // Limit visibility retry loop + private lastAllTasksSignature = ""; + private lastNotFilteredTasksSignature = ""; constructor( private parentEl: HTMLElement, private app: App, @@ -198,6 +200,12 @@ export class ContentComponent extends Component { notFilteredTasks: Task[], forceRefresh: boolean = false, ) { + const updateSignatures = () => { + this.lastAllTasksSignature = this.computeTaskSignature(tasks); + this.lastNotFilteredTasksSignature = + this.computeTaskSignature(notFilteredTasks); + }; + // Allow forced refresh for cases where we know the data has changed if (forceRefresh) { console.log("ContentComponent: Forced refresh requested"); @@ -206,6 +214,7 @@ export class ContentComponent extends Component { this.pendingForceRefresh = true; this.allTasks = tasks; this.notFilteredTasks = notFilteredTasks; + updateSignatures(); this.applyFilters(); this.refreshTaskList(); return; @@ -219,47 +228,23 @@ export class ContentComponent extends Component { return; } - // Prevent unnecessary refreshes if data hasn't actually changed - // Check if the array reference has changed (which indicates an update) + const nextAllSignature = this.computeTaskSignature(tasks); + const nextNotFilteredSignature = + this.computeTaskSignature(notFilteredTasks); if ( - this.allTasks === tasks && - this.notFilteredTasks === notFilteredTasks + nextAllSignature === this.lastAllTasksSignature && + nextNotFilteredSignature === this.lastNotFilteredTasksSignature ) { console.log( - "ContentComponent: Same array references, skipping refresh", + "ContentComponent: Task signatures unchanged, skipping refresh", ); return; } - // Additional check for actual content changes - if ( - this.allTasks.length === tasks.length && - this.notFilteredTasks.length === notFilteredTasks.length && - tasks.length > 0 - ) { - // Quick check - if same length and not empty, check if first few tasks are identical - const sampleSize = Math.min(5, tasks.length); - let unchanged = true; - for (let i = 0; i < sampleSize; i++) { - if ( - this.allTasks[i]?.id !== tasks[i]?.id || - this.allTasks[i]?.originalMarkdown !== - tasks[i]?.originalMarkdown - ) { - unchanged = false; - break; - } - } - if (unchanged) { - console.log( - "ContentComponent: Tasks unchanged, skipping refresh", - ); - return; - } - } - this.allTasks = tasks; this.notFilteredTasks = notFilteredTasks; + this.lastAllTasksSignature = nextAllSignature; + this.lastNotFilteredTasksSignature = nextNotFilteredSignature; this.applyFilters(); this.refreshTaskList(); } @@ -350,6 +335,21 @@ export class ContentComponent extends Component { this.refreshTaskList(); } + private computeTaskSignature(tasks: Task[]): string { + if (!tasks || tasks.length === 0) return ""; + return tasks + .map((task) => + [ + task.id, + task.completed ? "1" : "0", + task.originalMarkdown ?? "", + task.content ?? "", + task.metadata ? JSON.stringify(task.metadata) : "", + ].join("|"), + ) + .join(";"); + } + private cleanupComponents() { // Unload and clear previous components this.taskComponents.forEach((component) => this.removeChild(component)); diff --git a/src/dataflow/api/WriteAPI.ts b/src/dataflow/api/WriteAPI.ts index d97ac504..f508b164 100644 --- a/src/dataflow/api/WriteAPI.ts +++ b/src/dataflow/api/WriteAPI.ts @@ -814,6 +814,11 @@ export class WriteAPI { argsList: UpdateTaskArgs[], ): Promise { return this.enqueueWrite(async () => { + // Emit batch operation start event + emit(this.app, Events.BATCH_OPERATION_START, { + count: argsList.length, + }); + const summary: BulkOperationResult = { successCount: 0, failCount: 0, @@ -821,24 +826,32 @@ export class WriteAPI { totalCount: argsList.length, }; - for (const args of argsList) { - const originalTask = await Promise.resolve( - this.getTaskById(args.taskId), - ); - const updateResult = await this.performUpdateTask(args); - if (updateResult.success) { - summary.successCount++; - } else { - summary.failCount++; - summary.errors.push({ - taskId: args.taskId, - taskContent: originalTask?.content || "", - error: updateResult.error || "Unknown error", - }); + try { + for (const args of argsList) { + const originalTask = await Promise.resolve( + this.getTaskById(args.taskId), + ); + const updateResult = await this.performUpdateTask(args); + if (updateResult.success) { + summary.successCount++; + } else { + summary.failCount++; + summary.errors.push({ + taskId: args.taskId, + taskContent: originalTask?.content || "", + error: updateResult.error || "Unknown error", + }); + } } - } - return summary; + return summary; + } finally { + // Emit batch operation complete event (always, even if error occurred) + emit(this.app, Events.BATCH_OPERATION_COMPLETE, { + successCount: summary.successCount, + failCount: summary.failCount, + }); + } }); } diff --git a/src/dataflow/events/Events.ts b/src/dataflow/events/Events.ts index a92f3250..d6265d0f 100644 --- a/src/dataflow/events/Events.ts +++ b/src/dataflow/events/Events.ts @@ -16,8 +16,20 @@ export const Events = { ICS_EVENTS_UPDATED: "task-genius:ics-events-updated", FILE_TASK_UPDATED: "task-genius:file-task-updated", FILE_TASK_REMOVED: "task-genius:file-task-removed", + BATCH_OPERATION_START: "task-genius:batch-operation-start", + BATCH_OPERATION_COMPLETE: "task-genius:batch-operation-complete", } as const; +// Batch operation payload types +export interface BatchOperationStartPayload { + count: number; +} + +export interface BatchOperationCompletePayload { + successCount: number; + failCount: number; +} + export type SeqClock = { next(): number }; let _seq = 0; @@ -45,3 +57,8 @@ export const onTaskCacheUpdated = (app: App, handler: (payload: any) => void) => export const emitTaskCacheUpdated = (app: App, payload: any) => emit(app, Events.TASK_CACHE_UPDATED, payload); +export const emitBatchOperationStart = (app: App, payload: BatchOperationStartPayload) => + emit(app, Events.BATCH_OPERATION_START, payload); +export const emitBatchOperationComplete = (app: App, payload: BatchOperationCompletePayload) => + emit(app, Events.BATCH_OPERATION_COMPLETE, payload); + diff --git a/src/pages/FluentTaskView.ts b/src/pages/FluentTaskView.ts index e1c155a9..81c31614 100644 --- a/src/pages/FluentTaskView.ts +++ b/src/pages/FluentTaskView.ts @@ -139,7 +139,7 @@ export class FluentTaskView extends ItemView { * Main initialization method */ async onOpen() { - console.log("[TG-V2] onOpen started"); + console.log("[TG] onOpen started"); this.isInitializing = true; try { @@ -148,7 +148,7 @@ export class FluentTaskView extends ItemView { // ==================== if (this.DEBUG_MODE) { console.log( - "[TG-V2] Initializing UI, managers, structure, and events...", + "[TG] Initializing UI, managers, structure, and events...", ); } @@ -179,7 +179,7 @@ export class FluentTaskView extends ItemView { if (this.DEBUG_MODE) { console.log( - "[TG-V2] ✅ UI, managers, structure, and events initialized", + "[TG] ✅ UI, managers, structure, and events initialized", ); } @@ -187,7 +187,7 @@ export class FluentTaskView extends ItemView { // PHASE 5: Restore Workspace State // ==================== if (this.DEBUG_MODE) { - console.log("[TG-V2] Restoring workspace state..."); + console.log("[TG] Restoring workspace state..."); } const savedWorkspaceId = @@ -197,7 +197,7 @@ export class FluentTaskView extends ItemView { this.viewState.currentWorkspace = savedWorkspaceId; if (this.DEBUG_MODE) { console.log( - `[TG-V2] Restored workspace ID: ${savedWorkspaceId}`, + `[TG] Restored workspace ID: ${savedWorkspaceId}`, ); } } @@ -239,11 +239,11 @@ export class FluentTaskView extends ItemView { // ==================== // PHASE 6: Load Data (KEY PHASE) // ==================== - console.log("[TG-V2] Loading tasks..."); + console.log("[TG] Loading tasks..."); await this.dataManager.loadTasks(false); // Will trigger onTasksLoaded callback await this.dataManager.registerDataflowListeners(); console.log( - `[TG-V2] ✅ Loaded ${this.tasks.length} tasks, ${this.filteredTasks.length} after filters`, + `[TG] ✅ Loaded ${this.tasks.length} tasks, ${this.filteredTasks.length} after filters`, ); // ==================== @@ -254,12 +254,12 @@ export class FluentTaskView extends ItemView { // Check window size and auto-collapse sidebar if needed if (this.DEBUG_MODE) { - console.log("[TG-V2] Checking sidebar collapse..."); + console.log("[TG] Checking sidebar collapse..."); } this.layoutManager.checkAndCollapseSidebar(); } catch (error) { - console.error("[TG-V2] ❌ Initialization error:", error); - console.error("[TG-V2] Error stack:", (error as Error).stack); + console.error("[TG] ❌ Initialization error:", error); + console.error("[TG] Error stack:", (error as Error).stack); this.loadError = (error as Error).message || "Failed to initialize view"; } finally { @@ -268,17 +268,17 @@ export class FluentTaskView extends ItemView { // ==================== if (this.DEBUG_MODE) { console.log( - `[TG-V2] Finalizing (isInitializing was ${this.isInitializing})`, + `[TG] Finalizing (isInitializing was ${this.isInitializing})`, ); } this.isInitializing = false; if (this.DEBUG_MODE) { - console.log("[TG-V2] Calling final updateView()..."); + console.log("[TG] Calling final updateView()..."); } this.updateView(); - console.log("[TG-V2] ✅ Initialization complete"); + console.log("[TG] ✅ Initialization complete"); } } @@ -326,7 +326,7 @@ export class FluentTaskView extends ItemView { } }, onUpdateNeeded: (source) => { - console.log(`[TG-V2] Update needed from source: ${source}`); + console.log(`[TG] Update needed from source: ${source}`); // Re-apply filters and update view this.filteredTasks = this.dataManager.applyFilters(this.tasks); this.updateView(); @@ -395,7 +395,7 @@ export class FluentTaskView extends ItemView { this.updateView(); }, onProjectSelected: (projectId) => { - console.log(`[TG-V2] Project selected: ${projectId}`); + console.log(`[TG] Project selected: ${projectId}`); this.viewState.selectedProject = projectId; // Switch to projects view @@ -451,7 +451,7 @@ export class FluentTaskView extends ItemView { ); } catch (e) { console.warn( - "[TG-V2] Failed to project-sync filter UI state", + "[TG] Failed to project-sync filter UI state", e, ); // If filter sync fails, still update the view @@ -498,14 +498,14 @@ export class FluentTaskView extends ItemView { this.selectionManager = new TaskSelectionManager(this.app, this.plugin); this.addChild(this.selectionManager); - console.log("[TG-V2] Managers initialized"); + console.log("[TG] Managers initialized"); } /** * Build UI structure - MUST match original DOM structure for CSS */ private async buildUIStructure() { - console.log("[TG-V2] Building UI structure"); + console.log("[TG] Building UI structure"); // Create layout structure (exact same as original) const layoutContainer = this.rootContainerEl.createDiv({ @@ -592,12 +592,12 @@ export class FluentTaskView extends ItemView { } else { sidebarEl.hide(); console.log( - "[TG-V2] Using workspace side leaves: skip in-view sidebar", + "[TG] Using workspace side leaves: skip in-view sidebar", ); } // Create top navigation - console.log("[TG-V2] Initializing top navigation"); + console.log("[TG] Initializing top navigation"); this.topNavigation = new TopNavigation( topNavEl, this.plugin, @@ -616,7 +616,7 @@ export class FluentTaskView extends ItemView { this.addChild(this.topNavigation); // Initialize view components - console.log("[TG-V2] Initializing view components"); + console.log("[TG] Initializing view components"); this.componentManager = new FluentComponentManager( this.app, this.plugin, @@ -654,7 +654,7 @@ export class FluentTaskView extends ItemView { this.componentManager.initializeViewComponents(); // Sidebar toggle in header and responsive collapse - console.log("[TG-V2] Creating sidebar toggle"); + console.log("[TG] Creating sidebar toggle"); this.layoutManager.createSidebarToggle(); // Create task count mark @@ -677,10 +677,10 @@ export class FluentTaskView extends ItemView { }); // Create action buttons in Obsidian view header - console.log("[TG-V2] Creating action buttons"); + console.log("[TG] Creating action buttons"); this.layoutManager.createActionButtons(); - console.log("[TG-V2] UI structure built"); + console.log("[TG] UI structure built"); } /** @@ -772,14 +772,12 @@ export class FluentTaskView extends ItemView { !leafId.startsWith("view-config-") && leafId !== "global-filter" ) { - console.log( - "[TG-V2] Filter changed from live component", - ); + console.log("[TG] Filter changed from live component"); this.liveFilterState = filterState; this.currentFilterState = filterState; } else if (!leafId) { // No leafId means it's also a live filter change - console.log("[TG-V2] Filter changed (no leafId)"); + console.log("[TG] Filter changed (no leafId)"); this.liveFilterState = filterState; this.currentFilterState = filterState; } @@ -807,7 +805,7 @@ export class FluentTaskView extends ItemView { } } catch (e) { console.warn( - "[TG-V2] Failed to sync selectedProject from filter state", + "[TG] Failed to sync selectedProject from filter state", e, ); } @@ -871,7 +869,7 @@ export class FluentTaskView extends ItemView { private updateView() { // Enhanced logging with all critical state console.log( - `[TG-V2] updateView called: ` + + `[TG] updateView called: ` + `isInitializing=${this.isInitializing}, ` + `viewId=${this.currentViewId}, ` + `tasks=${this.tasks.length}, ` + @@ -881,12 +879,12 @@ export class FluentTaskView extends ItemView { ); if (this.isInitializing) { - console.log("[TG-V2] ⏭️ Skip update during initialization"); + console.log("[TG] ⏭️ Skip update during initialization"); return; } console.log( - `[TG-V2] ▶️ Proceeding with view update for ${this.currentViewId}`, + `[TG] ▶️ Proceeding with view update for ${this.currentViewId}`, ); // Update task count @@ -934,7 +932,7 @@ export class FluentTaskView extends ItemView { * Reset all active filters */ private resetCurrentFilter(): void { - console.log("[TG-V2] Resetting filter"); + console.log("[TG] Resetting filter"); // Clear filter states this.liveFilterState = null; @@ -985,7 +983,7 @@ export class FluentTaskView extends ItemView { * Clean up on close */ async onClose() { - console.log("[TG-V2] onClose started"); + console.log("[TG] onClose started"); // Save workspace layout before closing this.workspaceStateManager.saveWorkspaceLayout(); @@ -998,6 +996,6 @@ export class FluentTaskView extends ItemView { // Clear selection this.actionHandlers.clearSelection(); - console.log("[TG-V2] onClose completed"); + console.log("[TG] onClose completed"); } }