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()