FIX: sync - suspend panel watchers during copy/delete (avoid O(n²) reload)

A folder-sync copy/delete on a large LOCAL directory crawled at ~1 file/sec.
The cost is not the file operation (raw unlink runs hundreds-thousands/sec)
but the source panel's directory watcher: each changed file fires a watcher
event, and once >100 (or >25% of the listing) pending changes accumulate,
TFileView reloads the WHOLE directory (ufileview.pas), rate-limited to once
per second. During a bulk operation this full reload keeps re-firing, so the
whole thing becomes O(n²) and stalls — the sync runs the operation inline on
the GUI thread, so each reload directly blocks it.

Add TFileView.SetWatcherEnabled (public wrapper over the existing protected
EnableWatcher, plus one reconciling reload on resume) and have the sync
dialog suspend both source panels' watchers around the copy/delete run and
the compare-grid delete buttons, restoring them via try/finally. Operations
now proceed at filesystem speed; the panels refresh once at the end.

Cross-platform: the reload path is platform-agnostic and is fed by inotify
(Linux), ReadDirectoryChangesW (Windows) and FSEvents (macOS) alike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
heredie 2026-06-19 05:40:42 -06:00
commit 9a172ea610
2 changed files with 51 additions and 2 deletions

View file

@ -443,6 +443,13 @@ type
function Reload(const PathToReload: String): Boolean; overload;
procedure Reload(AForced: Boolean);
procedure ReloadIfNeeded;
{en
Suspend/resume the directory watcher around an app-initiated bulk
operation. While suspended, inotify changes are not turned into
repeated full reloads (which is O() for large folders); resuming
performs a single reconciling reload. No-op for non-filesystem views.
}
procedure SetWatcherEnabled(AEnabled: Boolean);
procedure StopWorkers; virtual;
// For now we use here the knowledge that there are tabs.
@ -3390,6 +3397,22 @@ begin
end;
end;
procedure TFileView.SetWatcherEnabled(AEnabled: Boolean);
begin
if AEnabled then
begin
// Only the filesystem watcher causes the per-file reload storm, so only
// it was suspended; re-enable and do one reload to catch up on changes.
if Assigned(FileSource) and FileSource.IsClass(TFileSystemFileSource) then
begin
EnableWatcher(True);
Reload(True);
end;
end
else if WatcherActive then
EnableWatcher(False);
end;
procedure TFileView.SetFlatView(AFlatView: Boolean);
begin
FFlatView:= AFlatView;

View file

@ -166,6 +166,9 @@ type
FCmpFileSourceL, FCmpFileSourceR: IFileSource;
FCmpFilePathL, FCmpFilePathR: string;
FAddressL, FAddressR: string;
// The two source panels, kept so their directory watchers can be
// suspended during bulk copy/delete (avoids the per-file reload storm).
FFileView1, FFileView2: TFileView;
hCols: array [0..6] of record Left, Width: Integer end;
CheckContentThread: TObject;
Ftotal, Fequal, Fnoneq, FuniqueL, FuniqueR: Integer;
@ -189,6 +192,7 @@ type
procedure SetSyncRecState(AState: TSyncRecState);
procedure DeleteFiles(ALeft, ARight: Boolean);
function DeleteFiles(FileSource: IFileSource; var Files: TFiles): Boolean;
procedure SetPanelWatchers(AEnabled: Boolean);
procedure UpdateList(ALeft, ARight: TFiles; ARemoveLeft, ARemoveRight: Boolean);
procedure SetProgressBytes(AProgressBar: TKASProgressBar; CurrentBytes: Int64; TotalBytes: Int64);
procedure SetProgressFiles(AProgressBar: TKASProgressBar; CurrentFiles: Int64; TotalFiles: Int64);
@ -783,6 +787,8 @@ begin
pnlCopyProgress.Visible:= CopyLeft or CopyRight;
pnlDeleteProgress.Visible:= DeleteLeft or DeleteRight;
SetPanelWatchers(False);
try
i := 0;
while i < FVisibleItems.Count do
begin
@ -834,6 +840,9 @@ begin
else DeleteRightFiles.Free;
if not pnlProgress.Visible then Break;
end;
finally
SetPanelWatchers(True);
end;
EnableControls(True);
btnCompare.Click;
end;
@ -1836,6 +1845,16 @@ begin
end;
end;
procedure TfrmSyncDirsDlg.SetPanelWatchers(AEnabled: Boolean);
begin
// Suspend the source panels' directory watchers while we copy/delete, so a
// large local operation does not trigger one full O(n) panel reload per ~100
// changes (which makes a big delete behave as O(n²), ~1 file/sec). Resuming
// does a single reconciling reload of each panel.
if Assigned(FFileView1) then FFileView1.SetWatcherEnabled(AEnabled);
if Assigned(FFileView2) then FFileView2.SetWatcherEnabled(AEnabled);
end;
procedure TfrmSyncDirsDlg.DeleteFiles(ALeft, ARight: Boolean);
var
Message: String;
@ -1883,8 +1902,13 @@ begin
EnableControls(False);
pnlCopyProgress.Visible:= False;
pnlDeleteProgress.Visible:= True;
if ALeft then DeleteFiles(FCmpFileSourceL, ALeftList);
if ARight then DeleteFiles(FCmpFileSourceR, ARightList);
SetPanelWatchers(False);
try
if ALeft then DeleteFiles(FCmpFileSourceL, ALeftList);
if ARight then DeleteFiles(FCmpFileSourceR, ARightList);
finally
SetPanelWatchers(True);
end;
UpdateList(nil, nil, ALeft, ARight);
EnableControls(True);
end;
@ -2049,6 +2073,8 @@ begin
FFoundItems := TStringListEx.Create;
FFoundItems.CaseSensitive := FileNameCaseSensitive;
FFoundItems.Sorted := True;
FFileView1 := FileView1;
FFileView2 := FileView2;
FFileSourceL := FileView1.FileSource;
FFileSourceR := FileView2.FileSource;
FAddressL := FileView1.CurrentAddress;