From 962a23bf9369f42e41119e74b9111da13613f8d6 Mon Sep 17 00:00:00 2001 From: JulienDeveaux Date: Sun, 15 Mar 2026 01:52:01 +0100 Subject: [PATCH 1/4] feat: folder glancing --- Glance.xcodeproj/project.pbxproj | 4 + QLPlugin/Info.plist | 3 + QLPlugin/MainVC.swift | 2 +- QLPlugin/Views/PreviewVCFactory.swift | 5 +- .../Views/PreviewVCs/OutlinePreviewVC.swift | 20 +++- .../Views/Previews/DirectoryPreview.swift | 95 +++++++++++++++++++ README.md | 2 + 7 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 QLPlugin/Views/Previews/DirectoryPreview.swift diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index 894ede8..894e503 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 7EB36482244B03CC00D7F96F /* markdown-main.css in Resources */ = {isa = PBXBuildFile; fileRef = 7EB3643D244B03CC00D7F96F /* markdown-main.css */; }; 7EB36488244B65D900D7F96F /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB36487244B65D900D7F96F /* String.swift */; }; 7EB3648A244B665700D7F96F /* ZIPPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB36489244B665700D7F96F /* ZIPPreview.swift */; }; + A1B2C3D4E5F6789000112234 /* DirectoryPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789000112233 /* DirectoryPreview.swift */; }; 7EB7491924228549007265A4 /* JupyterPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB7491824228549007265A4 /* JupyterPreview.swift */; }; 7ECC8CF2240CB4CC000D6970 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ECC8CF1240CB4CC000D6970 /* AppDelegate.swift */; }; 7ECC8CF4240CB4CC000D6970 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ECC8CF3240CB4CC000D6970 /* ViewController.swift */; }; @@ -267,6 +268,7 @@ 7EB3643D244B03CC00D7F96F /* markdown-main.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = "markdown-main.css"; sourceTree = ""; }; 7EB36487244B65D900D7F96F /* String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 7EB36489244B665700D7F96F /* ZIPPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZIPPreview.swift; sourceTree = ""; }; + A1B2C3D4E5F6789000112233 /* DirectoryPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectoryPreview.swift; sourceTree = ""; }; 7EB7491824228549007265A4 /* JupyterPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JupyterPreview.swift; sourceTree = ""; }; 7ECC8CEE240CB4CC000D6970 /* Glance.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Glance.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7ECC8CF1240CB4CC000D6970 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -357,6 +359,7 @@ 7E1DC529240E6FDE00D0A061 /* CodePreview.swift */, 7EB7491824228549007265A4 /* JupyterPreview.swift */, 7E1DC520240E6D8000D0A061 /* MarkdownPreview.swift */, + A1B2C3D4E5F6789000112233 /* DirectoryPreview.swift */, 7E7A9DAC244F24B900276E51 /* TARPreview.swift */, 7E413F7F2418DD6200CFBB1D /* TSVPreview.swift */, 7EB36489244B665700D7F96F /* ZIPPreview.swift */, @@ -867,6 +870,7 @@ 7E022BED245253B3000EFCD3 /* OutlinePreviewVC.swift in Sources */, 7E9F0D7E24168870007F1008 /* Script.swift in Sources */, 7EB3648A244B665700D7F96F /* ZIPPreview.swift in Sources */, + A1B2C3D4E5F6789000112234 /* DirectoryPreview.swift in Sources */, 7E9F0D7D24168870007F1008 /* Stylesheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QLPlugin/Info.plist b/QLPlugin/Info.plist index 3cb34c5..ce52dc7 100644 --- a/QLPlugin/Info.plist +++ b/QLPlugin/Info.plist @@ -36,6 +36,9 @@ - public.xml (.xml) --> + + public.folder + com.sun.java-archive com.sun.web-application-archive diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index d9bf2c9..4308dc4 100644 --- a/QLPlugin/MainVC.swift +++ b/QLPlugin/MainVC.swift @@ -105,7 +105,7 @@ class MainVC: NSViewController, QLPreviewingController { /// Generates a preview of the selected file and adds the corresponding child view controller. private func previewFile(file: File) throws { // Initialize `PreviewVC` for the file type - if let previewInitializerType = PreviewVCFactory.getPreviewInitializer(fileURL: file.url) { + if let previewInitializerType = PreviewVCFactory.getPreviewInitializer(fileURL: file.url, isDirectory: file.isDirectory) { // Generate file preview let previewInitializer = previewInitializerType.init() let previewVC = try previewInitializer.createPreviewVC(file: file) diff --git a/QLPlugin/Views/PreviewVCFactory.swift b/QLPlugin/Views/PreviewVCFactory.swift index 8767005..4351dbf 100644 --- a/QLPlugin/Views/PreviewVCFactory.swift +++ b/QLPlugin/Views/PreviewVCFactory.swift @@ -3,7 +3,10 @@ import Foundation /// Returns a `PreviewVC` subclass that can be used to generate a preview of the provided file. /// May return `nil` if the file is not supported. class PreviewVCFactory { - static func getPreviewInitializer(fileURL: URL) -> Preview.Type? { + static func getPreviewInitializer(fileURL: URL, isDirectory: Bool = false) -> Preview.Type? { + if isDirectory { + return DirectoryPreview.self + } switch fileURL.pathExtension.lowercased() { case "gz": // `gzip` is only supported for tarballs diff --git a/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift b/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift index b1e772a..b894578 100644 --- a/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift +++ b/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift @@ -3,6 +3,7 @@ import Cocoa class OutlinePreviewVC: NSViewController, PreviewVC { @objc dynamic var rootNodes: [FileTreeNode] private let labelText: String? + private let expandAll: Bool @objc dynamic var customSortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] @@ -10,18 +11,20 @@ class OutlinePreviewVC: NSViewController, PreviewVC { @IBOutlet private var outlineView: NSOutlineView! @IBOutlet private var label: NSTextField! - required convenience init(rootNodes: [FileTreeNode], labelText: String?) { - self.init(nibName: nil, bundle: nil, rootNodes: rootNodes, labelText: labelText) + required convenience init(rootNodes: [FileTreeNode], labelText: String?, expandAll: Bool = false) { + self.init(nibName: nil, bundle: nil, rootNodes: rootNodes, labelText: labelText, expandAll: expandAll) } init( nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?, rootNodes: [FileTreeNode], - labelText: String? + labelText: String?, + expandAll: Bool = false ) { self.rootNodes = rootNodes self.labelText = labelText + self.expandAll = expandAll super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) // Register required value transformers @@ -38,7 +41,11 @@ class OutlinePreviewVC: NSViewController, PreviewVC { override func viewDidLoad() { super.viewDidLoad() setUpView() - expandSingleRootItem() + if expandAll { + expandAllItems() + } else { + expandSingleRootItem() + } } private func setUpView() { @@ -58,6 +65,11 @@ class OutlinePreviewVC: NSViewController, PreviewVC { outlineView.expandItem(root.children?.first!) } } + + /// Expands all items in the outline view. + private func expandAllItems() { + outlineView.expandItem(nil, expandChildren: true) + } } /// `ValueTransformer` which formats the provided date. diff --git a/QLPlugin/Views/Previews/DirectoryPreview.swift b/QLPlugin/Views/Previews/DirectoryPreview.swift new file mode 100644 index 0000000..afc05ee --- /dev/null +++ b/QLPlugin/Views/Previews/DirectoryPreview.swift @@ -0,0 +1,95 @@ +import Foundation +import os.log + +class DirectoryPreview: Preview { + let fileManager = FileManager.default + + /// Max number of items to include in the tree to avoid performance issues. + let maxItems = 500 + /// Max recursion depth. + let maxDepth = 5 + + required init() {} + + /// Directories inside temporary extraction paths (e.g. from ZIP browsing in Finder) + /// should not be previewed by our plugin. + private func isTemporaryPath(_ path: String) -> Bool { + return path.hasPrefix("/private/var/folders/") + || path.hasPrefix("/var/folders/") + || path.hasPrefix("/tmp/") + || path.hasPrefix("/private/tmp/") + } + + func createPreviewVC(file: File) throws -> PreviewVC { + if isTemporaryPath(file.path) { + throw PreviewError.fileSizeError(path: file.path) + } + + let fileTree = FileTree() + var itemCount = 0 + scanDirectory(url: file.url, fileTree: fileTree, basePath: file.url.path, depth: 0, itemCount: &itemCount) + + let childrenCount = countItems(nodes: fileTree.root.childrenList) + let labelText = "\(childrenCount) items" + + return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText, expandAll: true) + } + + private func scanDirectory(url: URL, fileTree: FileTree, basePath: String, depth: Int, itemCount: inout Int) { + guard depth < maxDepth, itemCount < maxItems else { return } + + let contents: [URL] + do { + contents = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + } catch { + os_log( + "Could not read directory %{public}s: %{public}s", + log: Log.general, + type: .error, + url.path, + error.localizedDescription + ) + return + } + + for itemURL in contents { + guard itemCount < maxItems else { return } + + let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) + let isDir = resourceValues?.isDirectory ?? false + let size = resourceValues?.fileSize ?? 0 + let dateModified = resourceValues?.contentModificationDate + + // Build relative path from the base directory + let relativePath = String(itemURL.path.dropFirst(basePath.count + 1)) + + do { + try fileTree.addNode( + path: relativePath, + isDirectory: isDir, + size: size, + dateModified: dateModified + ) + itemCount += 1 + } catch { + os_log("%{public}s", log: Log.parse, type: .error, error.localizedDescription) + } + + if isDir { + scanDirectory(url: itemURL, fileTree: fileTree, basePath: basePath, depth: depth + 1, itemCount: &itemCount) + } + } + } + + private func countItems(nodes: [FileTreeNode]) -> Int { + var count = nodes.count + for node in nodes where node.isDirectory { + count += countItems(nodes: node.childrenList) + } + return count + } +} diff --git a/README.md b/README.md index 9a8dfdb..c39cc31 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ Alternatively, you can install Glance directly. The installation is slightly com

+- **Directory**: folders are previewed as a tree view showing their contents (files, subfolders, sizes and dates) + ## FAQ **There are existing Quick Look apps for some of the supported file types. Why create another one?** From 205afde187e437741fff9230d5e5dd908b0107e4 Mon Sep 17 00:00:00 2001 From: Julien Deveaux Date: Mon, 30 Mar 2026 12:11:05 +0200 Subject: [PATCH 2/4] lint: fixes --- QLPlugin/Views/Previews/DirectoryPreview.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/QLPlugin/Views/Previews/DirectoryPreview.swift b/QLPlugin/Views/Previews/DirectoryPreview.swift index afc05ee..acc2a28 100644 --- a/QLPlugin/Views/Previews/DirectoryPreview.swift +++ b/QLPlugin/Views/Previews/DirectoryPreview.swift @@ -14,7 +14,7 @@ class DirectoryPreview: Preview { /// Directories inside temporary extraction paths (e.g. from ZIP browsing in Finder) /// should not be previewed by our plugin. private func isTemporaryPath(_ path: String) -> Bool { - return path.hasPrefix("/private/var/folders/") + path.hasPrefix("/private/var/folders/") || path.hasPrefix("/var/folders/") || path.hasPrefix("/tmp/") || path.hasPrefix("/private/tmp/") @@ -36,7 +36,9 @@ class DirectoryPreview: Preview { } private func scanDirectory(url: URL, fileTree: FileTree, basePath: String, depth: Int, itemCount: inout Int) { - guard depth < maxDepth, itemCount < maxItems else { return } + guard depth < maxDepth, itemCount < maxItems else { + return + } let contents: [URL] do { @@ -57,7 +59,9 @@ class DirectoryPreview: Preview { } for itemURL in contents { - guard itemCount < maxItems else { return } + guard itemCount < maxItems else { + return + } let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) let isDir = resourceValues?.isDirectory ?? false From 221d0ded0fd53dd16d57ed982cd4bb2d5afa70cc Mon Sep 17 00:00:00 2001 From: Julien Deveaux Date: Mon, 30 Mar 2026 12:16:15 +0200 Subject: [PATCH 3/4] update: gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 186451e..30a0e82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ xcuserdata/ # `go build` output HTMLConverter/htmlconverter*.a HTMLConverter/htmlconverter*.h + +.idea From e856e09075d8ac0238f23d1431595a82789b52f1 Mon Sep 17 00:00:00 2001 From: JulienDeveaux Date: Sun, 12 Apr 2026 22:24:29 +0200 Subject: [PATCH 4/4] fix: swiftlint and swiftformat --- QLPlugin/MainVC.swift | 5 ++- .../Views/PreviewVCs/OutlinePreviewVC.swift | 38 +++++++++++++---- .../Views/Previews/DirectoryPreview.swift | 42 ++++++++++++++++--- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index 4308dc4..7c0b606 100644 --- a/QLPlugin/MainVC.swift +++ b/QLPlugin/MainVC.swift @@ -105,7 +105,10 @@ class MainVC: NSViewController, QLPreviewingController { /// Generates a preview of the selected file and adds the corresponding child view controller. private func previewFile(file: File) throws { // Initialize `PreviewVC` for the file type - if let previewInitializerType = PreviewVCFactory.getPreviewInitializer(fileURL: file.url, isDirectory: file.isDirectory) { + if let previewInitializerType = PreviewVCFactory.getPreviewInitializer( + fileURL: file.url, + isDirectory: file.isDirectory + ) { // Generate file preview let previewInitializer = previewInitializerType.init() let previewVC = try previewInitializer.createPreviewVC(file: file) diff --git a/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift b/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift index b894578..4d383db 100644 --- a/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift +++ b/QLPlugin/Views/PreviewVCs/OutlinePreviewVC.swift @@ -11,8 +11,18 @@ class OutlinePreviewVC: NSViewController, PreviewVC { @IBOutlet private var outlineView: NSOutlineView! @IBOutlet private var label: NSTextField! - required convenience init(rootNodes: [FileTreeNode], labelText: String?, expandAll: Bool = false) { - self.init(nibName: nil, bundle: nil, rootNodes: rootNodes, labelText: labelText, expandAll: expandAll) + required convenience init( + rootNodes: [FileTreeNode], + labelText: String?, + expandAll: Bool = false + ) { + self.init( + nibName: nil, + bundle: nil, + rootNodes: rootNodes, + labelText: labelText, + expandAll: expandAll + ) } init( @@ -84,9 +94,13 @@ class DateTransformer: ValueTransformer { dateFormatter.doesRelativeDateFormatting = true } - override class func transformedValueClass() -> AnyClass { NSString.self } + override class func transformedValueClass() -> AnyClass { + NSString.self + } - override class func allowsReverseTransformation() -> Bool { false } + override class func allowsReverseTransformation() -> Bool { + false + } override func transformedValue(_ value: Any?) -> Any? { guard let date = value as? Date else { @@ -109,9 +123,13 @@ class IconTransformer: ValueTransformer { contentsOfFile: "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericDocumentIcon.icns" ) - override class func transformedValueClass() -> AnyClass { NSImage.self } + override class func transformedValueClass() -> AnyClass { + NSImage.self + } - override class func allowsReverseTransformation() -> Bool { false } + override class func allowsReverseTransformation() -> Bool { + false + } override func transformedValue(_ value: Any?) -> Any? { guard let isDirectoryNumber = value as? NSNumber else { @@ -128,9 +146,13 @@ class SizeTransformer: ValueTransformer { let byteCountFormatter = ByteCountFormatter() let fallbackValue = "--" - override class func transformedValueClass() -> AnyClass { NSString.self } + override class func transformedValueClass() -> AnyClass { + NSString.self + } - override class func allowsReverseTransformation() -> Bool { false } + override class func allowsReverseTransformation() -> Bool { + false + } override func transformedValue(_ value: Any?) -> Any? { guard let size = value as? NSNumber else { diff --git a/QLPlugin/Views/Previews/DirectoryPreview.swift b/QLPlugin/Views/Previews/DirectoryPreview.swift index acc2a28..f0a2a1f 100644 --- a/QLPlugin/Views/Previews/DirectoryPreview.swift +++ b/QLPlugin/Views/Previews/DirectoryPreview.swift @@ -27,15 +27,31 @@ class DirectoryPreview: Preview { let fileTree = FileTree() var itemCount = 0 - scanDirectory(url: file.url, fileTree: fileTree, basePath: file.url.path, depth: 0, itemCount: &itemCount) + scanDirectory( + url: file.url, + fileTree: fileTree, + basePath: file.url.path, + depth: 0, + itemCount: &itemCount + ) let childrenCount = countItems(nodes: fileTree.root.childrenList) let labelText = "\(childrenCount) items" - return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText, expandAll: true) + return OutlinePreviewVC( + rootNodes: fileTree.root.childrenList, + labelText: labelText, + expandAll: true + ) } - private func scanDirectory(url: URL, fileTree: FileTree, basePath: String, depth: Int, itemCount: inout Int) { + private func scanDirectory( + url: URL, + fileTree: FileTree, + basePath: String, + depth: Int, + itemCount: inout Int + ) { guard depth < maxDepth, itemCount < maxItems else { return } @@ -44,7 +60,11 @@ class DirectoryPreview: Preview { do { contents = try fileManager.contentsOfDirectory( at: url, - includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], + includingPropertiesForKeys: [ + .isDirectoryKey, + .fileSizeKey, + .contentModificationDateKey, + ], options: [.skipsHiddenFiles] ) } catch { @@ -63,7 +83,11 @@ class DirectoryPreview: Preview { return } - let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) + let resourceValues = try? itemURL.resourceValues(forKeys: [ + .isDirectoryKey, + .fileSizeKey, + .contentModificationDateKey, + ]) let isDir = resourceValues?.isDirectory ?? false let size = resourceValues?.fileSize ?? 0 let dateModified = resourceValues?.contentModificationDate @@ -84,7 +108,13 @@ class DirectoryPreview: Preview { } if isDir { - scanDirectory(url: itemURL, fileTree: fileTree, basePath: basePath, depth: depth + 1, itemCount: &itemCount) + scanDirectory( + url: itemURL, + fileTree: fileTree, + basePath: basePath, + depth: depth + 1, + itemCount: &itemCount + ) } } }