From 470a0db69d327050c82989b360d3ec5609ec5875 Mon Sep 17 00:00:00 2001 From: GA Date: Sun, 25 Oct 2026 15:38:33 +0100 Subject: [PATCH] feat: preview files and folders inside archives When Explorer shows an archive as a folder (zip, and on newer Windows 7z/rar...), the selection arrives as "C:\dir\file.zip\inner\name.txt", which no plugin can read. - ArchiveEntryHelper copies the entry out through the shell to a temporary file, and ViewWindowManager previews that copy; _invokedPath keeps the original path so toggling still closes the preview. Copies from earlier previews are removed (files still open are kept). - A folder inside an archive ("C:\dir\file.zip\docs") is shown by the archive viewer, listing that folder straight from the archive. - In the archive viewer, double-clicking a file in the list extracts it to a temporary folder and previews it in QuickLook. The two new status labels in the archive panel ("Extracting ... " and "Folder not found in the archive") are plain strings like the existing "Preview failed" one next to them. KeyboardLog.cs (the QuickLook.Keyboard.log writer) is included because slow extractions are logged there; it is identical to the copy added by the Raw Input keyboard change. Co-Authored-By: Claude Haiku 5.5 --- .../ArchiveFile/ArchiveFileEntry.cs | 5 + .../ArchiveFile/ArchiveFileListView.xaml.cs | 20 ++ .../ArchiveFile/ArchiveInfoPanel.xaml.cs | 208 +++++++++++++- .../ArchiveFile/Extensions.cs | 8 + .../QuickLook.Plugin.ArchiveViewer/Plugin.cs | 7 +- QuickLook/Helpers/ArchiveEntryHelper.cs | 270 ++++++++++++++++++ QuickLook/Helpers/KeyboardLog.cs | 102 +++++++ QuickLook/ViewWindowManager.cs | 43 ++- 8 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 QuickLook/Helpers/ArchiveEntryHelper.cs create mode 100644 QuickLook/Helpers/KeyboardLog.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileEntry.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileEntry.cs index 9c6fa97a8..047b4f880 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileEntry.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileEntry.cs @@ -39,6 +39,11 @@ public ArchiveFileEntry(string name, bool isFolder, ArchiveFileEntry parent = nu public SortedList Children { get; set; } = []; public string Name { get; set; } + + /// + /// Full path of the entry inside the archive, used to extract it for preview + /// + public string Key { get; set; } public bool Encrypted { get; set; } public bool IsFolder { get; set; } public ulong Size { get; set; } diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileListView.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileListView.xaml.cs index bae67a9c6..f184913de 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileListView.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveFileListView.xaml.cs @@ -17,7 +17,9 @@ using QuickLook.Plugin.ArchiveViewer.ArchiveFile; using System; +using System.Windows; using System.Windows.Controls; +using System.Windows.Input; namespace QuickLook.Plugin.ArchiveViewer.ArchiveFile; @@ -26,6 +28,24 @@ public partial class ArchiveFileListView : UserControl, IDisposable public ArchiveFileListView() { InitializeComponent(); + + treeView.MouseDoubleClick += TreeView_MouseDoubleClick; + } + + /// + /// Raised when the user asks to preview a file inside the archive + /// + public event Action PreviewRequested; + + private void TreeView_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + // only react to double-clicks on an item, not on the scrollbar or empty space + var item = (e.OriginalSource as DependencyObject)?.FindAncestor(); + if (item?.DataContext is not ArchiveFileEntry entry || entry.IsFolder) + return; + + e.Handled = true; + PreviewRequested?.Invoke(entry); } public void Dispose() diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveInfoPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveInfoPanel.xaml.cs index 513cf43e6..6c392ac35 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveInfoPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/ArchiveInfoPanel.xaml.cs @@ -34,8 +34,21 @@ namespace QuickLook.Plugin.ArchiveViewer.ArchiveFile; public partial class ArchiveInfoPanel : UserControl, IDisposable, INotifyPropertyChanged { + // ReaderFactory is slow... so limit its usage + private static readonly string[] _useReader = [".tar.gz", ".tgz", ".tar.bz2", ".tar.lz", ".tar.xz"]; + + private static readonly string _previewRoot = Path.Combine(Path.GetTempPath(), "QuickLook", "ArchivePreview"); + private readonly Dictionary _fileEntries = []; + private readonly string _path; + + /// + /// Folder inside the archive to show ("docs\sub"), or empty for the whole archive + /// + private readonly string _innerFolder = string.Empty; + private bool _disposed; + private bool _extracting; private double _loadPercent; private ulong _totalZippedSize; private string _type; @@ -47,6 +60,16 @@ public ArchiveInfoPanel(string path) // design-time only Resources.MergedDictionaries.Clear(); + // a folder inside an archive comes as "C:\dir\file.zip\docs\sub": show the archive from that folder + if (!File.Exists(path) && SplitArchivePath(path) is { } split) + { + path = split.Archive; + _innerFolder = split.InnerFolder; + } + + _path = path; + fileListView.PreviewRequested += BeginPreviewEntry; + BeginLoadArchive(path); } @@ -92,10 +115,18 @@ private void BeginLoadArchive(string path) return; } + // the shown root: the whole archive, or the folder inside it that was asked for + var rootKey = FindEntryKey(_innerFolder); + if (rootKey == null) + { + Dispatcher.Invoke(() => { lblLoading.Content = $"Folder not found in the archive: {_innerFolder}"; }); + return; + } + var folders = -1; // do not count root node var files = 0; ulong sizeU = 0L; - _fileEntries.ForEach(e => + _fileEntries.Where(e => IsUnder(e.Key, rootKey)).ForEach(e => { if (e.Value.IsFolder) folders++; @@ -120,9 +151,10 @@ private void BeginLoadArchive(string path) if (_disposed) return; - fileListView.SetDataContext(_fileEntries[string.Empty].Children.Keys); - archiveCount.Content = - $"{_type} archive{t}"; + fileListView.SetDataContext(_fileEntries[rootKey].Children.Keys); + archiveCount.Content = string.IsNullOrEmpty(rootKey) + ? $"{_type} archive{t}" + : $"Folder in {_type} archive {Path.GetFileName(_path)}{t}"; archiveSizeC.Content = $"Compressed size {((long)_totalZippedSize).ToPrettySize(2)}"; archiveSizeU.Content = $"Uncompressed size {((long)sizeU).ToPrettySize(2)}"; @@ -136,10 +168,7 @@ private void LoadItemsFromArchive(string path) { using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - // ReaderFactory is slow... so limit its usage - string[] useReader = [".tar.gz", ".tgz", ".tar.bz2", ".tar.lz", ".tar.xz"]; - - if (useReader.Any(path.ToLower().EndsWith)) + if (UseReader(path)) { var reader = ReaderFactory.Open(fileStream, new ChardetReaderOptions()); @@ -197,6 +226,7 @@ private void ProcessByLevel(IEntry entry) _fileEntries.Add(file, new ArchiveFileEntry(Path.GetFileName(entry.Key), false, parent) { + Key = entry.Key, Encrypted = entry.IsEncrypted, Size = (ulong)entry.Size, ModifiedDate = entry.LastModifiedTime ?? new DateTime() @@ -204,6 +234,168 @@ private void ProcessByLevel(IEntry entry) } } + private static bool UseReader(string path) + { + return _useReader.Any(path.ToLower().EndsWith); + } + + /// + /// Extracts the entry to a temporary folder and previews it in QuickLook + /// + private void BeginPreviewEntry(ArchiveFileEntry entry) + { + if (_extracting || entry.Encrypted || string.IsNullOrEmpty(entry.Key)) + return; + + _extracting = true; + lblLoading.Content = $"Extracting {entry.Name} ..."; + LoadPercent = 0d; + + Task.Run(() => + { + string target = null; + try + { + target = ExtractEntry(entry); + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + } + + Dispatcher.Invoke(() => + { + _extracting = false; + LoadPercent = 100d; + + if (_disposed || target == null) + return; + + PluginHelper.InvokePreview(target); + }); + }); + } + + private string ExtractEntry(ArchiveFileEntry entry) + { + CleanupPreviewFolders(); + + var dir = Path.Combine(_previewRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var target = Path.Combine(dir, entry.Name); + + using var fileStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + if (UseReader(_path)) + { + var reader = ReaderFactory.Open(fileStream, new ChardetReaderOptions()); + + while (reader.MoveToNextEntry()) + { + if (_disposed) + return null; + if (reader.Entry.Key != entry.Key) + continue; + + using var source = reader.OpenEntryStream(); + WriteEntry(source, target, (ulong)reader.Entry.Size); + return target; + } + } + else + { + using var archive = ArchiveFactory.Open(fileStream, new ChardetReaderOptions()); + + var archiveEntry = archive.Entries.FirstOrDefault(e => e.Key == entry.Key); + if (archiveEntry != null) + { + using var source = archiveEntry.OpenEntryStream(); + WriteEntry(source, target, (ulong)archiveEntry.Size); + return target; + } + } + + return null; + } + + private void WriteEntry(Stream source, string target, ulong size) + { + using var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.Read); + + var buffer = new byte[81920]; + ulong written = 0; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + if (_disposed) + throw new OperationCanceledException(); + + output.Write(buffer, 0, read); + written += (ulong)read; + if (size > 0) + LoadPercent = Math.Min(99d, 100d * written / size); + } + } + + /// + /// Removes files extracted by previous previews. Files still in use (e.g. by a pinned window) are kept. + /// + private static void CleanupPreviewFolders() + { + if (!Directory.Exists(_previewRoot)) + return; + + foreach (var dir in Directory.GetDirectories(_previewRoot)) + { + try + { + Directory.Delete(dir, true); + } + catch + { + // still in use + } + } + } + + /// + /// Splits "C:\dir\file.zip\docs\sub" into the archive file and the folder inside it ("docs\sub"). + /// + internal static (string Archive, string InnerFolder)? SplitArchivePath(string path) + { + for (var parent = Path.GetDirectoryName(path); !string.IsNullOrEmpty(parent); parent = Path.GetDirectoryName(parent)) + { + if (File.Exists(parent)) + return (parent, path.Substring(parent.Length).Trim('\\')); + if (Directory.Exists(parent)) + return null; + } + + return null; + } + + /// + /// The key of the folder entry named , matched ignoring case (the shell may + /// show names differently), or null when the archive has no such folder. + /// + private string FindEntryKey(string folder) + { + if (string.IsNullOrEmpty(folder)) + return string.Empty; + if (_fileEntries.TryGetValue(folder, out var exact) && exact.IsFolder) + return folder; + + return _fileEntries.FirstOrDefault(e => e.Value.IsFolder + && string.Equals(e.Key, folder, StringComparison.OrdinalIgnoreCase)).Key; + } + + private static bool IsUnder(string key, string rootKey) + { + return string.IsNullOrEmpty(rootKey) + || key == rootKey + || key.StartsWith(rootKey + "\\", StringComparison.Ordinal); + } + private string GetDirectoryName(string path) { var d = Path.GetDirectoryName(path); diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/Extensions.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/Extensions.cs index 9e13b682b..8557011f4 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/Extensions.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/ArchiveFile/Extensions.cs @@ -30,6 +30,14 @@ public static void ForEach(this IEnumerable enumeration, Action action) action(item); } + public static T FindAncestor(this DependencyObject element) where T : DependencyObject + { + while (element != null && element is not T) + element = element is Visual ? VisualTreeHelper.GetParent(element) : LogicalTreeHelper.GetParent(element); + + return element as T; + } + public static T GetDescendantByType(this Visual element) where T : class { if (element == null) diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.cs index 510a38240..dc65e8434 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.cs @@ -132,7 +132,12 @@ public void Prepare(string path, ContextObject context) public void View(string path, ContextObject context) { - if (Path.GetFileName(path).Equals(".DS_Store", StringComparison.OrdinalIgnoreCase)) + if (!File.Exists(path)) + { + // a folder inside an archive ("C:\dir\file.zip\docs"), sent by QuickLook for Explorer views that list archive contents + _panel = new ArchiveInfoPanel(path); + } + else if (Path.GetFileName(path).Equals(".DS_Store", StringComparison.OrdinalIgnoreCase)) { _panel = new DSStoreInfoPanel(path); } diff --git a/QuickLook/Helpers/ArchiveEntryHelper.cs b/QuickLook/Helpers/ArchiveEntryHelper.cs new file mode 100644 index 000000000..b6d1d92ca --- /dev/null +++ b/QuickLook/Helpers/ArchiveEntryHelper.cs @@ -0,0 +1,270 @@ +// 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 System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Security.Cryptography; +using System.Text; + +namespace QuickLook.Helpers; + +/// +/// Previews files that live inside an archive the shell opens as a folder (zip, and on newer Windows +/// 7z/rar...). Explorer hands those over as "C:\dir\file.zip\inner\name.txt", which no plugin can read, +/// so the entry is copied out through the shell to a temporary file first. +/// +internal static class ArchiveEntryHelper +{ + private static readonly string TempRoot = Path.Combine(Path.GetTempPath(), "QuickLook", "ArchiveEntries"); + + /// + /// Returns a temporary copy of the archive entry at , or null when the path + /// is not a file inside an archive. + /// + public static string TryExtract(string path) + { + try + { + var archive = FindArchive(path); + if (archive == null) + return null; + + var item = FindEntry(archive, path.Substring(archive.Length + 1)); + if (item == null) + return null; + + item.GetAttributes(SFGAO_FOLDER, out var attributes); + if ((attributes & SFGAO_FOLDER) != 0) + return null; + + // one folder per archive entry and archive version, so the same entry isn't copied twice + var info = new FileInfo(archive); + var dir = Path.Combine(TempRoot, Hash($"{path}|{info.Length}|{info.LastWriteTimeUtc.Ticks}")); + var target = Path.Combine(dir, Path.GetFileName(path)); + if (File.Exists(target)) + return target; + + CleanupOldEntries(); + Directory.CreateDirectory(dir); + + var bhidStream = BHID_Stream; + var iidStream = typeof(IStream).GUID; + item.BindToHandler(IntPtr.Zero, ref bhidStream, ref iidStream, out var obj); + var stream = (IStream)obj; + try + { + CopyToFile(stream, target); + } + finally + { + Marshal.ReleaseComObject(stream); + } + + return target; + } + catch (Exception e) + { + Common.Helpers.ProcessHelper.WriteLog(e.ToString()); + return null; + } + } + + /// + /// True when is a folder inside an archive, e.g. "C:\dir\file.zip\docs". + /// + public static bool IsArchiveFolder(string path) + { + try + { + var archive = FindArchive(path); + if (archive == null) + return false; + + var item = FindEntry(archive, path.Substring(archive.Length + 1)); + if (item == null) + return false; + + item.GetAttributes(SFGAO_FOLDER, out var attributes); + return (attributes & SFGAO_FOLDER) != 0; + } + catch (Exception e) + { + Common.Helpers.ProcessHelper.WriteLog(e.ToString()); + return false; + } + } + + /// + /// The nearest ancestor of a nonexistent path that is an existing file, e.g. "C:\dir\file.zip". + /// + private static string FindArchive(string path) + { + if (string.IsNullOrEmpty(path) || path.StartsWith("::")) + return null; + + for (var parent = Path.GetDirectoryName(path); !string.IsNullOrEmpty(parent); parent = Path.GetDirectoryName(parent)) + { + if (File.Exists(parent)) + return parent; + if (Directory.Exists(parent)) + return null; + } + + return null; + } + + /// + /// Walks the archive folder name by name: parsing a full path through a zip folder is unreliable. + /// + private static IShellItem FindEntry(string archive, string relativePath) + { + var iidShellItem = typeof(IShellItem).GUID; + SHCreateItemFromParsingName(archive, IntPtr.Zero, ref iidShellItem, out var current); + + foreach (var name in relativePath.Split(['\\'], StringSplitOptions.RemoveEmptyEntries)) + { + current = FindChild(current, name); + if (current == null) + return null; + } + + return current; + } + + private static IShellItem FindChild(IShellItem folder, string name) + { + var bhidEnum = BHID_EnumItems; + var iidEnum = typeof(IEnumShellItems).GUID; + folder.BindToHandler(IntPtr.Zero, ref bhidEnum, ref iidEnum, out var obj); + var items = (IEnumShellItems)obj; + + var fetched = new IShellItem[1]; + while (items.Next(1, fetched, out var count) == 0 && count == 1) + { + var child = fetched[0]; + if (NameEquals(child, SIGDN.PARENTRELATIVEPARSING, name) || NameEquals(child, SIGDN.NORMALDISPLAY, name)) + return child; + } + + return null; + } + + private static bool NameEquals(IShellItem item, SIGDN sigdn, string name) + { + if (item.GetDisplayName(sigdn, out var ptr) != 0 || ptr == IntPtr.Zero) + return false; + + var display = Marshal.PtrToStringUni(ptr); + Marshal.FreeCoTaskMem(ptr); + return string.Equals(display, name, StringComparison.OrdinalIgnoreCase); + } + + private static void CopyToFile(IStream stream, string target) + { + var buffer = new byte[81920]; + var readPtr = Marshal.AllocCoTaskMem(sizeof(int)); + try + { + using var output = new FileStream(target + ".part", FileMode.Create, FileAccess.Write, FileShare.None); + while (true) + { + stream.Read(buffer, buffer.Length, readPtr); + var read = Marshal.ReadInt32(readPtr); + if (read <= 0) + break; + output.Write(buffer, 0, read); + } + } + finally + { + Marshal.FreeCoTaskMem(readPtr); + } + + File.Move(target + ".part", target); + } + + /// + /// Removes copies made for earlier previews. Files still open (e.g. in a pinned window) are kept. + /// + private static void CleanupOldEntries() + { + if (!Directory.Exists(TempRoot)) + return; + + foreach (var dir in Directory.GetDirectories(TempRoot)) + { + try + { + Directory.Delete(dir, true); + } + catch + { + // still in use + } + } + } + + private static string Hash(string text) + { + using var sha = SHA1.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToLowerInvariant())); + return BitConverter.ToString(bytes, 0, 8).Replace("-", string.Empty); + } + + private const uint SFGAO_FOLDER = 0x20000000; + private static readonly Guid BHID_Stream = new("1CEBB3AB-7C10-499A-A417-92CA16C4CB83"); + private static readonly Guid BHID_EnumItems = new("94F60519-2850-4924-AA5A-D15E84868039"); + + private enum SIGDN : uint + { + NORMALDISPLAY = 0x00000000, + PARENTRELATIVEPARSING = 0x80018001, + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern void SHCreateItemFromParsingName(string path, IntPtr pbc, ref Guid riid, out IShellItem item); + + [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem + { + void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv); + + void GetParent(out IShellItem ppsi); + + [PreserveSig] + int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName); + + void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); + + void Compare(IShellItem psi, uint hint, out int piOrder); + } + + [ComImport, Guid("70629033-E363-4A28-A567-0DB78006E6D7"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IEnumShellItems + { + [PreserveSig] + int Next(uint celt, [Out, MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.Interface, SizeParamIndex = 0)] IShellItem[] rgelt, out uint pceltFetched); + + void Skip(uint celt); + + void Reset(); + + void Clone(out IEnumShellItems ppenum); + } +} 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..c5da87444 100644 --- a/QuickLook/ViewWindowManager.cs +++ b/QuickLook/ViewWindowManager.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using QuickLook.Common.ExtensionMethods; using QuickLook.Common.Helpers; using QuickLook.Common.Plugin; using QuickLook.Helpers; @@ -30,6 +31,11 @@ public class ViewWindowManager : IDisposable private static ViewWindowManager _instance; private string _invokedPath = string.Empty; + + /// + /// The path the plugin shows: , or its temporary copy for an archive entry. + /// + private string _previewPath = string.Empty; private ViewerWindow _viewerWindow; internal ViewWindowManager() @@ -151,28 +157,49 @@ public void InvokePreview(string path = null) if (_viewerWindow.IsVisible && path == _invokedPath) return; + // A file inside an archive shown as a folder (e.g. "C:\dir\file.zip\inner.txt") is previewed + // from a temporary copy; _invokedPath keeps the original path so toggling still closes it. + var previewPath = path; + IViewer archiveFolderViewer = null; var isDirectory = Directory.Exists(path); - if (!isDirectory && !File.Exists(path)) - if (!path.StartsWith("::")) // CLSID + if (!isDirectory && !File.Exists(path) && !path.StartsWith("::")) // CLSID + { + var sw0 = Stopwatch.StartNew(); + previewPath = ArchiveEntryHelper.TryExtract(path); + KeyboardLog.WriteIfSlow("extract archive entry", sw0.ElapsedMilliseconds); + + // A folder inside an archive: the archive viewer lists it straight from the archive. + if (previewPath == null && ArchiveEntryHelper.IsArchiveFolder(path)) + { + archiveFolderViewer = PluginManager.GetInstance().LoadedPlugins + .Find(p => p.GetType().Assembly.GetName().Name == "QuickLook.Plugin.ArchiveViewer") + ?.GetType().CreateInstance(); + previewPath = archiveFolderViewer != null ? path : null; + isDirectory = true; + } + + if (previewPath == null) return; + } // Check extension filtering before proceeding (skip for directories) - if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(path)) + if (!isDirectory && !ExtensionFilterHelper.IsExtensionAllowed(previewPath)) return; _invokedPath = path; + _previewPath = previewPath; RunFocusMonitor(); - var matchedPlugin = PluginManager.GetInstance().FindMatch(path); + var matchedPlugin = archiveFolderViewer ?? PluginManager.GetInstance().FindMatch(previewPath); - BeginShowNewWindow(path, matchedPlugin); + BeginShowNewWindow(previewPath, matchedPlugin); } public void InvokePluginPreview(string plugin, string path = null) { if (string.IsNullOrEmpty(path)) - path = _invokedPath; + path = _previewPath; if (string.IsNullOrEmpty(path)) return; @@ -203,9 +230,9 @@ public void ReloadPreview() if (!_viewerWindow.IsVisible || string.IsNullOrEmpty(_invokedPath)) return; - var matchedPlugin = PluginManager.GetInstance().FindMatch(_invokedPath); + var matchedPlugin = PluginManager.GetInstance().FindMatch(_previewPath); - BeginShowNewWindow(_invokedPath, matchedPlugin); + BeginShowNewWindow(_previewPath, matchedPlugin); } public void ToggleFullscreen()