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 diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index 8ebdcd3..d64ef34 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -110,6 +110,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 */; }; @@ -269,6 +270,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 = ""; }; @@ -360,6 +362,7 @@ 7E1DC529240E6FDE00D0A061 /* CodePreview.swift */, 7EB7491824228549007265A4 /* JupyterPreview.swift */, 7E1DC520240E6D8000D0A061 /* MarkdownPreview.swift */, + A1B2C3D4E5F6789000112233 /* DirectoryPreview.swift */, 7E7A9DAC244F24B900276E51 /* TARPreview.swift */, 7E413F7F2418DD6200CFBB1D /* TSVPreview.swift */, 7EB36489244B665700D7F96F /* ZIPPreview.swift */, @@ -871,6 +874,7 @@ 7E9F0D7E24168870007F1008 /* Script.swift in Sources */, 7EB3648A244B665700D7F96F /* ZIPPreview.swift in Sources */, 103654F42F726241006F2538 /* SevenZipPreview.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 5f79fec..740306e 100644 --- a/QLPlugin/Info.plist +++ b/QLPlugin/Info.plist @@ -36,6 +36,9 @@ - public.xml (.xml) --> + + public.folder + org.7-zip.7-zip-archive com.sun.java-archive diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index d9bf2c9..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) { + 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 ca5d036..6f1e66b 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..4d383db 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,30 @@ 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 +51,11 @@ class OutlinePreviewVC: NSViewController, PreviewVC { override func viewDidLoad() { super.viewDidLoad() setUpView() - expandSingleRootItem() + if expandAll { + expandAllItems() + } else { + expandSingleRootItem() + } } private func setUpView() { @@ -58,6 +75,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. @@ -72,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 { @@ -97,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 { @@ -116,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 new file mode 100644 index 0000000..f0a2a1f --- /dev/null +++ b/QLPlugin/Views/Previews/DirectoryPreview.swift @@ -0,0 +1,129 @@ +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 { + 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 4447e18..01e3ed2 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?**