diff --git a/QuickLook/Helpers/KeyboardLog.cs b/QuickLook/Helpers/KeyboardLog.cs
new file mode 100644
index 000000000..b92d2d510
--- /dev/null
+++ b/QuickLook/Helpers/KeyboardLog.cs
@@ -0,0 +1,102 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using QuickLook.Common.Helpers;
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+namespace QuickLook.Helpers;
+
+///
+/// Diagnostic log for the keyboard pipeline, written to QuickLook.Keyboard.log in the data folder.
+/// Only QuickLook's own keys and anomalies are logged, never other keystrokes.
+///
+internal static class KeyboardLog
+{
+ private const long MAX_SIZE = 512 * 1024;
+
+ /// Preview phases slower than this block switching to the next file and get logged.
+ private const long SLOW_PREVIEW_PHASE_MS = 100;
+
+ private static readonly string LogPath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Keyboard.log");
+
+ // Bounded so that a stuck disk can never make the keyboard hook wait or grow memory.
+ private static readonly BlockingCollection Queue = new(new ConcurrentQueue(), 1000);
+
+ static KeyboardLog()
+ {
+ new Thread(WriteLoop)
+ {
+ IsBackground = true,
+ Name = "QuickLook.KeyboardLog",
+ Priority = ThreadPriority.BelowNormal,
+ }.Start();
+ }
+
+ ///
+ /// Never blocks; safe to call from inside the low-level keyboard hook.
+ ///
+ public static void Write(string message)
+ {
+ Queue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId}] {message}");
+ }
+
+ ///
+ /// Logs a preview phase that ran on the UI thread only when it was slow enough to delay
+ /// the next selection change, naming the plugin and file type, never the file name.
+ ///
+ public static void WriteIfSlow(string phase, long elapsedMs)
+ {
+ if (elapsedMs >= SLOW_PREVIEW_PHASE_MS)
+ Write($"Slow preview {phase}: {elapsedMs} ms");
+ }
+
+ private static void WriteLoop()
+ {
+ var batch = new StringBuilder();
+
+ foreach (var line in Queue.GetConsumingEnumerable())
+ {
+ batch.AppendLine(line);
+ while (Queue.TryTake(out var more))
+ batch.AppendLine(more);
+
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
+
+ var file = new FileInfo(LogPath);
+ if (file.Exists && file.Length > MAX_SIZE)
+ {
+ File.Delete(LogPath + ".old");
+ File.Move(LogPath, LogPath + ".old");
+ }
+
+ File.AppendAllText(LogPath, batch.ToString());
+ }
+ catch
+ {
+ // logging must never take QuickLook down
+ }
+
+ batch.Clear();
+ }
+ }
+}
diff --git a/QuickLook/ViewWindowManager.cs b/QuickLook/ViewWindowManager.cs
index e18fc7004..7faa45f43 100644
--- a/QuickLook/ViewWindowManager.cs
+++ b/QuickLook/ViewWindowManager.cs
@@ -164,8 +164,19 @@ public void InvokePreview(string path = null)
RunFocusMonitor();
+ // Stop the previous preview's background work (file reads, folder counting...) before
+ // matching the new path, so it doesn't keep competing for the disk while plugins probe it.
+ var sw = Stopwatch.StartNew();
+ _viewerWindow.UnloadPlugin();
+ var unloadMs = sw.ElapsedMilliseconds;
+
+ sw.Restart();
var matchedPlugin = PluginManager.GetInstance().FindMatch(path);
+ var fileType = isDirectory ? "" : Path.GetExtension(path);
+ KeyboardLog.WriteIfSlow("unload", unloadMs);
+ KeyboardLog.WriteIfSlow($"match ({fileType} -> {matchedPlugin.GetType().Namespace})", sw.ElapsedMilliseconds);
+
BeginShowNewWindow(path, matchedPlugin);
}
diff --git a/QuickLook/ViewerWindow.Actions.cs b/QuickLook/ViewerWindow.Actions.cs
index c5fb50506..89cb4e6cf 100644
--- a/QuickLook/ViewerWindow.Actions.cs
+++ b/QuickLook/ViewerWindow.Actions.cs
@@ -253,7 +253,10 @@ internal void UnloadPlugin()
try
{
+ var cleanupWatch = Stopwatch.StartNew();
Plugin?.Cleanup();
+ if (Plugin != null)
+ KeyboardLog.WriteIfSlow($"cleanup ({Plugin.GetType().Namespace})", cleanupWatch.ElapsedMilliseconds);
}
catch (Exception e)
{
@@ -284,9 +287,11 @@ internal void BeginShow(IViewer matchedPlugin, string path,
ContextObject.ColorProfileName = DisplayDeviceHelper.GetMonitorColorProfileFromWindow(this);
// Get window size before showing it
+ var prepareWatch = Stopwatch.StartNew();
try
{
Plugin.Prepare(path, ContextObject);
+ KeyboardLog.WriteIfSlow($"prepare ({Plugin.GetType().Namespace})", prepareWatch.ElapsedMilliseconds);
}
catch (Exception e)
{
@@ -360,7 +365,9 @@ internal void BeginShow(IViewer matchedPlugin, string path,
// Avoid main thread scheduling lag
if (Plugin is null) return;
+ var viewWatch = Stopwatch.StartNew();
Plugin.View(path, ContextObject);
+ KeyboardLog.WriteIfSlow($"view ({Plugin.GetType().Namespace})", viewWatch.ElapsedMilliseconds);
// Initial the more menu
ClearMoreMenuUnpin();