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
This commit is contained in:
Quorafind 2025-10-19 16:39:41 +08:00
parent add61b7777
commit fc7600c93a
5 changed files with 153 additions and 83 deletions

View file

@ -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 () => {

View file

@ -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));

View file

@ -814,6 +814,11 @@ export class WriteAPI {
argsList: UpdateTaskArgs[],
): Promise<BulkOperationResult> {
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,
});
}
});
}

View file

@ -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);

View file

@ -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");
}
}