diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8d4807d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +name: Build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: build + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Build MacTree.app + env: + SIGN_IDENTITY: "-" + BUILD_NUMBER: ${{ github.run_number }} + run: | + swift --version + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then export VERSION="${GITHUB_REF_NAME#v}"; fi + ./scripts/build-app.sh + + - name: Smoke test + run: | + build/MacTree.app/Contents/MacOS/MacTree --bench "$GITHUB_WORKSPACE/Sources" + codesign --verify --deep --strict build/MacTree.app + + - name: Package + run: ditto -c -k --keepParent build/MacTree.app MacTree.zip + + - uses: actions/upload-artifact@v7 + with: + name: MacTree + path: MacTree.zip + if-no-files-found: error + + release: + name: release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: MacTree + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + mv MacTree.zip "MacTree-${GITHUB_REF_NAME}.zip" + cat > notes.md <<'NOTES' + WizTree-style disk space analyzer for macOS 15 or later (Apple silicon and Intel). + + **Install:** unzip, move `MacTree.app` to Applications, and open it. The build is ad-hoc signed and not notarized, so macOS blocks the first launch. To allow it, go to System Settings › Privacy & Security and click **Open Anyway**. Or run `xattr -dr com.apple.quarantine /Applications/MacTree.app`. + + For complete results, allow **Full Disk Access** when MacTree asks. + NOTES + gh release create "$GITHUB_REF_NAME" "MacTree-${GITHUB_REF_NAME}.zip" \ + --repo "$GITHUB_REPOSITORY" \ + --title "MacTree ${GITHUB_REF_NAME}" \ + --notes-file notes.md \ + --generate-notes diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..bfa3dcc --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:6.0 +import PackageDescription + +/** + * MacTree: one macOS 15+ executable built as the app binary. + * + * Release builds use -Ounchecked for the scanner's hot loops. The target links + * AppKit, Quartz (Quick Look) and UniformTypeIdentifiers (file icons). + * scripts/build-app.sh wraps the binary into MacTree.app. + */ +let package = Package( + name: "MacTree", + platforms: [.macOS(.v15)], + targets: [ + .executableTarget( + name: "MacTree", + path: "Sources/MacTree", + swiftSettings: [.unsafeFlags(["-Ounchecked"], .when(configuration: .release))], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("Quartz"), + .linkedFramework("UniformTypeIdentifiers"), + ] + ) + ], + swiftLanguageModes: [.v5] +) diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..479524a --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleLocalizations + + en + ko + + CFBundleDisplayName + MacTree + CFBundleExecutable + MacTree + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.aodjo.MacTree + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MacTree + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 15.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSDesktopFolderUsageDescription + MacTree reads folder and file sizes on your Desktop to show what is using disk space. + NSDocumentsFolderUsageDescription + MacTree reads folder and file sizes in Documents to show what is using disk space. + NSDownloadsFolderUsageDescription + MacTree reads folder and file sizes in Downloads to show what is using disk space. + NSRemovableVolumesUsageDescription + MacTree reads folder and file sizes on removable drives you choose to scan. + NSNetworkVolumesUsageDescription + MacTree reads folder and file sizes on network volumes you choose to scan. + NSHumanReadableCopyright + Disk space analyzer for macOS + CFBundleDocumentTypes + + + CFBundleTypeName + Folder + CFBundleTypeRole + Viewer + LSHandlerRank + None + LSItemContentTypes + + public.folder + public.volume + + + + + diff --git a/Resources/en.lproj/InfoPlist.strings b/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..e69de29 diff --git a/Resources/ko.lproj/InfoPlist.strings b/Resources/ko.lproj/InfoPlist.strings new file mode 100644 index 0000000..a2f24dc --- /dev/null +++ b/Resources/ko.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Shown by macOS when MacTree first reads a protected folder. */ +"NSDesktopFolderUsageDescription" = "디스크 공간을 무엇이 차지하는지 보여주기 위해 데스크탑의 폴더와 파일 크기를 읽습니다."; +"NSDocumentsFolderUsageDescription" = "디스크 공간을 무엇이 차지하는지 보여주기 위해 문서 폴더의 폴더와 파일 크기를 읽습니다."; +"NSDownloadsFolderUsageDescription" = "디스크 공간을 무엇이 차지하는지 보여주기 위해 다운로드 폴더의 폴더와 파일 크기를 읽습니다."; +"NSRemovableVolumesUsageDescription" = "스캔하도록 선택한 외장 드라이브의 폴더와 파일 크기를 읽습니다."; +"NSNetworkVolumesUsageDescription" = "스캔하도록 선택한 네트워크 볼륨의 폴더와 파일 크기를 읽습니다."; diff --git a/Sources/MacTree/App/AppDelegate.swift b/Sources/MacTree/App/AppDelegate.swift new file mode 100644 index 0000000..982575b --- /dev/null +++ b/Sources/MacTree/App/AppDelegate.swift @@ -0,0 +1,261 @@ +import AppKit + +/** + * Application delegate: builds the menu bar, owns the main window and handles + * launch arguments, Dock drops and quit requests. + */ +final class AppDelegate: NSObject, NSApplicationDelegate { + /** The single main window. */ + private var windowController: MainWindowController? + /** Polls for the end of the scan in `--snapshot` mode. */ + private var snapshotTimer: Timer? + /** A folder dropped on the Dock icon before the window existed. */ + private var pendingOpen: URL? + + /** + * Installs a custom handler for the "quit" Apple Event before launch finishes. + * + * System Settings' "Quit & Reopen" (shown after granting Full Disk Access) + * sends a quit Apple Event. AppKit's default handler refuses to quit while + * a sheet is attached, which would leave the app running behind the + * permission sheet, so the event is routed through `handleQuitEvent`. + * + * @param {Notification} notification - The will-finish-launching notification. + * + * @example + * // Called by AppKit during launch, before applicationDidFinishLaunching(_:). + */ + func applicationWillFinishLaunching(_ notification: Notification) { + NSAppleEventManager.shared().setEventHandler( + self, andSelector: #selector(handleQuitEvent(_:withReplyEvent:)), + forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEQuitApplication)) + } + + /** + * Quits in response to a quit Apple Event, closing any sheets first. + * + * @param {NSAppleEventDescriptor} event - The quit event. + * @param {NSAppleEventDescriptor} reply - The reply event (unused). + * + * @example + * NSRunningApplication(processIdentifier: pid)?.terminate() // arrives here + */ + @objc private func handleQuitEvent(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { + NSApp.terminateClosingSheets() + } + + /** + * Builds the menu bar and the main window, then acts on launch arguments. + * + * Normally shows and activates the window and, on the next run-loop turn, + * asks for Full Disk Access if it is missing. `--scan ` starts a scan + * right away; otherwise a folder dropped on the Dock icon during launch is + * scanned. + * + * Debug aids: `--snapshot ` keeps the window behind other windows + * (no activation, no permission sheet) and saves a picture of it. With + * `--snapshot-at ` the picture is taken at a fixed time, for + * example to capture a scan in progress; otherwise it is taken 2.5 s after + * the scan finishes and after the other debug arguments were applied. The + * app then quits unless `--keep-open` is given. + * + * @param {Notification} notification - The did-finish-launching notification. + * + * @example + * // MacTree --scan /Applications --snapshot /tmp/window.png + */ + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.mainMenu = buildMainMenu() + let wc = MainWindowController() + windowController = wc + + let args = CommandLine.arguments + let snapshotPath = args.firstIndex(of: "--snapshot").flatMap { $0 + 1 < args.count ? args[$0 + 1] : nil } + if snapshotPath != nil { + wc.window?.orderBack(nil) + } else { + wc.showWindow(nil) + NSApp.activate() + DispatchQueue.main.async { wc.requestFullDiskAccessIfNeeded() } + } + + if let i = args.firstIndex(of: "--scan"), i + 1 < args.count { + wc.startScan(URL(fileURLWithPath: args[i + 1])) + } else if let url = pendingOpen { + pendingOpen = nil + wc.startScan(url) + } + if let snapshotPath, let i = args.firstIndex(of: "--snapshot-at"), i + 1 < args.count, + let delay = Double(args[i + 1]) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + wc.saveSnapshot(to: snapshotPath) + NSApp.terminateClosingSheets() + } + } else if let snapshotPath { + snapshotTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] t in + guard wc.scanner == nil, wc.result != nil else { return } + t.invalidate() + self?.snapshotTimer = nil + wc.applyDebugArguments(args) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + wc.saveSnapshot(to: snapshotPath) + if !args.contains("--keep-open") { NSApp.terminateClosingSheets() } + } + } + } + } + + /** + * Quits when the main window closes; the app has no window-less mode. + * + * @param {NSApplication} sender - The application. + * @returns {Bool} Always true. + * + * @example + * // Closing the window with ⌘W ends the app. + */ + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + /** + * Menu action for "Quit MacTree" (⌘Q). + * + * Goes through `terminateClosingSheets()` so quitting also works while the + * permission sheet is open. + * + * @param {Any?} sender - The menu item. + * + * @example + * appMenu.addItem(withTitle: L.menuQuit, action: #selector(quit(_:)), keyEquivalent: "q") + */ + @objc func quit(_ sender: Any?) { + NSApp.terminateClosingSheets() + } + + /** + * Scans a folder or volume dropped on the Dock icon or opened with the app. + * + * Only the first URL is used. If the window does not exist yet (the open + * request arrived during launch), the URL is kept and scanned once + * `applicationDidFinishLaunching` runs. + * + * @param {NSApplication} sender - The application. + * @param {[URL]} urls - The items to open. + * + * @example + * // Dropping ~/Downloads on the Dock icon scans ~/Downloads. + */ + func application(_ sender: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + if let wc = windowController { wc.startScan(url) } else { pendingOpen = url } + } + + // MARK: Menu + + /** + * Builds the menu bar: app, File, Edit, View and Window menus. + * + * Most items target nil so they travel the responder chain to the main + * window controller, which validates them. "Move to Trash" uses ⌘⌫, Show + * in Finder ⇧⌘R, Copy Path ⌥⌘C and treemap zoom out ⌘↑. + * + * @returns {NSMenu} The main menu. + * + * @example + * NSApp.mainMenu = buildMainMenu() + */ + private func buildMainMenu() -> NSMenu { + let main = NSMenu() + + let appMenu = NSMenu() + appMenu.addItem(withTitle: L.menuAbout, action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: L.menuFullDiskAccess, action: #selector(MainWindowController.showFullDiskAccess(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: L.menuHide, action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthers = appMenu.addItem(withTitle: L.menuHideOthers, action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: L.menuShowAll, action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: L.menuQuit, action: #selector(quit(_:)), keyEquivalent: "q") + main.addItem(submenu: appMenu, title: L.appName) + + let file = NSMenu(title: L.menuFile) + file.addItem(withTitle: L.menuScanFolder, action: #selector(MainWindowController.chooseFolder(_:)), keyEquivalent: "o") + file.addItem(withTitle: L.rescan, action: #selector(MainWindowController.rescanAll(_:)), keyEquivalent: "r") + file.addItem(withTitle: L.stop, action: #selector(MainWindowController.stopScan(_:)), keyEquivalent: ".") + file.addItem(withTitle: L.exportCSV, action: #selector(MainWindowController.exportCSV(_:)), keyEquivalent: "e") + file.addItem(.separator()) + file.addItem(withTitle: L.open, action: #selector(MainWindowController.openItems(_:)), keyEquivalent: "") + let reveal = file.addItem(withTitle: L.revealInFinder, action: #selector(MainWindowController.revealInFinder(_:)), keyEquivalent: "r") + reveal.keyEquivalentModifierMask = [.command, .shift] + file.addItem(withTitle: L.quickLook, action: #selector(MainWindowController.quickLook(_:)), keyEquivalent: "y") + file.addItem(withTitle: L.rescanFolder, action: #selector(MainWindowController.rescanFolder(_:)), keyEquivalent: "") + file.addItem(.separator()) + let trash = file.addItem(withTitle: L.moveToTrash, action: #selector(MainWindowController.moveToTrash(_:)), + keyEquivalent: String(UnicodeScalar(NSBackspaceCharacter)!)) + trash.keyEquivalentModifierMask = [.command] + file.addItem(.separator()) + file.addItem(withTitle: L.menuClose, action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + main.addItem(submenu: file, title: L.menuFile) + + let edit = NSMenu(title: L.menuEdit) + edit.addItem(withTitle: L.t("Undo", "실행 취소"), action: Selector(("undo:")), keyEquivalent: "z") + edit.addItem(.separator()) + edit.addItem(withTitle: L.t("Cut", "오려두기"), action: #selector(NSText.cut(_:)), keyEquivalent: "x") + edit.addItem(withTitle: L.menuCopy, action: #selector(NSText.copy(_:)), keyEquivalent: "c") + let copyPath = edit.addItem(withTitle: L.copyPath, action: #selector(MainWindowController.copyPath(_:)), keyEquivalent: "c") + copyPath.keyEquivalentModifierMask = [.command, .option] + edit.addItem(withTitle: L.t("Paste", "붙이기"), action: #selector(NSText.paste(_:)), keyEquivalent: "v") + edit.addItem(withTitle: L.menuSelectAll, action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + edit.addItem(.separator()) + edit.addItem(withTitle: L.menuFind, action: #selector(MainWindowController.focusSearch(_:)), keyEquivalent: "f") + main.addItem(submenu: edit, title: L.menuEdit) + + let view = NSMenu(title: L.menuView) + let treeItem = view.addItem(withTitle: L.menuShowTree, action: #selector(MainWindowController.viewModeChanged(_:)), keyEquivalent: "1") + treeItem.tag = 0 + let filesItem = view.addItem(withTitle: L.menuShowFiles, action: #selector(MainWindowController.viewModeChanged(_:)), keyEquivalent: "2") + filesItem.tag = 1 + view.addItem(.separator()) + let logical = view.addItem(withTitle: L.menuUseLogical, action: #selector(MainWindowController.sizeModeChanged(_:)), keyEquivalent: "") + logical.tag = SizeMode.logical.rawValue + let allocated = view.addItem(withTitle: L.menuUseAllocated, action: #selector(MainWindowController.sizeModeChanged(_:)), keyEquivalent: "") + allocated.tag = SizeMode.allocated.rawValue + view.addItem(.separator()) + let zoomOut = view.addItem(withTitle: L.zoomOut, action: #selector(MainWindowController.zoomOut(_:)), keyEquivalent: String(UnicodeScalar(NSUpArrowFunctionKey)!)) + zoomOut.keyEquivalentModifierMask = [.command] + view.addItem(withTitle: L.zoomReset, action: #selector(MainWindowController.zoomReset(_:)), keyEquivalent: "0") + view.addItem(.separator()) + let fs = view.addItem(withTitle: L.menuFullScreen, action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + fs.keyEquivalentModifierMask = [.command, .control] + main.addItem(submenu: view, title: L.menuView) + + let window = NSMenu(title: L.menuWindow) + window.addItem(withTitle: L.menuMinimize, action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + window.addItem(withTitle: L.menuZoom, action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + main.addItem(submenu: window, title: L.menuWindow) + NSApp.windowsMenu = window + + return main + } +} + +private extension NSMenu { + /** + * Appends a top-level item that opens `submenu`. + * + * Also sets the submenu's title, which AppKit shows for the menu bar entry. + * + * @param {NSMenu} submenu - The menu to attach. + * @param {String} title - Title of the menu bar entry. + * + * @example + * main.addItem(submenu: fileMenu, title: L.menuFile) + */ + func addItem(submenu: NSMenu, title: String) { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + submenu.title = title + item.submenu = submenu + addItem(item) + } +} diff --git a/Sources/MacTree/App/L10n.swift b/Sources/MacTree/App/L10n.swift new file mode 100644 index 0000000..f5a0ec4 --- /dev/null +++ b/Sources/MacTree/App/L10n.swift @@ -0,0 +1,439 @@ +import Foundation + +/** + * Minimal English / Korean localisation. + * + * The language is picked once from the user's first preferred language; + * everything else falls back to English. + */ +enum L { + /** Whether the UI shows Korean text; decided once at launch. */ + static let isKorean: Bool = Locale.preferredLanguages.first?.hasPrefix("ko") ?? false + + /** + * Picks the English or Korean variant of a string. + * + * @param {String} en - English text. + * @param {String} ko - Korean text. + * @returns {String} `ko` when the UI language is Korean, otherwise `en`. + * + * @example + * L.t("Scan", "스캔") // "스캔" on a Korean system + */ + @inline(__always) static func t(_ en: String, _ ko: String) -> String { isKorean ? ko : en } + + // MARK: General + + /** Application name, shown in the window title and menus. */ + static var appName: String { "MacTree" } + /** File-type list label for files without an extension. */ + static var noExtension: String { t("(no extension)", "(확장자 없음)") } + + // MARK: Toolbar + + /** Toolbar button that starts a scan. */ + static var scan: String { t("Scan", "스캔") } + /** Toolbar button and menu item that stop a running scan. */ + static var stop: String { t("Stop", "중지") } + /** File menu item that rescans the current location. */ + static var rescan: String { t("Rescan", "다시 스캔") } + /** Location pop-up entry that opens a folder picker. */ + static var chooseFolder: String { t("Choose Folder…", "폴더 선택…") } + /** Toolbar label of the location pop-up. */ + static var location: String { t("Location", "위치") } + /** Placeholder of the toolbar search field. */ + static var searchPlaceholder: String { t("Search files (e.g. *.mov, cache)", "파일 검색 (예: *.mov, cache)") } + /** Toolbar label of the size-mode switch. */ + static var sizeModeLabel: String { t("Size", "크기 기준") } + /** Size-mode segment for logical size. */ + static var sizeModeLogical: String { t("Size", "크기") } + /** Size-mode segment for allocated size. */ + static var sizeModeAllocated: String { t("Allocated", "할당 크기") } + + // MARK: Tabs + + /** View switch segment for the folder tree. */ + static var treeView: String { t("Tree View", "트리 보기") } + /** View switch segment for the flat file list. */ + static var fileView: String { t("File View", "파일 보기") } + + // MARK: Columns + + /** Column header: item name. */ + static var colName: String { t("Name", "이름") } + /** Column header: share of the parent folder. */ + static var colPercent: String { t("% of Parent", "상위 대비 %") } + /** Column header: logical size. */ + static var colSize: String { t("Size", "크기") } + /** Column header: allocated size. */ + static var colAllocated: String { t("Allocated", "할당 크기") } + /** Column header: files plus folders below an item. */ + static var colItems: String { t("Items", "항목") } + /** Column header: file count. */ + static var colFiles: String { t("Files", "파일") } + /** Column header: folder count. */ + static var colFolders: String { t("Folders", "폴더") } + /** Column header: modification date. */ + static var colModified: String { t("Modified", "수정일") } + /** Column header: containing folder in the File View and the unreadable-folders list. */ + static var colPath: String { t("Folder", "위치") } + /** Column header of the file-type list. */ + static var colExtension: String { t("Extension", "확장자") } + /** Column header: share of the whole scan in the file-type list. */ + static var colPercentTotal: String { t("%", "%") } + + // MARK: Status / summary + + /** Idle hint in the summary bar, status bar and empty treemap. */ + static var ready: String { t("Choose a volume or folder, then press Scan.", "볼륨이나 폴더를 선택한 뒤 스캔을 누르세요.") } + /** Progress prefix while scanning. */ + static var scanning: String { t("Scanning", "스캔 중") } + /** Summary bar: volume capacity. */ + static var total: String { t("Total", "전체") } + /** Summary bar: space in use. */ + static var used: String { t("Used", "사용") } + /** Summary bar: free space. */ + static var free: String { t("Free", "여유") } + /** Summary bar: bytes found by the scan. */ + static var scanned: String { t("Scanned", "스캔됨") } + /** Lower-case unit after a file count. */ + static var files: String { t("files", "파일") } + /** Lower-case unit after a folder count. */ + static var folders: String { t("folders", "폴더") } + /** Appended to the scan statistics when the scan was stopped early. */ + static var cancelledNote: String { t("(stopped — partial results)", "(중지됨 — 일부 결과)") } + + /** + * Status bar text for the File View when no filter hides anything. + * + * @param {Int} n - Number of files listed. + * @returns {String} e.g. "1,076,094 files". + * + * @example + * status.rightLabel.stringValue = L.filesMatched(total) + */ + static func filesMatched(_ n: Int) -> String { t("\(Fmt.count(n)) files", "파일 \(Fmt.count(n))개") } + + /** + * Status bar text for the File View while a search filter is active. + * + * @param {Int} shown - Files that match the filter. + * @param {Int} total - All files in the scan. + * @returns {String} e.g. "4,771 of 1,076,094 files". + * + * @example + * status.rightLabel.stringValue = L.filesShown(4_771, 1_076_094) + */ + static func filesShown(_ shown: Int, _ total: Int) -> String { + t("\(Fmt.count(shown)) of \(Fmt.count(total)) files", "파일 \(Fmt.count(total))개 중 \(Fmt.count(shown))개") + } + + // MARK: Context menu + + /** Context menu: open with the default app. */ + static var open: String { t("Open", "열기") } + /** Context and File menu: reveal in Finder. */ + static var revealInFinder: String { t("Show in Finder", "Finder에서 보기") } + /** Context and File menu: Quick Look preview. */ + static var quickLook: String { t("Quick Look", "훑어보기") } + /** Context and Edit menu: copy the path. */ + static var copyPath: String { t("Copy Path", "경로 복사") } + /** Context and File menu, and the confirmation button: move to the Trash. */ + static var moveToTrash: String { t("Move to Trash", "휴지통으로 이동") } + /** Context and File menu: rescan one folder. */ + static var rescanFolder: String { t("Rescan This Folder", "이 폴더 다시 스캔") } + /** Context menu: show a folder on its own in the treemap. */ + static var zoomTreemap: String { t("Zoom Treemap Here", "트리맵 확대") } + /** Treemap header button and View menu: zoom out. */ + static var zoomOut: String { t("Zoom Out", "축소") } + /** Treemap header button and View menu: show the whole scan. */ + static var zoomReset: String { t("Show Whole Tree", "전체 보기") } + /** Context menu on a file: list all files of its type. */ + static var showFilesOfType: String { t("Show Files of This Type", "이 형식의 파일 보기") } + + // MARK: Alerts + + /** + * Title of the Move to Trash confirmation. + * + * @param {Int} n - Number of items to move. + * @returns {String} Singular wording for one item, plural otherwise. + * + * @example + * alert.messageText = L.trashConfirmTitle(targets.count) + */ + static func trashConfirmTitle(_ n: Int) -> String { + n == 1 ? t("Move this item to the Trash?", "이 항목을 휴지통으로 이동할까요?") + : t("Move \(n) items to the Trash?", "\(n)개 항목을 휴지통으로 이동할까요?") + } + + /** + * Body of the Move to Trash confirmation. + * + * @param {String} size - Formatted total size of the items. + * @returns {String} e.g. "12.3 GB will be moved to the Trash." + * + * @example + * alert.informativeText = L.trashConfirmBody(Fmt.bytes(total)) + */ + static func trashConfirmBody(_ size: String) -> String { + t("\(size) will be moved to the Trash.", "\(size)이(가) 휴지통으로 이동됩니다.") + } + + /** Cancel button in alerts and sheets. */ + static var cancel: String { t("Cancel", "취소") } + /** Title of the alert listing items that could not be trashed. */ + static var trashFailed: String { t("Could not move to Trash", "휴지통으로 이동하지 못했습니다") } + + // MARK: Permanent deletion + + /** Toolbar button, left of the search field, that deletes the marked items. */ + static var deletePermanently: String { t("Delete Permanently", "완전히 삭제") } + /** Tooltip of the Delete Permanently button while nothing is marked. */ + static var deleteHint: String { + t("Select items and press Delete to mark them, then click here to delete them permanently.", + "항목을 선택하고 Delete 키로 삭제 대상을 표시한 뒤, 여기를 누르면 완전히 삭제합니다.") + } + /** Context menu: mark the selection for permanent deletion. */ + static var markForDeletion: String { t("Mark for Deletion", "삭제 대상으로 표시") } + /** Context menu: remove the deletion mark. */ + static var unmarkForDeletion: String { t("Unmark for Deletion", "삭제 표시 해제") } + /** Status bar text while marked items are being deleted. */ + static var deleting: String { t("Deleting…", "삭제 중…") } + /** Title of the alert listing items that could not be deleted. */ + static var deleteFailed: String { t("Some items could not be deleted", "일부 항목을 삭제하지 못했습니다") } + /** Note under the failed deletions: a folder may be half-deleted and its size stale. */ + static var deletePartialHint: String { + t("A folder that failed may have been partly deleted; use Rescan This Folder to refresh its size.", + "실패한 폴더는 일부만 삭제됐을 수 있습니다. ‘이 폴더 다시 스캔’으로 크기를 새로 고치세요.") + } + + /** + * Title of the Delete Permanently button while items are marked. + * + * @param {Int} n - Number of marked items. + * @returns {String} e.g. "Delete Permanently (3)". + * + * @example + * deleteButton.title = L.deletePermanentlyCount(3) + */ + static func deletePermanentlyCount(_ n: Int) -> String { + t("Delete Permanently (\(n))", "완전히 삭제 (\(n))") + } + + /** + * Summary of the marked items for the status bar and button tooltip. + * + * @param {Int} n - Number of marked items. + * @param {String} size - Their formatted total size. + * @returns {String} e.g. "3 items marked for deletion · 12.3 GB". + * + * @example + * status.label.stringValue = L.markedSummary(3, "12.3 GB") + */ + static func markedSummary(_ n: Int, _ size: String) -> String { + t("\(n) items marked for deletion · \(size)", "삭제 대상 \(n)개 · \(size)") + } + + /** + * Title of the Delete Permanently confirmation. + * + * @param {Int} n - Number of items to delete. + * @returns {String} Singular wording for one item, plural otherwise. + * + * @example + * alert.messageText = L.deleteConfirmTitle(targets.count) + */ + static func deleteConfirmTitle(_ n: Int) -> String { + n == 1 ? t("Delete this item permanently?", "이 항목을 완전히 삭제할까요?") + : t("Delete \(n) items permanently?", "\(n)개 항목을 완전히 삭제할까요?") + } + + /** + * Body of the Delete Permanently confirmation. + * + * @param {String} size - Formatted total size of the items. + * @returns {String} A warning that the deletion is immediate and cannot be undone. + * + * @example + * alert.informativeText = L.deleteConfirmBody(Fmt.bytes(total)) + */ + static func deleteConfirmBody(_ size: String) -> String { + t("\(size) will be deleted immediately, without going to the Trash. This can't be undone.", + "\(size)이(가) 휴지통을 거치지 않고 즉시 삭제됩니다. 되돌릴 수 없습니다.") + } + + // MARK: Menus + + /** App menu: About. */ + static var menuAbout: String { t("About MacTree", "MacTree 정보") } + /** App menu: Hide. */ + static var menuHide: String { t("Hide MacTree", "MacTree 가리기") } + /** App menu: Hide Others. */ + static var menuHideOthers: String { t("Hide Others", "기타 가리기") } + /** App menu: Show All. */ + static var menuShowAll: String { t("Show All", "모두 보기") } + /** App menu: Quit. */ + static var menuQuit: String { t("Quit MacTree", "MacTree 종료") } + /** File menu title. */ + static var menuFile: String { t("File", "파일") } + /** Edit menu title. */ + static var menuEdit: String { t("Edit", "편집") } + /** View menu title. */ + static var menuView: String { t("View", "보기") } + /** Window menu title. */ + static var menuWindow: String { t("Window", "윈도우") } + /** File menu: pick a folder and scan it. */ + static var menuScanFolder: String { t("Scan Folder…", "폴더 스캔…") } + /** File menu: close the window. */ + static var menuClose: String { t("Close Window", "윈도우 닫기") } + /** Edit menu: Copy. */ + static var menuCopy: String { t("Copy", "복사") } + /** Edit menu: Select All. */ + static var menuSelectAll: String { t("Select All", "전체 선택") } + /** Edit menu: focus the search field. */ + static var menuFind: String { t("Find", "찾기") } + /** View menu: measure by logical size. */ + static var menuUseLogical: String { t("Measure by Size", "크기 기준으로 표시") } + /** View menu: measure by allocated size. */ + static var menuUseAllocated: String { t("Measure by Allocated Size", "할당 크기 기준으로 표시") } + /** Window menu: Minimize. */ + static var menuMinimize: String { t("Minimize", "최소화") } + /** Window menu: Zoom. */ + static var menuZoom: String { t("Zoom", "확대/축소") } + /** View menu: full screen. */ + static var menuFullScreen: String { t("Enter Full Screen", "전체 화면 시작") } + /** View menu: show the Tree View. */ + static var menuShowTree: String { t("Tree View", "트리 보기") } + /** View menu: show the File View. */ + static var menuShowFiles: String { t("File View", "파일 보기") } + + // MARK: Export + + /** File menu: export the scan as CSV. */ + static var exportCSV: String { t("Export CSV…", "CSV로 내보내기…") } + /** Status bar prefix while exporting. */ + static var exporting: String { t("Exporting", "내보내는 중") } + + /** + * Status bar text after a successful export. + * + * @param {Int} n - Number of rows written. + * @param {String} path - Destination file path. + * @returns {String} e.g. "Exported 1,231,942 rows to /Users/me/scan.csv". + * + * @example + * status.label.stringValue = L.exported(rows, url.path) + */ + static func exported(_ n: Int, _ path: String) -> String { + t("Exported \(Fmt.count(n)) rows to \(path)", "\(Fmt.count(n))행을 내보냈습니다: \(path)") + } + + /** Title of the export error alert. */ + static var exportFailed: String { t("Export failed", "내보내기에 실패했습니다") } + + // MARK: Full Disk Access + + /** Title of the Full Disk Access sheet. */ + static var fdaTitle: String { t("Allow Full Disk Access", "전체 디스크 접근 권한 허용") } + /** Explanation at the top of the Full Disk Access sheet. */ + static var fdaBody: String { + t("To measure the whole disk accurately, MacTree needs Full Disk Access. Without it, macOS hides folders such as Mail, Messages, Safari and other apps' data, and asks separately before MacTree can read Desktop, Documents and Downloads.", + "디스크 전체를 정확하게 분석하려면 전체 디스크 접근 권한이 필요합니다. 권한이 없으면 macOS가 메일, 메시지, Safari, 다른 앱의 데이터 같은 폴더를 숨기고, 데스크탑·문서·다운로드 폴더는 읽기 전에 따로 허용을 묻습니다.") + } + /** Full Disk Access sheet, step 1. */ + static var fdaStep1: String { t("Click “Open System Settings”.", "‘시스템 설정 열기’를 누릅니다.") } + /** Full Disk Access sheet, step 2. */ + static var fdaStep2: String { + t("Turn on MacTree in the list. If it isn't listed, drag the icon on the right into the list (or click + and choose MacTree).", + "목록에서 MacTree를 켭니다. 목록에 없으면 오른쪽 아이콘을 목록으로 끌어다 놓으세요(또는 + 버튼으로 MacTree 추가).") + } + /** Full Disk Access sheet, step 3. */ + static var fdaStep3: String { t("MacTree notices the change automatically.", "허용하면 MacTree가 자동으로 감지합니다.") } + /** Status line while the sheet waits for the grant. */ + static var fdaWaiting: String { t("Waiting for Full Disk Access…", "권한 허용을 기다리는 중…") } + /** Status line once the grant is usable. */ + static var fdaGranted: String { t("Full Disk Access is on.", "전체 디스크 접근 권한이 허용되었습니다.") } + /** Hint above the relaunch link while still waiting. */ + static var fdaRelaunchHint: String { t("Turned it on but still waiting?", "허용했는데 계속 기다리는 중인가요?") } + /** Status line when the grant exists but needs a relaunch to apply. */ + static var fdaGrantedNeedsRelaunch: String { + t("Access is on. Relaunch MacTree to apply it.", "권한이 허용되었습니다. 적용하려면 MacTree를 다시 시작하세요.") + } + /** Button that relaunches the app. */ + static var fdaRelaunch: String { t("Relaunch MacTree", "MacTree 다시 시작") } + /** Button that opens System Settings at Full Disk Access. */ + static var fdaOpenSettings: String { t("Open System Settings", "시스템 설정 열기") } + /** Button that dismisses the sheet without access. */ + static var fdaLater: String { t("Not Now", "나중에") } + /** Checkbox that stops the sheet from appearing at launch. */ + static var fdaDontAsk: String { t("Don't ask at launch", "실행할 때 묻지 않기") } + /** Button that closes the sheet once access is granted. */ + static var fdaDone: String { t("Done", "완료") } + /** Caption under the draggable app icon. */ + static var fdaDragHint: String { t("Drag into the list", "목록으로 드래그") } + /** App menu item that reopens the Full Disk Access sheet. */ + static var menuFullDiskAccess: String { t("Full Disk Access…", "전체 디스크 접근 권한…") } + + /** + * Summary bar warning when missing Full Disk Access hid folders. + * + * @param {Int} n - Number of unreadable folders. + * @returns {String} A call to action that opens the permission sheet. + * + * @example + * warningButton.title = L.deniedNeedsAccess(result.deniedCount) + */ + static func deniedNeedsAccess(_ n: Int) -> String { + t("\(Fmt.count(n)) folders could not be read — Allow Full Disk Access…", + "권한이 없어 폴더 \(Fmt.count(n))개를 읽지 못함 — 권한 허용…") + } + + /** + * Summary bar note when only protected system folders were skipped. + * + * @param {Int} n - Number of unreadable folders. + * @returns {String} A quiet note that opens the list of skipped folders. + * + * @example + * warningButton.title = L.deniedSystem(result.deniedCount) + */ + static func deniedSystem(_ n: Int) -> String { + t("\(Fmt.count(n)) protected system folders skipped", "시스템 보호 폴더 \(Fmt.count(n))개 제외됨") + } + + /** + * Title of the unreadable-folders sheet. + * + * @param {Int} n - Number of unreadable folders. + * @returns {String} e.g. "288 folders could not be read". + * + * @example + * title.stringValue = L.deniedTitle(totalCount) + */ + static func deniedTitle(_ n: Int) -> String { + t("\(Fmt.count(n)) folders could not be read", "읽지 못한 폴더 \(Fmt.count(n))개") + } + + /** Explanation in the unreadable-folders sheet. */ + static var deniedBody: String { + t("These are system folders that macOS protects (System Integrity Protection) or that only the administrator account (root) can open. They are usually small and are left out of the totals.", + "macOS가 보호하는(시스템 무결성 보호) 폴더이거나 관리자(root) 계정만 열 수 있는 시스템 폴더입니다. 대부분 용량이 작으며 합계에서 제외됩니다.") + } + /** Column header for why a folder was unreadable. */ + static var deniedReason: String { t("Reason", "이유") } + /** Reason: privacy protection or System Integrity Protection. */ + static var reasonProtected: String { t("Protected by macOS", "macOS 보호") } + /** Reason: Unix permissions allow only root. */ + static var reasonRootOnly: String { t("Administrator (root) only", "관리자(root) 전용") } + /** Close button of the unreadable-folders sheet. */ + static var close: String { t("Close", "닫기") } + + // MARK: Treemap + + /** Mouse hint on the right of the treemap header. */ + static var treemapHint: String { + t("Click: select · Delete: mark for deletion · Scroll: zoom · Drag: pan · Double-click: open folder · Right-click: menu", + "클릭: 선택 · Delete: 삭제 표시 · 휠: 확대/축소 · 드래그: 이동 · 더블클릭: 폴더 열기 · 우클릭: 메뉴") + } +} diff --git a/Sources/MacTree/App/Permissions.swift b/Sources/MacTree/App/Permissions.swift new file mode 100644 index 0000000..3e75511 --- /dev/null +++ b/Sources/MacTree/App/Permissions.swift @@ -0,0 +1,177 @@ +import AppKit + +/** + * Full Disk Access (TCC "SystemPolicyAllFiles"). + * + * macOS has no API that shows a Full Disk Access prompt, so the app probes a + * file that only processes with the grant can open and guides the user to + * System Settings. + */ +enum FullDiskAccess { + /** Files readable only with Full Disk Access; opening them never triggers a prompt. */ + private static var probes: [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + home + "/Library/Application Support/com.apple.TCC/TCC.db", + "/Library/Application Support/com.apple.TCC/TCC.db", + home + "/Library/Safari/Bookmarks.plist", + ] + } + + /** + * Whether this process can read files protected by Full Disk Access. + * + * Opens each probe file in turn. EPERM means privacy protection refused + * the read, so access is missing; a missing file or any other error is + * inconclusive and the next probe is tried. A grant made after launch is + * not visible here until the app restarts (see `probeInFreshProcess`). + * Setting `MACTREE_ASSUME_NO_FDA` forces false, which lets the permission + * UI be tested on a Mac that already has access. + */ + static var isGranted: Bool { + if ProcessInfo.processInfo.environment[assumeDeniedVariable] != nil { return false } + for path in probes { + let fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + if fd >= 0 { + close(fd) + return true + } + if errno == EPERM { return false } + } + return false + } + + /** + * Checks for Full Disk Access from a newly started child process. + * + * macOS applies a new grant only to processes started after it, so this + * detects a grant that the running app cannot use until it is relaunched. + * Runs this executable with `probeArgument` on a background queue; the + * child does not inherit `MACTREE_ASSUME_NO_FDA`, so it reports the real + * state. The completion handler runs on the main queue. + * + * @param {(Bool) -> Void} completion - Receives true if the child could read the probe files. + * + * @example + * FullDiskAccess.probeInFreshProcess { granted in + * if granted && !FullDiskAccess.isGranted { showRelaunchPrompt() } + * } + */ + static func probeInFreshProcess(completion: @escaping (Bool) -> Void) { + guard let executable = Bundle.main.executableURL else { + completion(false) + return + } + DispatchQueue.global(qos: .utility).async { + let task = Process() + task.executableURL = executable + task.arguments = [probeArgument] + var env = ProcessInfo.processInfo.environment + env[assumeDeniedVariable] = nil + task.environment = env + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + var granted = false + if (try? task.run()) != nil { + task.waitUntilExit() + granted = task.terminationStatus == 0 + } + DispatchQueue.main.async { completion(granted) } + } + } + + /** Environment variable that makes `isGranted` report false in this process only. */ + private static let assumeDeniedVariable = "MACTREE_ASSUME_NO_FDA" + + /** Command-line flag handled in main.swift; the process exits with status 0 when access is granted. */ + static let probeArgument = "--probe-full-disk-access" + + /** + * Opens System Settings at Privacy & Security › Full Disk Access. + * + * Tries the current settings URL scheme first and falls back to the older + * System Preferences one; stops at the first URL the system accepts. + * + * @example + * FullDiskAccess.openSettings() + */ + static func openSettings() { + let urls = [ + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + ] + for s in urls { + if let url = URL(string: s), NSWorkspace.shared.open(url) { return } + } + } + + /** + * Quits and reopens the app, optionally rescanning a folder on start. + * + * A new Full Disk Access grant only applies to a fresh process. A small + * shell script waits for this process to exit and then reopens the bundle + * with `open`, passing `--scan` when a path is given. Outside an .app + * bundle there is nothing to reopen, so the app just quits. Attached + * sheets are closed first because AppKit refuses to quit while one is open. + * + * @param {String?} path - Folder to scan after the relaunch, or nil to start idle. + * + * @example + * FullDiskAccess.relaunch(scanning: result?.rootPath) + */ + static func relaunch(scanning path: String?) { + let bundle = Bundle.main.bundlePath + guard bundle.hasSuffix(".app") else { + NSApp.terminateClosingSheets() + return + } + var script = "while kill -0 \(getpid()) 2>/dev/null; do sleep 0.1; done; /usr/bin/open \"$0\"" + var args = [bundle] + if let path { + script += " --args --scan \"$1\"" + args.append(path) + } + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/sh") + task.arguments = ["-c", script] + args + try? task.run() + NSApp.terminateClosingSheets() + } + + /** Whether the app runs from an .app bundle and can therefore reopen itself. */ + static var canRelaunch: Bool { Bundle.main.bundlePath.hasSuffix(".app") } + + /** User-defaults key for "Don't ask at launch". */ + private static let skipKey = "skipFullDiskAccessPrompt" + + /** Whether the user ticked "Don't ask at launch"; persisted in user defaults. */ + static var promptSuppressed: Bool { + get { UserDefaults.standard.bool(forKey: skipKey) } + set { UserDefaults.standard.set(newValue, forKey: skipKey) } + } +} + +extension NSApplication { + /** + * Quits the app even when a sheet is open. + * + * AppKit silently refuses to terminate while a window has a sheet + * attached, which would break ⌘Q, the relaunch button and System + * Settings' "Quit & Reopen". Ends every attached sheet first (with a small + * cap in case a sheet keeps re-presenting itself), then terminates. + * + * @example + * NSApp.terminateClosingSheets() + */ + func terminateClosingSheets() { + for window in windows { + var guardCount = 0 + while let sheet = window.attachedSheet, guardCount < 8 { + window.endSheet(sheet) + sheet.orderOut(nil) + guardCount += 1 + } + } + terminate(nil) + } +} diff --git a/Sources/MacTree/App/main.swift b/Sources/MacTree/App/main.swift new file mode 100644 index 0000000..4f00bee --- /dev/null +++ b/Sources/MacTree/App/main.swift @@ -0,0 +1,101 @@ +import AppKit + +/** Command-line arguments, checked for the headless modes before the UI starts. */ +let arguments = CommandLine.arguments + +/** + * Child-process permission probe, run by `FullDiskAccess.probeInFreshProcess`. + * + * Exits with status 0 when Full Disk Access is granted and 1 otherwise, + * before any UI is created, so the parent can detect a grant that only + * applies to newly started processes. + */ +if arguments.contains(FullDiskAccess.probeArgument) { + exit(FullDiskAccess.isGranted ? 0 : 1) +} + +/** + * Headless benchmark: `MacTree --bench /path [threads]`. + * + * Scans the path, then prints timing, totals, the largest top-level entries + * and the largest extensions. `MT_TIMELINE` prints scan progress every second; + * `MT_DENIED` lists every unreadable folder with its errno class. + */ +if let i = arguments.firstIndex(of: "--bench"), i + 1 < arguments.count { + let threads = i + 2 < arguments.count ? Int(arguments[i + 2]) ?? Scanner.defaultThreadCount : Scanner.defaultThreadCount + let scanner = Scanner(path: arguments[i + 1], sizeMode: .allocated) + if ProcessInfo.processInfo.environment["MT_TIMELINE"] != nil { + Thread.detachNewThread { + var last = 0 + var t = 0 + while true { + Thread.sleep(forTimeInterval: 1) + t += 1 + let p = scanner.progress + print("t=\(t)s files=\(p.files) (+\(p.files - last)) dirs=\(p.dirs) cur=\(p.currentPath)") + last = p.files + } + } + } + let result = scanner.run(threads: threads) + let r = result.root + print("root:", result.rootPath) + print("threads:", threads, "time:", Fmt.seconds(result.elapsed)) + print("files:", r.fileCount, "dirs:", r.dirCount, "denied:", result.deniedCount) + print("size:", r.size, Fmt.bytes(r.size), "alloc:", r.alloc, Fmt.bytes(r.alloc)) + for c in r.children.prefix(15) { + print(String(format: " %-40@ %12@ %12@ %9d", c.name as NSString, Fmt.bytes(c.alloc) as NSString, + Fmt.bytes(c.size) as NSString, c.fileCount)) + } + if ProcessInfo.processInfo.environment["MT_DENIED"] != nil { + for d in result.denied { print(" denied", d.isPrivacyProtected ? "EPERM " : "EACCES", d.path) } + } + let top = result.extStats.sorted { $0.alloc > $1.alloc }.prefix(10) + for e in top { print(" ext", e.displayName, Fmt.bytes(e.alloc), e.count) } + exit(0) +} + +/** + * Headless CSV export, like WizTree's /export: `MacTree --export /path out.csv`. + * + * Scans the path and writes every folder and file to the CSV. Exits with + * status 1 and a message on stderr if the file cannot be written. + */ +if let i = arguments.firstIndex(of: "--export"), i + 2 < arguments.count { + let result = Scanner(path: arguments[i + 1], sizeMode: .allocated).run() + do { + let rows = try CSVExporter.export(root: result.root, to: URL(fileURLWithPath: arguments[i + 2])) { _ in } + print("exported \(rows) rows in \(Fmt.seconds(result.elapsed)) scan") + exit(0) + } catch { + FileHandle.standardError.write("export failed: \(error.localizedDescription)\n".data(using: .utf8)!) + exit(1) + } +} + +/** + * Headless treemap render: `MacTree --treemap /path out.png`. + * + * Scans the path and writes a 1600×800 cushion treemap as PNG, printing the + * render time and the number of laid-out items. + */ +if let i = arguments.firstIndex(of: "--treemap"), i + 2 < arguments.count { + let result = Scanner(path: arguments[i + 1], sizeMode: .allocated).run() + let colors = ExtColors(stats: result.extStats, mode: .allocated) + let params = TreemapRenderer.Params(width: 1600, height: 800, sizeMode: .allocated, colors: colors, highlightExt: nil) + let t0 = Date() + guard let (image, layout) = TreemapRenderer.render(root: result.root, params: params, isCancelled: { false }), + let image else { exit(1) } + print("render:", Fmt.seconds(Date().timeIntervalSince(t0)), "items:", layout.items.count) + let rep = NSBitmapImageRep(cgImage: image) + try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: arguments[i + 2])) + exit(0) +} + +/** The shared application instance that runs the GUI. */ +let app = NSApplication.shared +/** Builds the menus and the main window once the app has launched. */ +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() diff --git a/Sources/MacTree/Model/CSVExporter.swift b/Sources/MacTree/Model/CSVExporter.swift new file mode 100644 index 0000000..d4d8b16 --- /dev/null +++ b/Sources/MacTree/Model/CSVExporter.swift @@ -0,0 +1,115 @@ +import Foundation + +/** Writes every folder and file of a scan as CSV, like WizTree's export. */ +enum CSVExporter { + /** + * Streams the whole tree to a CSV file. + * + * Columns: Path, Type (Folder/File), Size, Allocated, Files, Folders, + * Modified (ISO 8601). The file starts with a UTF-8 byte-order mark so + * spreadsheet apps pick the right encoding. Paths are always quoted, with + * embedded quotes doubled. Rows go out depth-first, parents before their + * children, and each directory's path is built only once. + * + * Output is buffered in about 1 MB chunks, and `progress` is called after + * each flush. The tree is read under `treeLock` for the whole export, so + * call this off the main thread; main-thread tree mutations wait until it + * finishes. An existing file at `url` is replaced. + * + * @param {Node} root - The scan root (or any subtree) to export. + * @param {URL} url - Destination file. + * @param {(Int) -> Void} progress - Called with the number of rows written so far, on the calling thread. + * @returns {Int} The number of data rows written (header excluded). + * @throws {CocoaError} If the file cannot be created or written. + * + * @example + * DispatchQueue.global().async { + * let rows = try? CSVExporter.export(root: result.root, to: url) { print("\($0) rows") } + * } + */ + static func export(root: Node, to url: URL, progress: (Int) -> Void) throws -> Int { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) + } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + + var buffer = [UInt8]() + buffer.reserveCapacity(1 << 20) + + /** + * Writes the buffered bytes to the file and empties the buffer. + * + * Keeps the buffer's capacity so the next chunk does not reallocate. + * + * @throws {Error} If writing to the file handle fails. + * + * @example + * if buffer.count > limit { try flush() } + */ + func flush() throws { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + + /** + * Appends raw text to the buffer. + * + * The text is copied as UTF-8 without any escaping, so use it only for + * values that cannot contain commas or quotes. + * + * @param {String} s - Text to append. + * + * @example + * append(",File,") + */ + func append(_ s: String) { buffer.append(contentsOf: s.utf8) } + + /** + * Appends a CSV-quoted field to the buffer. + * + * Wraps the value in double quotes and doubles any quote inside it, so + * paths containing commas, quotes or newlines stay one field. + * + * @param {String} s - Field value, typically a path. + * + * @example + * appendQuoted("/Users/me/we,ird \"name\"") // "/Users/me/we,ird ""name""" + */ + func appendQuoted(_ s: String) { + buffer.append(0x22) + for b in s.utf8 { + if b == 0x22 { buffer.append(0x22) } + buffer.append(b) + } + buffer.append(0x22) + } + + let iso = ISO8601DateFormatter() + buffer.append(contentsOf: [0xEF, 0xBB, 0xBF]) + append("Path,Type,Size,Allocated,Files,Folders,Modified\n") + + treeLock.lock() + defer { treeLock.unlock() } + var rows = 0 + var stack: [(Node, String)] = [(root, root.path)] + while let (node, path) = stack.popLast() { + appendQuoted(path) + append(node.isDir ? ",Folder," : ",File,") + append("\(node.size),\(node.alloc),\(node.fileCount),\(node.dirCount),") + if let d = node.modificationDate { append(iso.string(from: d)) } + buffer.append(0x0A) + rows += 1 + if buffer.count > (1 << 20) - 4096 { + try flush() + progress(rows) + } + if node.isDir { + let prefix = path.hasSuffix("/") ? path : path + "/" + for c in node.children.reversed() { stack.append((c, prefix + c.name)) } + } + } + try flush() + return rows + } +} diff --git a/Sources/MacTree/Model/ExtColors.swift b/Sources/MacTree/Model/ExtColors.swift new file mode 100644 index 0000000..80f2096 --- /dev/null +++ b/Sources/MacTree/Model/ExtColors.swift @@ -0,0 +1,141 @@ +import AppKit + +/** An sRGB colour with float components in 0…1, cheap to use in the treemap's pixel loop. */ +struct RGB { + /** Red component, 0…1. */ + var r: Float + /** Green component, 0…1. */ + var g: Float + /** Blue component, 0…1. */ + var b: Float + + /** + * Creates a colour from its three components. + * + * Values are stored as given; nothing is clamped, so callers that scale + * a colour (for example to brighten a cushion) clamp later when writing pixels. + * + * @param {Float} r - Red component, 0…1. + * @param {Float} g - Green component, 0…1. + * @param {Float} b - Blue component, 0…1. + * + * @example + * let grey = RGB(0.5, 0.5, 0.5) + */ + init(_ r: Float, _ g: Float, _ b: Float) { self.r = r; self.g = g; self.b = b } + + /** + * Creates a colour from a 24-bit `0xRRGGBB` value. + * + * The top byte is ignored, so an alpha channel in the value has no effect. + * + * @param {UInt32} hex - The colour as `0xRRGGBB`. + * + * @example + * let blue = RGB(hex: 0x3D7EFF) + */ + init(hex: UInt32) { + r = Float((hex >> 16) & 0xFF) / 255 + g = Float((hex >> 8) & 0xFF) / 255 + b = Float(hex & 0xFF) / 255 + } + + /** The same colour as an opaque sRGB `NSColor`, for swatches and bars. */ + var nsColor: NSColor { NSColor(srgbRed: CGFloat(r), green: CGFloat(g), blue: CGFloat(b), alpha: 1) } + + /** Neutral grey used for files without an extension. */ + static let gray = RGB(0.62, 0.62, 0.64) +} + +/** + * Colour assignment per file extension, shared by the treemap and the file-type list. + * + * The biggest types get distinct palette colours; the long tail gets stable, + * hash-derived muted colours so an extension keeps its colour across scans. + */ +final class ExtColors { + /** Vivid colours handed to the largest extensions, biggest first. */ + private static let palette: [UInt32] = [ + 0x3D7EFF, 0xF0453A, 0x2FC25B, 0xF7C325, 0xA35CF5, 0x16C2D5, + 0xFF8A1F, 0xF0509B, 0x8CCB1E, 0x1FB39B, 0x6567F2, 0xE9A10C, + 0x5AB0FF, 0xFF6F61, 0x62DE8B, 0xFFE066, 0xC792F9, 0x6DE0EC, + 0xFFB066, 0xF78DC1, 0xB5E061, 0x5FD6C1, 0x9B9DF7, 0xD9C27A, + ] + + /** Colour per extension id, dense over the ids known when the table was built. */ + private var colors: [RGB] + + /** + * Builds the colour table for one scan. + * + * Every extension known to `ExtensionTable` first gets its hashed colour; + * id 0 (no extension) becomes grey; then the extensions ranked largest by + * `mode` take the palette colours in order. Extensions interned after this + * point fall back to their hashed colour in `color(_:)`. + * + * @param {[ExtStat]} stats - Per-extension totals of the scan. + * @param {SizeMode} mode - The size used to rank extensions. + * + * @example + * let colors = ExtColors(stats: result.extStats, mode: .allocated) + */ + init(stats: [ExtStat], mode: SizeMode) { + let count = max(ExtensionTable.shared.count, 1) + colors = (0.. $1.metric(mode) } + for (i, s) in ranked.prefix(ExtColors.palette.count).enumerated() { + colors[Int(s.id)] = RGB(hex: ExtColors.palette[i]) + } + } + + /** + * Returns the colour for an extension id. + * + * Ids outside the table (extensions first seen after it was built, e.g. + * by a folder rescan) get their stable hashed colour instead of failing. + * + * @param {UInt16} ext - Extension id from `ExtensionTable`. + * @returns {RGB} The colour used to paint files of that type. + * + * @example + * let c = colors.color(node.ext) + */ + @inline(__always) func color(_ ext: UInt16) -> RGB { + Int(ext) < colors.count ? colors[Int(ext)] : ExtColors.hashed(ext) + } + + /** + * Returns the colour for an extension id as an `NSColor`. + * + * Convenience for AppKit drawing such as the swatches in the file-type list. + * + * @param {UInt16} ext - Extension id from `ExtensionTable`. + * @returns {NSColor} The opaque sRGB colour for that type. + * + * @example + * cell.color = colors.nsColor(stat.id) + */ + func nsColor(_ ext: UInt16) -> NSColor { color(ext).nsColor } + + /** + * Derives a muted colour from an extension id. + * + * Uses a multiplicative hash to spread neighbouring ids around the hue + * circle, with fixed saturation and brightness so long-tail types stay + * calmer than palette colours. Deterministic for a given id. + * + * @param {UInt16} ext - Extension id. + * @returns {RGB} The hashed sRGB colour. + * + * @example + * let fallback = ExtColors.hashed(4711) + */ + private static func hashed(_ ext: UInt16) -> RGB { + var h = UInt32(ext) &* 2_654_435_761 + h ^= h >> 15 + let hue = CGFloat(h % 360) / 360 + let c = NSColor(hue: hue, saturation: 0.42, brightness: 0.78, alpha: 1).usingColorSpace(.sRGB)! + return RGB(Float(c.redComponent), Float(c.greenComponent), Float(c.blueComponent)) + } +} diff --git a/Sources/MacTree/Model/ExtensionTable.swift b/Sources/MacTree/Model/ExtensionTable.swift new file mode 100644 index 0000000..5522a84 --- /dev/null +++ b/Sources/MacTree/Model/ExtensionTable.swift @@ -0,0 +1,154 @@ +import Foundation +import os + +/** + * Interns lowercase file extensions into small integer ids shared by the whole app. + * + * Id 0 means "no extension". Scanner threads intern concurrently, so every + * access goes through an unfair lock. + */ +final class ExtensionTable: @unchecked Sendable { + /** The app-wide table; ids stay valid for the life of the process. */ + static let shared = ExtensionTable() + + /** Lock-protected storage: id → name and name → id. */ + private struct State { + /** Extension names indexed by id; index 0 is the empty name. */ + var names: [String] = [""] + /** Reverse lookup from name to id. */ + var ids: [String: UInt16] = ["": 0] + } + + /** The table contents, guarded by an unfair lock. */ + private let state = OSAllocatedUnfairLock(initialState: State()) + + /** + * Returns the id for an extension, assigning a new one on first sight. + * + * Thread-safe. Once all 65,535 ids are taken, new extensions map to 0 + * ("no extension") instead of failing. + * + * @param {String} ext - Lowercase extension without the dot, or "" for none. + * @returns {UInt16} The extension's id. + * + * @example + * let movID = ExtensionTable.shared.intern("mov") + */ + func intern(_ ext: String) -> UInt16 { + state.withLock { s in + if let id = s.ids[ext] { return id } + guard s.names.count < Int(UInt16.max) else { return 0 } + let id = UInt16(s.names.count) + s.names.append(ext) + s.ids[ext] = id + return id + } + } + + /** + * Looks up the extension name for an id. + * + * Thread-safe. Unknown ids yield an empty string rather than trapping. + * + * @param {UInt16} id - An id previously returned by `intern(_:)`. + * @returns {String} The lowercase extension without the dot, or "" for id 0 or unknown ids. + * + * @example + * ExtensionTable.shared.name(node.ext) // "mov" + */ + func name(_ id: UInt16) -> String { + state.withLock { s in Int(id) < s.names.count ? s.names[Int(id)] : "" } + } + + /** Number of ids handed out so far, including id 0. */ + var count: Int { state.withLock { $0.names.count } } + + /** + * Returns a snapshot of every known extension name. + * + * The array index is the extension id, so it can size per-extension + * accumulators. Extensions interned later are not included. + * + * @returns {[String]} Names indexed by id; element 0 is "". + * + * @example + * let names = ExtensionTable.shared.allNames() + * var totals = [Int64](repeating: 0, count: names.count) + */ + func allNames() -> [String] { state.withLock { $0.names } } +} + +/** Aggregated statistics for one extension. */ +struct ExtStat { + /** Extension id from `ExtensionTable`. */ + let id: UInt16 + /** Lowercase extension without the dot; "" for files without one. */ + let name: String + /** Total logical size of the files with this extension. */ + var size: Int64 = 0 + /** Total allocated size of the files with this extension. */ + var alloc: Int64 = 0 + /** Number of files with this extension. */ + var count: Int = 0 + + /** Label for the file-type list: ".ext", or the localised "no extension" text. */ + var displayName: String { name.isEmpty ? L.noExtension : "." + name } + + /** + * Returns the total for the chosen size mode. + * + * @param {SizeMode} mode - Logical or allocated size. + * @returns {Int64} The matching total in bytes. + * + * @example + * let bytes = stat.metric(.allocated) + */ + func metric(_ mode: SizeMode) -> Int64 { mode == .allocated ? alloc : size } +} + +/** Builds per-extension totals from a scanned tree. */ +enum ExtStats { + /** + * Walks every file under `root` and tallies per-extension totals. + * + * Iterative, so deep trees cannot overflow the stack. If `root` is a file, + * the result covers just that file. Extensions with no files are left out. + * Reads `children`, so run it on the main thread or under `treeLock`. + * + * @param {Node} root - The directory (or single file) to tally. + * @returns {[ExtStat]} One entry per extension that has at least one file, in id order. + * + * @example + * let stats = ExtStats.compute(root: result.root) + * let biggest = stats.max { $0.alloc < $1.alloc } + */ + static func compute(root: Node) -> [ExtStat] { + let names = ExtensionTable.shared.allNames() + var size = [Int64](repeating: 0, count: names.count) + var alloc = [Int64](repeating: 0, count: names.count) + var count = [Int](repeating: 0, count: names.count) + var stack: [Node] = [root] + if !root.isDir { + stack = [] + let e = Int(root.ext) + size[e] += root.size; alloc[e] += root.alloc; count[e] += 1 + } + while let n = stack.popLast() { + for c in n.children { + if c.isDir { + stack.append(c) + } else { + let e = Int(c.ext) + size[e] &+= c.size + alloc[e] &+= c.alloc + count[e] &+= 1 + } + } + } + var result: [ExtStat] = [] + for i in 0.. 0 { + result.append(ExtStat(id: UInt16(i), name: names[i], size: size[i], alloc: alloc[i], count: count[i])) + } + return result + } +} diff --git a/Sources/MacTree/Model/Format.swift b/Sources/MacTree/Model/Format.swift new file mode 100644 index 0000000..2b6fa05 --- /dev/null +++ b/Sources/MacTree/Model/Format.swift @@ -0,0 +1,111 @@ +import Foundation + +/** + * Text formatting for sizes, counts, dates and durations shown in the UI. + * + * The cached formatters are not thread-safe; call these from the main thread. + */ +enum Fmt { + /** Size unit suffixes, one per power of 1000. */ + private static let units = ["B", "KB", "MB", "GB", "TB", "PB"] + + /** + * Formats a byte count with decimal (1000-based) units, matching Finder. + * + * Values under 1000 are shown as whole bytes. Larger values get one + * decimal place below 100 of their unit and none from 100 up, so labels + * stay short in narrow table columns. + * + * @param {Int64} value - Size in bytes. + * @returns {String} A label such as "512 B", "12.3 GB" or "384 GB". + * + * @example + * Fmt.bytes(12_345_678_901) // "12.3 GB" + */ + static func bytes(_ value: Int64) -> String { + if value < 1000 { return "\(value) B" } + var v = Double(value) + var u = 0 + while v >= 1000 && u < units.count - 1 { + v /= 1000 + u += 1 + } + return String(format: v >= 100 ? "%.0f %@" : "%.1f %@", v, units[u]) + } + + /** Locale-aware integer formatter with grouping separators. */ + private static let countFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.usesGroupingSeparator = true + return f + }() + + /** + * Formats an item count with the locale's grouping separators. + * + * Falls back to the plain number if the formatter fails. + * + * @param {Int} n - The count to format. + * @returns {String} A label such as "1,234,567". + * + * @example + * Fmt.count(1_076_094) // "1,076,094" + */ + static func count(_ n: Int) -> String { + countFormatter.string(from: NSNumber(value: n)) ?? "\(n)" + } + + /** Short date and time formatter in the user's locale. */ + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .short + return f + }() + + /** + * Formats a modification date as a short date and time. + * + * Unknown dates produce an empty string so table cells stay blank. + * + * @param {Date?} d - The date, or nil when unknown. + * @returns {String} The localised date and time, or "" for nil. + * + * @example + * Fmt.date(node.modificationDate) // "2026. 9. 12. 오전 8:15" + */ + static func date(_ d: Date?) -> String { + guard let d else { return "" } + return dateFormatter.string(from: d) + } + + /** + * Formats a fraction as a percentage with one decimal place. + * + * Non-finite input (such as 0/0 for an empty parent) yields an empty string. + * + * @param {Double} fraction - Value where 1.0 means 100 %. + * @returns {String} A label such as "35.1 %", or "" when not finite. + * + * @example + * Fmt.percent(0.351) // "35.1 %" + */ + static func percent(_ fraction: Double) -> String { + guard fraction.isFinite else { return "" } + return String(format: "%.1f %%", fraction * 100) + } + + /** + * Formats a duration in seconds with two decimal places. + * + * @param {TimeInterval} t - Duration in seconds. + * @returns {String} A label such as "2.84 s". + * + * @example + * Fmt.seconds(result.elapsed) // "24.71 s" + */ + static func seconds(_ t: TimeInterval) -> String { + String(format: "%.2f s", t) + } +} diff --git a/Sources/MacTree/Model/Node.swift b/Sources/MacTree/Model/Node.swift new file mode 100644 index 0000000..dbfac5b --- /dev/null +++ b/Sources/MacTree/Model/Node.swift @@ -0,0 +1,212 @@ +import Foundation + +/** + * One file or directory in the scanned tree. + * + * Kept deliberately small because a full-disk scan can produce millions of + * these. Directories hold aggregate values for their whole subtree once the + * scanner has finalised the tree. + */ +final class Node { + /** Per-node state bits, packed into one byte. */ + struct Flags: OptionSet { + /** The packed bit field. */ + let rawValue: UInt8 + + /** The node is a directory. */ + static let dir = Flags(rawValue: 1 << 0) + /** Directory contents could not be read (permissions or privacy protection). */ + static let denied = Flags(rawValue: 1 << 1) + /** The node is a symbolic link; it is never followed. */ + static let symlink = Flags(rawValue: 1 << 2) + /** Mount point of another volume that was not traversed. */ + static let otherVolume = Flags(rawValue: 1 << 3) + /** iCloud / File Provider placeholder whose data is not on disk. */ + static let dataless = Flags(rawValue: 1 << 4) + /** A directory whose aggregate values include unreadable content somewhere below. */ + static let partial = Flags(rawValue: 1 << 5) + } + + /** File name; for the scan root, the full path that was scanned. */ + var name: String + /** Containing directory, or nil for the scan root. */ + weak var parent: Node? + /** Entries of a directory, largest first by the current size mode. */ + var children: [Node] = [] + /** Logical size (all forks). For directories: sum of the subtree. */ + var size: Int64 + /** Allocated size on disk. For directories: sum of the subtree. */ + var alloc: Int64 + /** Modification time (seconds since 1970). For directories: newest in the subtree. */ + var mtime: UInt32 + /** Files in the subtree (1 for a file). */ + var fileCount: Int32 + /** Directories in the subtree, not counting self. */ + var dirCount: Int32 + /** Index into `ExtensionTable` (files only). */ + var ext: UInt16 + /** State bits such as directory, denied or symlink. */ + var flags: Flags + + /** + * Creates a node for one directory entry. + * + * A file starts with a file count of 1; a directory starts empty and gets + * its totals from `recomputeFromChildren()` once its children are known. + * + * @param {String} name - Entry name (or full path for a scan root). + * @param {Bool} isDir - Whether the entry is a directory. + * @param {Int64} [size=0] - Logical size in bytes. + * @param {Int64} [alloc=0] - Allocated size in bytes. + * @param {UInt32} [mtime=0] - Modification time in seconds since 1970, 0 if unknown. + * @param {UInt16} [ext=0] - Extension id from `ExtensionTable`, 0 for none. + * + * @example + * let file = Node(name: "movie.mov", isDir: false, size: 1_000_000, alloc: 1_003_520, ext: movID) + * file.fileCount // 1 + */ + init(name: String, isDir: Bool, size: Int64 = 0, alloc: Int64 = 0, mtime: UInt32 = 0, ext: UInt16 = 0) { + self.name = name + self.size = size + self.alloc = alloc + self.mtime = mtime + self.fileCount = isDir ? 0 : 1 + self.dirCount = 0 + self.ext = ext + self.flags = isDir ? .dir : [] + } + + /** Whether the node is a directory. */ + @inline(__always) var isDir: Bool { flags.contains(.dir) } + + /** Files plus directories in the subtree. */ + var itemCount: Int { Int(fileCount) + Int(dirCount) } + + /** + * Returns the size used for percentages, the treemap and default ordering. + * + * Logical size counts sparse files and iCloud placeholders at full length; + * allocated size is what the node really occupies on disk. + * + * @param {SizeMode} mode - Which size to report. + * @returns {Int64} The logical or allocated size in bytes. + * + * @example + * let bytes = node.metric(.allocated) + */ + @inline(__always) func metric(_ mode: SizeMode) -> Int64 { + mode == .allocated ? alloc : size + } + + /** Absolute path, rebuilt from the parent chain on each access. */ + var path: String { + guard let parent else { return name } + let base = parent.path + return base.hasSuffix("/") ? base + name : base + "/" + name + } + + /** File URL for `path`. */ + var url: URL { URL(fileURLWithPath: path, isDirectory: isDir) } + + /** Parents from the immediate one up to the scan root. */ + var ancestors: [Node] { + var result: [Node] = [] + var n = parent + while let p = n { result.append(p); n = p.parent } + return result + } + + /** + * Tells whether this node lies inside `other`'s subtree. + * + * A node counts as its own descendant. Walks the parent chain, so a node + * detached from the tree still reports its former ancestors. + * + * @param {Node} other - The candidate ancestor. + * @returns {Bool} True if `other` is this node or one of its ancestors. + * + * @example + * file.isDescendant(of: scanRoot) // true + */ + func isDescendant(of other: Node) -> Bool { + var n: Node? = self + while let c = n { + if c === other { return true } + n = c.parent + } + return false + } + + /** `mtime` as a date, or nil when unknown. */ + var modificationDate: Date? { + mtime == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(mtime)) + } +} + +/** Which size drives percentages, ordering and the treemap. */ +enum SizeMode: Int { + /** Logical file length. */ + case logical = 0 + /** Bytes allocated on disk. */ + case allocated = 1 +} + +extension Node { + /** + * Recomputes this directory's aggregate values from its immediate children. + * + * Sums sizes and counts, takes the newest modification time, and sets the + * `partial` flag when anything below could not be read. Children must + * already be up to date, so callers process directories bottom-up. Does + * nothing for files. + * + * @example + * for dir in directoriesBottomUp { dir.recomputeFromChildren() } + */ + func recomputeFromChildren() { + guard isDir else { return } + var s: Int64 = 0, a: Int64 = 0, f: Int32 = 0, d: Int32 = 0 + var m: UInt32 = 0 + var partial = flags.contains(.denied) + for c in children { + s &+= c.size + a &+= c.alloc + f &+= c.fileCount + if c.isDir { d &+= c.dirCount &+ 1 } + if c.mtime > m { m = c.mtime } + if c.flags.contains(.partial) || c.flags.contains(.denied) { partial = true } + } + size = s; alloc = a; fileCount = f; dirCount = d + if m > 0 { mtime = m } + if partial { flags.insert(.partial) } else { flags.remove(.partial) } + } + + /** + * Orders the children largest first. + * + * Ties on the chosen size are broken by the other size. Mutates `children` + * in place, so it must not run while a background reader walks the tree. + * + * @param {SizeMode} mode - The size to order by. + * + * @example + * treeLock.lock(); dir.sortChildren(by: .allocated); treeLock.unlock() + */ + func sortChildren(by mode: SizeMode) { + if mode == .allocated { + children.sort { $0.alloc != $1.alloc ? $0.alloc > $1.alloc : $0.size > $1.size } + } else { + children.sort { $0.size != $1.size ? $0.size > $1.size : $0.alloc > $1.alloc } + } + } + + /** The file reached by following the largest child down; colours directories too small to subdivide. */ + var dominantFile: Node? { + var n = self + while n.isDir { + guard let first = n.children.first else { return nil } + n = first + } + return n + } +} diff --git a/Sources/MacTree/Model/Scanner.swift b/Sources/MacTree/Model/Scanner.swift new file mode 100644 index 0000000..0f9cc87 --- /dev/null +++ b/Sources/MacTree/Model/Scanner.swift @@ -0,0 +1,716 @@ +import Foundation +import Darwin +import Synchronization +import os + +/** A folder the scan could not list, with the errno that stopped it. */ +struct DeniedFolder { + /** Absolute path of the folder. */ + let path: String + /** The errno from `open` or `getattrlistbulk`. */ + let error: Int32 + + /** + * Whether macOS privacy protection (TCC) or SIP refused access. + * + * EPERM comes from privacy protection or SIP and may clear with Full Disk + * Access; EACCES comes from plain Unix permissions, typically root-only + * system folders. + */ + var isPrivacyProtected: Bool { error == EPERM } +} + +/** Result of a completed (or cancelled) scan. */ +final class ScanResult { + /** Root of the scanned tree, finalised and sorted. */ + let root: Node + /** The resolved absolute path that was scanned. */ + let rootPath: String + /** Per-extension totals; kept in step when the tree is edited. */ + var extStats: [ExtStat] + /** Wall-clock duration of the scan, finalisation included. */ + let elapsed: TimeInterval + /** Number of folders that could not be read. */ + let deniedCount: Int + /** Up to `Scanner.maxDeniedRecorded` of the unreadable folders, sorted by path. */ + let denied: [DeniedFolder] + /** Whether the scan was stopped early, leaving partial results. */ + let cancelled: Bool + /** Capacity information for the volume holding the root, if available. */ + let volume: VolumeInfo? + /** Reused when rescanning a subfolder so the same volumes and firmlink rules apply. */ + let policy: TraversalPolicy + + /** + * Bundles everything a finished scan produced. + * + * @param {Node} root - Root of the finalised tree. + * @param {String} rootPath - Resolved path that was scanned. + * @param {[ExtStat]} extStats - Per-extension totals. + * @param {TimeInterval} elapsed - Scan duration in seconds. + * @param {Int} deniedCount - Number of unreadable folders. + * @param {[DeniedFolder]} denied - The recorded unreadable folders. + * @param {Bool} cancelled - Whether the scan was stopped early. + * @param {VolumeInfo?} volume - Capacity of the containing volume. + * @param {TraversalPolicy} policy - The traversal rules the scan used. + * + * @example + * let result = ScanResult(root: root, rootPath: "/", extStats: stats, elapsed: 24.7, + * deniedCount: 0, denied: [], cancelled: false, volume: nil, policy: policy) + */ + init(root: Node, rootPath: String, extStats: [ExtStat], elapsed: TimeInterval, + deniedCount: Int, denied: [DeniedFolder], cancelled: Bool, volume: VolumeInfo?, policy: TraversalPolicy) { + self.root = root + self.rootPath = rootPath + self.extStats = extStats + self.elapsed = elapsed + self.deniedCount = deniedCount + self.denied = denied + self.cancelled = cancelled + self.volume = volume + self.policy = policy + } +} + +/** + * Fast parallel directory scanner built on getattrlistbulk(2). + * + * Each worker lists one directory per job and pushes its subdirectories back + * onto a shared stack; aggregation and sorting happen once at the end. + */ +final class Scanner: @unchecked Sendable { + /** Live counters for the progress display. */ + struct Progress { + /** Files found so far. */ + var files: Int + /** Directories listed so far. */ + var dirs: Int + /** Bytes of the files found so far, in the scan's size mode. */ + var bytes: Int64 + /** A recently listed directory, refreshed every 64 directories per worker. */ + var currentPath: String + } + + /** The resolved absolute path being scanned. */ + let rootPath: String + /** Size mode used for the progress byte counter and the final ordering. */ + let sizeMode: SizeMode + + /** Shared stack of directories still to list. */ + private let queue = WorkQueue() + /** Which devices may be entered and which paths are skipped. */ + private let policy: TraversalPolicy + /** Set once `cancel()` is called; workers check it between directories. */ + private let cancelledFlag = Atomic(false) + /** Files found so far. */ + private let filesCounter = Atomic(0) + /** Directories listed so far. */ + private let dirsCounter = Atomic(0) + /** Bytes of the files found so far. */ + private let bytesCounter = Atomic(0) + /** Directories that could not be read. */ + private let deniedCounter = Atomic(0) + /** The first `maxDeniedRecorded` unreadable directories. */ + private let deniedList = OSAllocatedUnfairLock(initialState: [DeniedFolder]()) + /** Cap on recorded unreadable folders, so a pathological disk cannot grow the list without bound. */ + static let maxDeniedRecorded = 5000 + /** Path shown in the progress display. */ + private let currentPath = OSAllocatedUnfairLock(initialState: "") + + /** One directory waiting to be listed. */ + private struct Job { + /** The directory's node, whose children the job fills in. */ + let node: Node + /** Absolute path used to open the directory. */ + let path: String + /** Whether this is the scan root, which may itself be reached through a symlink. */ + let isRoot: Bool + } + + /** + * Prepares a scan of `path` without starting it. + * + * The path is resolved with realpath(3), so a symlinked root such as /tmp + * is scanned at its real location; if resolution fails the path is used + * as given. Without an explicit policy, one is derived from the mount + * table for this root. + * + * @param {String} path - Folder (or single file) to scan. + * @param {SizeMode} sizeMode - Size used for progress bytes and final ordering. + * @param {TraversalPolicy?} [policy=nil] - Policy of an enclosing scan, passed when rescanning a + * subfolder so the same volumes and firmlink exclusions apply. + * + * @example + * let scanner = Scanner(path: "/", sizeMode: .allocated) + */ + init(path: String, sizeMode: SizeMode, policy: TraversalPolicy? = nil) { + var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) + if realpath(path, &resolved) != nil { + rootPath = String(cString: resolved) + } else { + rootPath = path + } + self.sizeMode = sizeMode + self.policy = policy ?? TraversalPolicy.make(root: rootPath) + } + + /** A snapshot of the live counters; safe to read from any thread while scanning. */ + var progress: Progress { + Progress(files: filesCounter.load(ordering: .relaxed), + dirs: dirsCounter.load(ordering: .relaxed), + bytes: bytesCounter.load(ordering: .relaxed), + currentPath: currentPath.withLock { $0 }) + } + + /** + * Stops the scan as soon as possible. + * + * Pending directories are dropped and workers stop after the directory + * they are listing, so `run(threads:)` returns shortly with a partial tree + * marked as cancelled. Safe to call from any thread, more than once. + * + * @example + * stopButton.action = #selector(stop) // calls scanner.cancel() + */ + func cancel() { + cancelledFlag.store(true, ordering: .relaxed) + queue.stop() + } + + /** Whether `cancel()` has been called. */ + var isCancelled: Bool { cancelledFlag.load(ordering: .relaxed) } + + /** + * Scans the tree synchronously and returns the finished result. + * + * Blocks until every directory is listed, so call it from a background + * thread. Starts `threads` worker threads that share a LIFO work stack, + * then aggregates totals bottom-up, sorts every directory and tallies + * extensions. If the root is a single file, nothing is traversed and the + * result holds just that file. iCloud placeholders are never downloaded. + * + * @param {Int} [threads=Scanner.defaultThreadCount] - Number of worker threads. + * @returns {ScanResult} The finalised tree plus statistics; partial if cancelled. + * + * @example + * DispatchQueue.global().async { + * let result = Scanner(path: "/Applications", sizeMode: .allocated).run() + * print(result.root.fileCount) + * } + */ + func run(threads: Int = Scanner.defaultThreadCount) -> ScanResult { + Scanner.disableDatalessMaterialization() + let start = Date() + + let root = Node(name: rootPath, isDir: true) + var st = stat() + if lstat(rootPath, &st) == 0, (st.st_mode & S_IFMT) != S_IFDIR { + root.flags = [] + root.size = Int64(st.st_size) + root.alloc = Int64(st.st_blocks) * 512 + root.fileCount = 1 + } else { + queue.push([Job(node: root, path: rootPath, isRoot: true)]) + let group = DispatchGroup() + for _ in 0.. [Node] { + var dirs: [Node] = [] + var stack: [Node] = [root] + while let n = stack.popLast() { + guard n.isDir else { continue } + dirs.append(n) + for c in n.children where c.isDir { stack.append(c) } + } + return dirs + } + + /** + * Sorts the children of each given directory in parallel. + * + * Splits the list into chunks of 2048 directories for concurrentPerform. + * This is safe because each directory's children array is touched by + * exactly one iteration. + * + * @param {[Node]} dirs - Directories whose children to sort. + * @param {SizeMode} sizeMode - The size to order by. + * + * @example + * sort(allDirectories(root), by: .allocated) + */ + private static func sort(_ dirs: [Node], by sizeMode: SizeMode) { + let chunk = 2048 + let chunks = (dirs.count + chunk - 1) / chunk + dirs.withUnsafeBufferPointer { buf in + DispatchQueue.concurrentPerform(iterations: chunks) { i in + let lo = i * chunk, hi = min(buf.count, lo + chunk) + for j in lo.. 1 { + buf[j].sortChildren(by: sizeMode) + } + } + } + } + + // MARK: - Workers + + /** + * Body of one worker thread: lists directories until the work runs out. + * + * Each worker owns a bulk-attribute buffer and an extension-id cache for + * the scan's duration. Jobs popped after cancellation are discarded + * unlisted, but still reported finished so the queue can drain. + * + * @example + * Thread { scanner.workerLoop() }.start() + */ + private func workerLoop() { + let ctx = WorkerContext() + defer { ctx.buffer.deallocate() } + while let job = queue.pop() { + if !isCancelled { scan(job, ctx) } + queue.finished() + } + } + + /** Per-thread scratch state, so workers never contend on buffers or caches. */ + private final class WorkerContext { + /** Size of the getattrlistbulk result buffer. */ + let bufferSize = 256 * 1024 + /** Result buffer for getattrlistbulk, reused for every directory. */ + let buffer: UnsafeMutableRawPointer + /** Extension string to id, so the shared table's lock is taken only for new extensions. */ + var extCache: [String: UInt16] = [:] + /** Directories listed since this worker last updated the progress path. */ + var dirsSinceReport = 0 + + /** + * Allocates the worker's result buffer. + * + * The buffer is freed by `workerLoop()` when the worker finishes. + * + * @example + * let ctx = WorkerContext() + */ + init() { + buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 16) + } + + /** + * Returns the lower-cased extension id for a raw UTF-8 file name. + * + * The extension is the text after the last dot, if the dot is not the + * first character and 1–15 bytes follow it; names with a space in that + * part count as having no extension. ASCII letters are lower-cased + * byte-wise before the local cache lookup, and the shared table is + * consulted only on a cache miss. + * + * @param {UnsafeRawPointer} name - Start of the name bytes (not NUL-terminated). + * @param {Int} len - Number of bytes in the name. + * @returns {UInt16} The extension id, or 0 for no extension. + * + * @example + * let ext = ctx.extID(namePtr, nameLen) // "Movie.MOV" -> id of "mov" + */ + @inline(__always) + func extID(_ name: UnsafeRawPointer, _ len: Int) -> UInt16 { + var i = len - 1 + while i > 0 && name.load(fromByteOffset: i, as: UInt8.self) != 0x2E { i -= 1 } + let extLen = len - i - 1 + guard i > 0, extLen >= 1, extLen <= 15 else { return 0 } + let key = withUnsafeTemporaryAllocation(of: UInt8.self, capacity: 16) { tmp -> String? in + for k in 0..= 0x41 && b <= 0x5A { b |= 0x20 } + tmp[k] = b + } + return String(decoding: UnsafeBufferPointer(rebasing: tmp[0..= 64 { + ctx.dirsSinceReport = 0 + currentPath.withLock { $0 = job.path } + } + queue.push(subdirs) + } + + /** + * Marks a directory as unreadable and records why. + * + * Always counts the folder, but keeps its path only while fewer than + * `maxDeniedRecorded` are stored. Thread-safe. + * + * @param {Node} node - The directory that could not be read. + * @param {String} path - Its absolute path. + * @param {Int32} error - The errno that stopped it. + * + * @example + * if fd < 0 { recordDenied(node, job.path, errno) } + */ + private func recordDenied(_ node: Node, _ path: String, _ error: Int32) { + node.flags.insert(.denied) + deniedCounter.add(1, ordering: .relaxed) + deniedList.withLock { list in + if list.count < Scanner.maxDeniedRecorded { list.append(DeniedFolder(path: path, error: error)) } + } + } + + /** + * Prevents the scan from downloading iCloud / File Provider placeholders. + * + * Sets the process-wide I/O policy + * IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES to + * IOPOL_MATERIALIZE_DATALESS_FILES_OFF (scope IOPOL_SCOPE_PROCESS), so + * reading dataless entries never triggers a download. Idempotent. + * + * @example + * Scanner.disableDatalessMaterialization() + */ + static func disableDatalessMaterialization() { + _ = setiopolicy_np(3, 0, 1) + } +} + +/** + * A LIFO work stack shared by scanner threads. + * + * `pop` blocks until work is available or every worker is idle, which means + * the scan is complete. LIFO order keeps the traversal roughly depth-first, + * so the stack stays small. + */ +final class WorkQueue: @unchecked Sendable { + /** Pending items; the last one is popped first. */ + private var items: [T] = [] + /** Items popped but not yet reported finished. */ + private var active = 0 + /** Set by `stop()`; no more items are handed out or accepted. */ + private var stopped = false + /** Guards all state and wakes waiting workers. */ + private let cond = NSCondition() + + /** + * Adds items and wakes waiting workers. + * + * Wakes one waiter for a single item and all of them for several. Items + * pushed after `stop()` are discarded. + * + * @param {[T]} newItems - Items to add; an empty array is a no-op. + * + * @example + * queue.push(subdirectoryJobs) + */ + func push(_ newItems: [T]) { + guard !newItems.isEmpty else { return } + cond.lock() + if !stopped { + items.append(contentsOf: newItems) + if newItems.count == 1 { cond.signal() } else { cond.broadcast() } + } + cond.unlock() + } + + /** + * Takes the most recently pushed item, waiting if necessary. + * + * Returns nil once the queue is stopped, or when it is empty and no + * worker is still active, since then no more work can appear. Every + * non-nil result must be followed by `finished()`. + * + * @returns {T?} The next item, or nil when the work is done or stopped. + * + * @example + * while let job = queue.pop() { process(job); queue.finished() } + */ + func pop() -> T? { + cond.lock() + defer { cond.unlock() } + while true { + if stopped { return nil } + if let item = items.popLast() { + active += 1 + return item + } + if active == 0 { + cond.broadcast() + return nil + } + cond.wait() + } + } + + /** + * Reports that a popped item has been fully processed. + * + * Call it after pushing any follow-up work, so the queue never looks + * drained while more work is about to arrive. Wakes all waiters when the + * last active item finishes with nothing pending. + * + * @example + * scan(job, ctx) + * queue.finished() + */ + func finished() { + cond.lock() + active -= 1 + if active == 0 && items.isEmpty { cond.broadcast() } + cond.unlock() + } + + /** + * Discards pending items and releases all waiting workers. + * + * Afterwards `pop()` returns nil and `push(_:)` ignores new items. + * + * @example + * queue.stop() + */ + func stop() { + cond.lock() + stopped = true + items.removeAll() + cond.broadcast() + cond.unlock() + } +} diff --git a/Sources/MacTree/Model/TreemapRenderer.swift b/Sources/MacTree/Model/TreemapRenderer.swift new file mode 100644 index 0000000..f6b75ac --- /dev/null +++ b/Sources/MacTree/Model/TreemapRenderer.swift @@ -0,0 +1,706 @@ +import AppKit + +/** + * Serialises tree mutations against background readers. + * + * The main thread takes it while trashing, rescanning or re-sorting; the + * treemap layout pass and the file-list builder take it while they walk the tree. + */ +let treeLock = NSLock() + +/** + * Pixel-space layout of a rendered treemap, kept for hit testing and highlighting. + * + * Coordinates are view pixels; items reaching past the view are clamped to it (±1). + * Items form a tree through first-child / next-sibling links in `items`. + */ +final class TreemapLayout { + /** One laid-out rectangle and its links to the rest of the layout tree. */ + struct Item { + /** The file or directory this rectangle shows. */ + let node: Node + /** Clamped pixel bounds: left, top, right and bottom (exclusive). */ + let x0: Int32, y0: Int32, x1: Int32, y1: Int32 + /** Index of the first laid-out child, or -1. */ + var firstChild: Int32 = -1 + /** Index of the next laid-out sibling, or -1. */ + var nextSibling: Int32 = -1 + } + + /** All laid-out items; index 0 is the root. */ + var items: [Item] = [] + /** Width of the rendered picture in pixels. */ + let width: Int + /** Height of the rendered picture in pixels. */ + let height: Int + /** Index of the deepest item that covers the whole view: the folder being looked at. */ + private(set) var focusIndex = 0 + + /** + * Creates an empty layout for a picture of the given pixel size. + * + * The renderer fills `items` while it lays out the tree. + * + * @param {Int} width - Picture width in pixels. + * @param {Int} height - Picture height in pixels. + * + * @example + * let layout = TreemapLayout(width: 2400, height: 800) + */ + init(width: Int, height: Int) { + self.width = width + self.height = height + } + + /** + * Finds the deepest laid-out item under a pixel. + * + * Descends from the root through the child whose rectangle contains the + * point. Items too small to be laid out resolve to their nearest drawn + * ancestor. + * + * @param {Int} x - Horizontal pixel coordinate. + * @param {Int} y - Vertical pixel coordinate (top-left origin). + * @returns {Int?} Index into `items`, or nil if the point is outside the root. + * + * @example + * if let i = layout.hitTest(x: 120, y: 48) { print(layout.items[i].node.path) } + */ + func hitTest(x: Int, y: Int) -> Int? { + guard !items.isEmpty else { return nil } + let x = Int32(x), y = Int32(y) + var current = 0 + guard contains(items[0], x, y) else { return nil } + while true { + var child = items[current].firstChild + var found: Int32 = -1 + while child >= 0 { + if contains(items[Int(child)], x, y) { found = child; break } + child = items[Int(child)].nextSibling + } + if found < 0 { return current } + current = Int(found) + } + } + + /** + * Finds the item that shows a node. + * + * Follows the node's ancestor chain down from the layout root. When the + * node itself was not laid out (too small, culled or merged), the nearest + * laid-out ancestor is returned instead. + * + * @param {Node} node - A node inside the layout's root. + * @returns {Int?} Index into `items`, or nil when the node is not under the root. + * + * @example + * if let i = layout.find(selectedNode) { highlight(layout.items[i]) } + */ + func find(_ node: Node) -> Int? { + guard !items.isEmpty else { return nil } + let root = items[0].node + var chain: [Node] = [node] + var n = node.parent + while let p = n, chain.last !== root { + chain.append(p) + n = p.parent + } + guard chain.last === root else { return nil } + var current = 0 + for target in chain.reversed().dropFirst() { + var child = items[current].firstChild + var found: Int32 = -1 + while child >= 0 { + if items[Int(child)].node === target { found = child; break } + child = items[Int(child)].nextSibling + } + if found < 0 { break } + current = Int(found) + } + return current + } + + /** + * Updates `focusIndex` to the deepest item covering the entire view. + * + * Called once after layout. At zoom 1 that is the root; when zoomed deep + * into one folder (or file), it is that folder, which the header shows. + * + * @example + * layout.computeFocus() + * let focus = layout.items[layout.focusIndex].node + */ + func computeFocus() { + guard !items.isEmpty else { return } + var current = 0 + while true { + var child = items[current].firstChild + var next: Int32 = -1 + while child >= 0 { + let it = items[Int(child)] + if it.x0 <= 0 && it.y0 <= 0 && Int(it.x1) >= width && Int(it.y1) >= height { + next = child + break + } + child = it.nextSibling + } + if next < 0 { break } + current = Int(next) + } + focusIndex = current + } + + /** + * Tests whether a pixel lies inside an item's rectangle. + * + * Bounds are half-open: the right and bottom edges belong to the neighbour. + * + * @param {Item} it - The item to test. + * @param {Int32} x - Horizontal pixel coordinate. + * @param {Int32} y - Vertical pixel coordinate. + * @returns {Bool} True if the pixel is inside. + * + * @example + * contains(items[0], 10, 10) + */ + @inline(__always) private func contains(_ it: Item, _ x: Int32, _ y: Int32) -> Bool { + x >= it.x0 && x < it.x1 && y >= it.y0 && y < it.y1 + } +} + +/** + * Squarified treemap with cushion shading (van Wijk & van de Wetering), + * in the style of WinDirStat / WizTree, with a zoomable viewport. + */ +enum TreemapRenderer { + /** Everything one render needs besides the tree itself. */ + struct Params { + /** Output width in pixels (the visible view). */ + var width: Int + /** Output height in pixels (the visible view). */ + var height: Int + /** Which size determines each rectangle's area. */ + var sizeMode: SizeMode + /** Colour per extension. */ + var colors: ExtColors + /** When set, every other extension is drawn in dimmed grey. */ + var highlightExt: UInt16? + /** Zoom factor: the whole root spans `width × scale` by `height × scale` pixels… */ + var scale: Double = 1 + /** …of which the view shows the part starting at this horizontal offset (pixels)… */ + var offsetX: Double = 0 + /** …and this vertical offset (pixels). */ + var offsetY: Double = 0 + } + + /** Cushion height added at the root level. */ + private static let initialHeight = 0.40 + /** Factor by which the cushion height shrinks per nesting level. */ + private static let scaleFactor = 0.90 + /** Ambient light share, so cushion edges never go fully black. */ + private static let ambient: Float = 0.18 + /** Overall brightness boost applied to the base colours. */ + private static let brightness: Float = 1.18 + /** Normalised light direction, from the top left and mostly frontal. */ + private static let light: (x: Double, y: Double, z: Double) = { + let (x, y, z) = (-1.0, -1.0, 10.0) + let len = (x * x + y * y + z * z).squareRoot() + return (x / len, y / len, z / len) + }() + + /** Directories smaller than this (pixels, either side) are drawn as one block. */ + private static let minDirSide = 3.0 + /** Children below this many square pixels are merged into one block. */ + private static let minArea = 2.0 + /** Deepest nesting level laid out; keeps single-child chains from exhausting the stack. */ + private static let maxDepth = 160 + + /** One visible cushion to shade: its clamped pixel bounds, surface and colour. */ + private struct Leaf { + /** Pixel bounds clipped to the view: left, top, right and bottom (exclusive). */ + let x0: Int32, y0: Int32, x1: Int32, y1: Int32 + /** Cushion surface coefficients accumulated from all enclosing rectangles. */ + let s0: Double, s1: Double, s2: Double, s3: Double + /** Base colour before shading. */ + let color: RGB + } + + /** + * Lays out children with the squarified algorithm. + * + * Places `kids[0.. Double} metric - Size of a child. + * @param {Double} x - Left edge of the area. + * @param {Double} y - Top edge of the area. + * @param {Double} w - Width of the area. + * @param {Double} h - Height of the area. + * @param {Double} minArea - Smallest area a child may get before the rest is merged. + * @param {() -> Bool} [shouldStop={ false }] - Polled per row; returning true stops early. + * @param {(Int, Double, Double, Double, Double) -> Void} place - Receives a child index and its rectangle. + * @param {(Int, Double, Double, Double, Double) -> Void} rest - Receives the first merged child index and the leftover rectangle. + * + * @example + * squarify(kids, kids.count, metric: { Double($0.alloc) }, x: 0, y: 0, w: 800, h: 600, minArea: 2, + * place: { k, x, y, w, h in print(kids[k].name, x, y, w, h) }, rest: { _, _, _, _, _ in }) + */ + @inline(__always) + private static func squarify(_ kids: [Node], _ n: Int, metric: (Node) -> Double, + x: Double, y: Double, w: Double, h: Double, minArea: Double, + shouldStop: () -> Bool = { false }, + place: (Int, Double, Double, Double, Double) -> Void, + rest: (Int, Double, Double, Double, Double) -> Void) { + var remaining = 0.0 + for k in 0..= rh + let side = vertical ? rh : rw + let maxA = metric(kids[i]) * areaScale + var rowSum = 0.0 + var worst = Double.infinity + var j = i + while j < n { + let s = metric(kids[j]) + let newSum = rowSum + s + let t = newSum * areaScale / side + let tt = t * t + let newWorst = max(tt / (s * areaScale), maxA / tt) + if j > i && newWorst > worst { break } + worst = newWorst + rowSum = newSum + j += 1 + } + let t = rowSum * areaScale / side + var offset = 0.0 + for k in i.. Bool + /** Set once cancellation was observed, to unwind the recursion. */ + var cancelled = false + /** View width in pixels, as a Double for geometry. */ + let viewW: Double + /** View height in pixels, as a Double for geometry. */ + let viewH: Double + + /** + * Prepares a layout pass for one render. + * + * @param {Params} params - Output size, viewport, colours and size mode. + * @param {() -> Bool} isCancelled - Checked during layout; true abandons the pass. + * + * @example + * let builder = Builder(params: params) { token.isCancelled } + */ + init(params: Params, isCancelled: @escaping () -> Bool) { + self.params = params + self.layout = TreemapLayout(width: params.width, height: params.height) + self.isCancelled = isCancelled + viewW = Double(params.width) + viewH = Double(params.height) + } + + /** + * Returns the size that determines a node's area. + * + * @param {Node} n - The node to measure. + * @returns {Double} Logical or allocated bytes, per `params.sizeMode`. + * + * @example + * let total = kids.reduce(0) { $0 + metric($1) } + */ + @inline(__always) func metric(_ n: Node) -> Double { + Double(params.sizeMode == .allocated ? n.alloc : n.size) + } + + /** + * Picks the base colour for a leaf. + * + * Files use their extension's colour. A directory drawn as a single block + * (too small to subdivide) uses the colour of its largest file. With an + * extension highlighted, everything else becomes a dim grey that keeps a + * hint of the original brightness. + * + * @param {Node} node - The file or directory being drawn as one block. + * @returns {RGB} The colour to shade. + * + * @example + * addLeaf(x0, y0, x1, y1, surface, color(for: node)) + */ + func color(for node: Node) -> RGB { + let file = node.isDir ? node.dominantFile : node + let ext = file?.ext ?? 0 + var c = params.colors.color(ext) + if let hl = params.highlightExt, hl != ext || file == nil { + let lum = (c.r * 0.3 + c.g * 0.59 + c.b * 0.11) * 0.35 + 0.08 + c = RGB(lum, lum, lum) + } + return c + } + + /** + * Clamps a horizontal coordinate to just outside the view. + * + * Keeps stored rectangles within Int32 range even at high zoom, where + * layout coordinates can be millions of pixels away. + * + * @param {Double} v - Horizontal pixel coordinate. + * @returns {Int32} The coordinate limited to -1…width+1. + * + * @example + * let x0 = clampX(-12_000.0) // -1 + */ + @inline(__always) func clampX(_ v: Double) -> Int32 { Int32(min(max(v, -1), viewW + 1)) } + + /** + * Clamps a vertical coordinate to just outside the view. + * + * @param {Double} v - Vertical pixel coordinate. + * @returns {Int32} The coordinate limited to -1…height+1. + * + * @example + * let y1 = clampY(9_999_999.0) // height + 1 + */ + @inline(__always) func clampY(_ v: Double) -> Int32 { Int32(min(max(v, -1), viewH + 1)) } + + /** + * Adds an item for `node` covering (x, y, w, h) and recurses into it. + * + * Coordinates are view pixels and may lie far outside the view when + * zoomed. Rectangles are snapped to whole pixels; empty ones and ones + * entirely outside the view are skipped, since there is nothing to draw + * or hit-test there. The cushion ridge uses the true, unclamped extent so + * shading stays continuous while zooming. Directories large enough are + * subdivided; smaller ones, and files, become a single shaded leaf. The + * depth cap stops pathological single-child chains from exhausting the + * stack. Checks for cancellation every 1024 items. + * + * @param {Node} node - The file or directory to place. + * @param {Double} x - Left edge in view pixels. + * @param {Double} y - Top edge in view pixels. + * @param {Double} w - Width in pixels. + * @param {Double} h - Height in pixels. + * @param {Int32} parent - Index of the parent item, or -1 for the root. + * @param {inout Int32} lastSibling - The parent's last child so far; updated to link the new item. + * @param {(Double, Double, Double, Double)} surface - Cushion coefficients inherited from the parent. + * @param {Double} height - Ridge height for this level. + * @param {Int} depth - Nesting level below the root. + * + * @example + * var last: Int32 = -1 + * builder.place(root, x: 0, y: 0, w: 2400, h: 800, parent: -1, lastSibling: &last, + * surface: (0, 0, 0, 0), height: 0.4, depth: 0) + */ + func place(_ node: Node, x: Double, y: Double, w: Double, h: Double, + parent: Int32, lastSibling: inout Int32, + surface: (Double, Double, Double, Double), height: Double, depth: Int) { + let fx0 = x.rounded(), fy0 = y.rounded() + let fx1 = (x + w).rounded(), fy1 = (y + h).rounded() + guard fx1 > fx0, fy1 > fy0 else { return } + guard fx1 > 0, fy1 > 0, fx0 < viewW, fy0 < viewH else { return } + + let index = Int32(layout.items.count) + layout.items.append(TreemapLayout.Item(node: node, x0: clampX(fx0), y0: clampY(fy0), + x1: clampX(fx1), y1: clampY(fy1))) + if parent >= 0 { + if lastSibling >= 0 { + layout.items[Int(lastSibling)].nextSibling = index + } else { + layout.items[Int(parent)].firstChild = index + } + lastSibling = index + } + + var s = surface + addRidge(fx0, fx1, fy0, fy1, &s, height) + + let pw = fx1 - fx0, ph = fy1 - fy0 + if node.isDir, pw >= TreemapRenderer.minDirSide, ph >= TreemapRenderer.minDirSide, + !node.children.isEmpty, metric(node) > 0, depth < TreemapRenderer.maxDepth { + if (index & 0x3FF) == 0 && isCancelled() { cancelled = true; return } + layoutChildren(of: node, x: fx0, y: fy0, w: pw, h: ph, index: index, + surface: s, height: height * TreemapRenderer.scaleFactor, depth: depth + 1) + } else if node.isDir && metric(node) <= 0 { + return + } else { + addLeaf(fx0, fy0, fx1, fy1, s, color(for: node)) + } + } + + /** + * Subdivides a directory's rectangle among its children. + * + * Zero-size children at the end of the (sorted) list are ignored. When + * the remaining children would be sub-pixel, their space is drawn as one + * block coloured like the first of them. + * + * @param {Node} node - The directory being subdivided. + * @param {Double} x - Left edge in view pixels. + * @param {Double} y - Top edge in view pixels. + * @param {Double} w - Width in pixels. + * @param {Double} h - Height in pixels. + * @param {Int32} index - The directory's item index, parent of the new items. + * @param {(Double, Double, Double, Double)} surface - The directory's cushion coefficients. + * @param {Double} height - Ridge height for the children's level. + * @param {Int} depth - Nesting level of the children. + * + * @example + * layoutChildren(of: dir, x: 0, y: 0, w: 800, h: 600, index: 0, + * surface: s, height: 0.36, depth: 1) + */ + func layoutChildren(of node: Node, x: Double, y: Double, w: Double, h: Double, index: Int32, + surface: (Double, Double, Double, Double), height: Double, depth: Int) { + let kids = node.children + var n = kids.count + while n > 0 && metric(kids[n - 1]) <= 0 { n -= 1 } + guard n > 0 else { return } + var lastSibling: Int32 = -1 + TreemapRenderer.squarify(kids, n, metric: metric, x: x, y: y, w: w, h: h, + minArea: TreemapRenderer.minArea, shouldStop: { self.cancelled }, + place: { k, cx, cy, cw, ch in + self.place(kids[k], x: cx, y: cy, w: cw, h: ch, parent: index, + lastSibling: &lastSibling, surface: surface, height: height, depth: depth) + }, rest: { k, rx, ry, rw, rh in + let fx0 = rx.rounded(), fy0 = ry.rounded(), fx1 = (rx + rw).rounded(), fy1 = (ry + rh).rounded() + guard fx1 > fx0, fy1 > fy0 else { return } + var s = surface + self.addRidge(fx0, fx1, fy0, fy1, &s, height) + self.addLeaf(fx0, fy0, fx1, fy1, s, self.color(for: kids[k])) + }) + } + + /** + * Records the visible part of a cushion for shading. + * + * The rectangle is clipped to the view; nothing is recorded when no pixel + * of it is visible. The surface coefficients still describe the full, + * unclipped cushion. + * + * @param {Double} fx0 - Left edge in view pixels. + * @param {Double} fy0 - Top edge in view pixels. + * @param {Double} fx1 - Right edge in view pixels. + * @param {Double} fy1 - Bottom edge in view pixels. + * @param {(Double, Double, Double, Double)} s - Cushion surface coefficients. + * @param {RGB} color - Base colour. + * + * @example + * addLeaf(0, 0, 40, 30, surface, RGB.gray) + */ + func addLeaf(_ fx0: Double, _ fy0: Double, _ fx1: Double, _ fy1: Double, + _ s: (Double, Double, Double, Double), _ color: RGB) { + let x0 = Int32(max(fx0, 0)), y0 = Int32(max(fy0, 0)) + let x1 = Int32(min(fx1, viewW)), y1 = Int32(min(fy1, viewH)) + guard x1 > x0, y1 > y0 else { return } + leaves.append(Leaf(x0: x0, y0: y0, x1: x1, y1: y1, s0: s.0, s1: s.1, s2: s.2, s3: s.3, color: color)) + } + + /** + * Adds one level's parabolic ridge to a cushion surface. + * + * Each nesting level contributes a ridge spanning its rectangle in both + * directions (van Wijk & van de Wetering), which gives nested cushions + * their layered look. + * + * @param {Double} x0 - Left edge. + * @param {Double} x1 - Right edge. + * @param {Double} y0 - Top edge. + * @param {Double} y1 - Bottom edge. + * @param {inout (Double, Double, Double, Double)} s - Surface coefficients to update. + * @param {Double} h - Ridge height for this level. + * + * @example + * var s = (0.0, 0.0, 0.0, 0.0) + * addRidge(0, 800, 0, 600, &s, 0.4) + */ + @inline(__always) + func addRidge(_ x0: Double, _ x1: Double, _ y0: Double, _ y1: Double, + _ s: inout (Double, Double, Double, Double), _ h: Double) { + let h4 = 4 * h + let wf = h4 / (x1 - x0) + s.2 += wf * (x1 + x0) + s.0 -= wf + let hf = h4 / (y1 - y0) + s.3 += hf * (y1 + y0) + s.1 -= hf + } + } + + /** + * Lays out and shades `root` into a picture. + * + * Layout reads the tree under `treeLock`; shading then runs in parallel over + * chunks of leaves, which cover disjoint pixels. Pixels no leaf covers keep + * the dark background. Safe to call off the main thread. + * + * @param {Node} root - The directory to show as the whole treemap. + * @param {Params} params - Output size, viewport, colours and size mode. + * @param {() -> Bool} isCancelled - Polled during layout; true abandons the render. + * @returns {(CGImage?, TreemapLayout)?} The picture and its layout, or nil when cancelled or the size is empty. + * + * @example + * if let (image, layout) = TreemapRenderer.render(root: root, params: params, isCancelled: { false }) { + * show(image, layout) + * } + */ + static func render(root: Node, params: Params, isCancelled: @escaping () -> Bool) -> (CGImage?, TreemapLayout)? { + guard params.width > 0, params.height > 0 else { return nil } + let builder = Builder(params: params, isCancelled: isCancelled) + treeLock.lock() + var last: Int32 = -1 + builder.place(root, x: -params.offsetX, y: -params.offsetY, + w: Double(params.width) * params.scale, h: Double(params.height) * params.scale, + parent: -1, lastSibling: &last, surface: (0, 0, 0, 0), height: initialHeight, depth: 0) + treeLock.unlock() + if builder.cancelled || isCancelled() { return nil } + builder.layout.computeFocus() + + let width = params.width, height = params.height + let pixelCount = width * height + let pixels = UnsafeMutablePointer.allocate(capacity: pixelCount) + pixels.initialize(repeating: 0xFF1C1C1E, count: pixelCount) + + let leaves = builder.leaves + let chunk = 256 + let chunks = (leaves.count + chunk - 1) / chunk + leaves.withUnsafeBufferPointer { buf in + DispatchQueue.concurrentPerform(iterations: max(chunks, 1)) { c in + let lo = c * chunk, hi = min(buf.count, lo + chunk) + guard lo < hi else { return } + for li in lo.. CGRect? { + var chain: [Node] = [] + var n: Node? = node + while let c = n, c !== root { + chain.append(c) + n = c.parent + } + guard n === root else { return nil } + let metric: (Node) -> Double = { Double(mode == .allocated ? $0.alloc : $0.size) } + var rect = CGRect(x: 0, y: 0, width: width, height: height) + var parent = root + for target in chain.reversed() { + let kids = parent.children + var count = kids.count + while count > 0 && metric(kids[count - 1]) <= 0 { count -= 1 } + var found: CGRect? + squarify(kids, count, metric: metric, x: rect.minX, y: rect.minY, w: rect.width, h: rect.height, + minArea: 0, shouldStop: { found != nil }, place: { k, x, y, w, h in + if kids[k] === target { found = CGRect(x: x, y: y, width: w, height: h) } + }, rest: { _, _, _, _, _ in }) + guard let found else { return nil } + rect = found + parent = target + } + return rect + } + + /** + * Shades one cushion into the pixel buffer. + * + * For each pixel, derives the cushion's surface normal from the leaf's + * coefficients, lights it with ambient plus diffuse light from `light`, + * and writes the resulting BGRA colour. Leaves never overlap, so several + * threads can shade different leaves into the same buffer at once. + * + * @param {Leaf} leaf - The cushion to shade; bounds already clipped to the buffer. + * @param {UnsafeMutablePointer} pixels - The picture, one 32-bit pixel per entry. + * @param {Int} width - Buffer width in pixels (the row stride). + * + * @example + * for leaf in leaves { shade(leaf, pixels, width) } + */ + @inline(__always) + private static func shade(_ leaf: Leaf, _ pixels: UnsafeMutablePointer, _ width: Int) { + let lx = light.x, ly = light.y, lz = light.z + let isf = 1 - ambient + let cr = leaf.color.r * brightness, cg = leaf.color.g * brightness, cb = leaf.color.b * brightness + for iy in Int(leaf.y0).. 1 { cosa = 1 } + var p = isf * Float(cosa) + if p < 0 { p = 0 } + p += ambient + let r = min(255, max(0, Int(cr * p * 255))) + let g = min(255, max(0, Int(cg * p * 255))) + let b = min(255, max(0, Int(cb * p * 255))) + row[ix] = 0xFF00_0000 | UInt32(r) << 16 | UInt32(g) << 8 | UInt32(b) + } + } + } +} diff --git a/Sources/MacTree/Model/Volumes.swift b/Sources/MacTree/Model/Volumes.swift new file mode 100644 index 0000000..436dcb6 --- /dev/null +++ b/Sources/MacTree/Model/Volumes.swift @@ -0,0 +1,232 @@ +import Foundation +import Darwin + +/** A mounted file system, as reported by getmntinfo(3). */ +struct MountEntry { + /** Where the file system is mounted, e.g. "/System/Volumes/Data". */ + let mountPoint: String + /** Mounted device, e.g. "/dev/disk3s5", or a pseudo name such as "map auto_home". */ + let device: String + /** File system type, e.g. "apfs", "devfs" or "autofs". */ + let fsType: String + + /** Whole-disk name ("disk3" for "/dev/disk3s5"), or nil for network, devfs and other non-disk mounts. */ + var wholeDisk: String? { + guard device.hasPrefix("/dev/disk") else { return nil } + let rest = device.dropFirst("/dev/".count) + var end = rest.index(rest.startIndex, offsetBy: 4) + while end < rest.endIndex, rest[end].isNumber { end = rest.index(after: end) } + return String(rest[.. [MountEntry] { + var buf: UnsafeMutablePointer? + let n = getmntinfo(&buf, MNT_NOWAIT) + guard n > 0, let buf else { return [] } + return (0.. MountEntry? { + var s = statfs() + guard statfs(path, &s) == 0 else { return nil } + return MountEntry( + mountPoint: cString(&s.f_mntonname), + device: cString(&s.f_mntfromname), + fsType: cString(&s.f_fstypename) + ) + } + + /** + * Converts a fixed-size C character array from `statfs` into a String. + * + * Reads up to the first NUL byte. + * + * @param {T} tuple - A C char array imported as a Swift tuple, e.g. `f_mntonname`. + * @returns {String} The decoded text. + * + * @example + * var s = statfs(); statfs("/", &s) + * let point = cString(&s.f_mntonname) // "/" + */ + private static func cString(_ tuple: inout T) -> String { + withUnsafePointer(to: &tuple) { p in + p.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size) { String(cString: $0) } + } + } +} + +/** + * Decides which directories a scan may enter so that each byte is counted once. + * + * The scan stays within the APFS container (or physical disk) of the root. On + * the boot volume it also skips the Data-volume paths that are reachable + * through firmlinks as well. + */ +struct TraversalPolicy { + /** Devices (st_dev) the scan may enter. */ + let allowedDevices: Set + /** Absolute directory paths never entered. */ + let skipPaths: Set + + /** + * Builds the policy for scanning `root`. + * + * Allowed devices are the root's own device plus every other volume in + * the same container, so scanning "/" also covers the Data, VM and + * Preboot volumes. devfs, autofs, nullfs and fdesc mounts, other disks, + * and disk images such as simulator runtimes stay excluded. + * + * When the Data volume is mounted inside the scan root, each firmlink + * listed in /usr/share/firmlinks (lines of "\t") whose system-side path is also inside the root + * adds its Data-volume path to the skip list. Otherwise the same folders + * would be counted twice, once through the firmlink and once under + * /System/Volumes/Data. Scanning "/" additionally skips the magic + * resolver directories /.vol, /.nofollow and /.resolve. + * + * @param {String} root - The resolved absolute path being scanned. + * @returns {TraversalPolicy} The devices and paths the scanner should respect. + * + * @example + * let policy = TraversalPolicy.make(root: "/") + * policy.skipPaths.contains("/System/Volumes/Data/Users") // true + */ + static func make(root: String) -> TraversalPolicy { + let mounts = MountEntry.all() + let rootMount = MountEntry.containing(root) + var devices = Set() + if let d = deviceID(root) { devices.insert(d) } + + let excludedTypes: Set = ["devfs", "autofs", "nullfs", "fdesc"] + if let container = rootMount?.wholeDisk { + for m in mounts where m.wholeDisk == container && !excludedTypes.contains(m.fsType) { + if let d = deviceID(m.mountPoint) { devices.insert(d) } + } + } + + var skip = Set() + let dataMount = "/System/Volumes/Data" + let rootWithSlash = root.hasSuffix("/") ? root : root + "/" + if mounts.contains(where: { $0.mountPoint == dataMount }), dataMount.hasPrefix(rootWithSlash) { + if let text = try? String(contentsOfFile: "/usr/share/firmlinks", encoding: .utf8) { + for line in text.split(separator: "\n") { + let parts = line.split(separator: "\t", maxSplits: 1) + guard parts.count == 2 else { continue } + let source = String(parts[0]) + if source == root || source.hasPrefix(rootWithSlash) { + skip.insert(dataMount + "/" + parts[1]) + } + } + } + } + if root == "/" { + skip.formUnion(["/.vol", "/.nofollow", "/.resolve"]) + } + return TraversalPolicy(allowedDevices: devices, skipPaths: skip) + } + + /** + * Returns the device a path lives on. + * + * Follows symlinks, so firmlinked and mounted directories report the + * volume they resolve to. + * + * @param {String} path - Path to examine. + * @returns {dev_t?} The st_dev value, or nil if the path cannot be examined. + * + * @example + * let dataDevice = deviceID("/System/Volumes/Data") + */ + private static func deviceID(_ path: String) -> dev_t? { + var st = stat() + return stat(path, &st) == 0 ? st.st_dev : nil + } +} + +/** A user-visible volume for the location picker and the space summary. */ +struct VolumeInfo { + /** Root URL of the volume. */ + let url: URL + /** Display name, e.g. "Macintosh HD". */ + let name: String + /** Capacity in bytes. */ + let total: Int64 + /** Free bytes as Finder reports them (purgeable space counts as free). */ + let available: Int64 + + /** Bytes in use: capacity minus available. */ + var used: Int64 { max(0, total - available) } + + /** + * Lists the volumes a user would see in Finder. + * + * Hidden system volumes (Preboot, VM, …) are left out. Volumes whose + * resource values cannot be read are skipped. + * + * @returns {[VolumeInfo]} The browsable mounted volumes. + * + * @example + * for v in VolumeInfo.mounted() { print(v.name, Fmt.bytes(v.available)) } + */ + static func mounted() -> [VolumeInfo] { + let keys: [URLResourceKey] = [.volumeNameKey, .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey] + let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: keys, + options: [.skipHiddenVolumes]) ?? [] + return urls.compactMap { info(for: $0) } + } + + /** + * Describes the volume that contains a URL. + * + * Free space prefers "available for important usage", which counts + * purgeable space as free the way Finder does. It falls back to the plain + * available capacity when the former is zero or unsupported (e.g. on + * some network volumes). + * + * @param {URL} url - Any file URL on the volume. + * @returns {VolumeInfo?} The volume's name and space, or nil if it cannot be queried. + * + * @example + * let home = VolumeInfo.info(for: FileManager.default.homeDirectoryForCurrentUser) + */ + static func info(for url: URL) -> VolumeInfo? { + let keys: Set = [.volumeNameKey, .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey, + .volumeURLKey] + guard let v = try? url.resourceValues(forKeys: keys) else { return nil } + let total = Int64(v.volumeTotalCapacity ?? 0) + var avail = v.volumeAvailableCapacityForImportantUsage ?? 0 + if avail == 0 { avail = Int64(v.volumeAvailableCapacity ?? 0) } + return VolumeInfo(url: v.volume ?? url, name: v.volumeName ?? url.lastPathComponent, + total: total, available: avail) + } +} diff --git a/Sources/MacTree/UI/Cells.swift b/Sources/MacTree/UI/Cells.swift new file mode 100644 index 0000000..2bc4203 --- /dev/null +++ b/Sources/MacTree/UI/Cells.swift @@ -0,0 +1,369 @@ +import AppKit +import UniformTypeIdentifiers + +/** Text cell with an optional icon, used for every column except the bar columns. */ +final class TextCellView: NSTableCellView { + /** The cell's text. */ + let label = NSTextField(labelWithString: "") + /** 16 pt icon shown before the text when the cell was created with one. */ + private let icon = NSImageView() + /** Leading constraint of the label: after the icon, or at the cell edge. */ + private var labelLeading: NSLayoutConstraint! + + /** + * Creates a reusable text cell. + * + * Right-aligned cells are treated as numeric columns: they use monospaced + * digits and clip instead of truncating, so figures line up. Left-aligned + * cells truncate in the middle, keeping both ends of long names visible. + * + * @param {NSUserInterfaceItemIdentifier} identifier - Reuse identifier, normally the column id. + * @param {NSTextAlignment} alignment - Text alignment; `.right` selects the numeric style. + * @param {Bool} hasIcon - Whether to reserve room for a 16 pt icon before the text. + * + * @example + * let cell = TextCellView(identifier: .colSize, alignment: .right, hasIcon: false) + */ + init(identifier: NSUserInterfaceItemIdentifier, alignment: NSTextAlignment, hasIcon: Bool) { + super.init(frame: .zero) + self.identifier = identifier + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = alignment == .right ? .byClipping : .byTruncatingMiddle + label.alignment = alignment + label.font = alignment == .right + ? .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + : .systemFont(ofSize: NSFont.systemFontSize(for: .small)) + label.cell?.truncatesLastVisibleLine = true + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(label) + textField = label + + if hasIcon { + icon.translatesAutoresizingMaskIntoConstraints = false + icon.imageScaling = .scaleProportionallyUpOrDown + addSubview(icon) + imageView = icon + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + icon.centerYAnchor.constraint(equalTo: centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 16), + icon.heightAnchor.constraint(equalToConstant: 16), + ]) + labelLeading = label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4) + } else { + labelLeading = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2) + } + NSLayoutConstraint.activate([ + labelLeading, + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -3), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + /** + * Unsupported: cells are built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Fills the cell for a row. + * + * Always resets the icon and colour too, since cells are reused across rows. + * + * @param {String} text - Text to show. + * @param {NSImage?} [image=nil] - Icon to show; nil clears it. + * @param {Bool} [dimmed=false] - Draw the text in the secondary colour (unreadable or secondary items). + * + * @example + * cell.set(node.name, icon: Icons.icon(for: node), dimmed: node.flags.contains(.denied)) + */ + func set(_ text: String, icon image: NSImage? = nil, dimmed: Bool = false) { + label.stringValue = text + label.textColor = dimmed ? .secondaryLabelColor : .labelColor + icon.image = image + } +} + +/** "% of parent" style cell: a proportional bar with the percentage on top. */ +final class BarCellView: NSTableCellView { + /** Filled share of the bar, 0…1 (values above 1 are drawn full). */ + var fraction: Double = 0 { didSet { needsDisplay = true } } + /** Colour of the filled part. */ + var barColor: NSColor = .controlAccentColor { didSet { needsDisplay = true } } + /** Label drawn right-aligned over the bar, e.g. "35.1 %". */ + var text: String = "" { didSet { needsDisplay = true } } + + /** + * Creates a reusable bar cell. + * + * @param {NSUserInterfaceItemIdentifier} identifier - Reuse identifier, normally the column id. + * + * @example + * let cell = outlineView.cell(.colPercent) { BarCellView(identifier: .colPercent) } + */ + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + } + + /** + * Unsupported: cells are built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Draws the track, the filled share and the percentage text. + * + * Any non-zero fraction gets at least a 1.5 pt sliver so tiny shares stay + * visible. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * cell.fraction = 0.35 // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + let inset = bounds.insetBy(dx: 3, dy: 3) + let track = NSBezierPath(roundedRect: inset, xRadius: 2.5, yRadius: 2.5) + NSColor.quaternaryLabelColor.withAlphaComponent(0.25).setFill() + track.fill() + if fraction > 0 { + var fill = inset + fill.size.width = max(1.5, inset.width * CGFloat(min(1, fraction))) + let bar = NSBezierPath(roundedRect: fill, xRadius: 2.5, yRadius: 2.5) + barColor.withAlphaComponent(0.85).setFill() + bar.fill() + } + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular), + .foregroundColor: NSColor.labelColor, + ] + let s = text as NSString + let size = s.size(withAttributes: attrs) + s.draw(at: NSPoint(x: inset.maxX - size.width - 3, y: bounds.midY - size.height / 2), withAttributes: attrs) + } +} + +/** Small colour square plus extension name for the file-type list. */ +final class SwatchCellView: NSTableCellView { + /** Extension name, e.g. ".mov". */ + let label = NSTextField(labelWithString: "") + /** Treemap colour of the extension, drawn as the swatch. */ + var color: NSColor = .gray { didSet { needsDisplay = true } } + + /** + * Creates a reusable swatch cell with room for the 12 pt square before the label. + * + * @param {NSUserInterfaceItemIdentifier} identifier - Reuse identifier, normally the column id. + * + * @example + * let cell = tableView.cell(.colExt) { SwatchCellView(identifier: .colExt) } + */ + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) + label.lineBreakMode = .byTruncatingTail + addSubview(label) + textField = label + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 22), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + /** + * Unsupported: cells are built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Draws the rounded colour square with a faint outline so light colours stay visible. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * cell.color = colors.nsColor(stat.id) // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + let r = NSRect(x: 4, y: bounds.midY - 6, width: 12, height: 12) + let p = NSBezierPath(roundedRect: r, xRadius: 2.5, yRadius: 2.5) + color.setFill() + p.fill() + NSColor.black.withAlphaComponent(0.25).setStroke() + p.lineWidth = 0.5 + p.stroke() + } +} + +/** + * 16 pt icons for tree and file rows. + * + * Main-thread only: the caches are plain static storage. + */ +enum Icons { + /** Icon per extension id, filled on first use. */ + private static var byExt: [UInt16: NSImage] = [:] + /** Generic folder icon. */ + static let folder: NSImage = sized(NSWorkspace.shared.icon(for: .folder)) + /** Volume icon, used for the scan root. */ + static let volume: NSImage = sized(NSWorkspace.shared.icon(for: .volume)) + /** Symbolic link icon. */ + static let symlink: NSImage = sized(NSWorkspace.shared.icon(for: .symbolicLink)) + /** Plain document icon for unknown types. */ + static let generic: NSImage = sized(NSWorkspace.shared.icon(for: .data)) + /** Padlock shown for folders that could not be read. */ + static let locked: NSImage = { + let img = NSImage(systemSymbolName: "lock.fill", accessibilityDescription: nil) ?? folder + return img + }() + + /** Real icons of `.app` bundles, keyed by path; evictable under memory pressure. */ + private static let bundleIcons = NSCache() + + /** + * Picks the icon for a tree or file-list row. + * + * The scan root shows a volume icon, unreadable folders a padlock, app + * bundles their own icon (looked up by path and cached), other folders the + * folder icon. Files get the system icon for their extension's type, + * cached per extension; unknown types fall back to a generic document. + * + * @param {Node} node - The row's node. + * @returns {NSImage} A 16×16 icon. + * + * @example + * cell.set(node.name, icon: Icons.icon(for: node)) + */ + static func icon(for node: Node) -> NSImage { + if node.isDir { + if node.parent == nil { return volume } + if node.flags.contains(.denied) { return locked } + if node.name.hasSuffix(".app") { + let path = node.path as NSString + if let img = bundleIcons.object(forKey: path) { return img } + let img = sized(NSWorkspace.shared.icon(forFile: path as String)) + bundleIcons.setObject(img, forKey: path) + return img + } + return folder + } + if node.flags.contains(.symlink) { return symlink } + if let img = byExt[node.ext] { return img } + let ext = ExtensionTable.shared.name(node.ext) + let img: NSImage + if !ext.isEmpty, let type = UTType(filenameExtension: ext) { + img = sized(NSWorkspace.shared.icon(for: type)) + } else { + img = generic + } + byExt[node.ext] = img + return img + } + + /** + * Sets an image's display size to 16×16 points. + * + * Mutates and returns the same image; NSWorkspace hands out a fresh image + * per call, so no shared icon is affected. + * + * @param {NSImage} image - Image to resize. + * @returns {NSImage} The same image, now 16×16 points. + * + * @example + * let icon = sized(NSWorkspace.shared.icon(for: .folder)) + */ + private static func sized(_ image: NSImage) -> NSImage { + image.size = NSSize(width: 16, height: 16) + return image + } +} + +extension NSUserInterfaceItemIdentifier { + /** Name column (tree, file list). */ + static let colName = NSUserInterfaceItemIdentifier("name") + /** Percentage bar column. */ + static let colPercent = NSUserInterfaceItemIdentifier("percent") + /** Logical size column. */ + static let colSize = NSUserInterfaceItemIdentifier("size") + /** Allocated size column. */ + static let colAlloc = NSUserInterfaceItemIdentifier("alloc") + /** Files plus folders column. */ + static let colItems = NSUserInterfaceItemIdentifier("items") + /** File count column. */ + static let colFiles = NSUserInterfaceItemIdentifier("files") + /** Folder count column. */ + static let colFolders = NSUserInterfaceItemIdentifier("folders") + /** Modification date column. */ + static let colModified = NSUserInterfaceItemIdentifier("modified") + /** Containing folder column (file list, unreadable-folders sheet). */ + static let colPath = NSUserInterfaceItemIdentifier("path") + /** Extension column of the file-type list. */ + static let colExt = NSUserInterfaceItemIdentifier("ext") + /** File count column of the file-type list. */ + static let colCount = NSUserInterfaceItemIdentifier("count") +} + +extension NSTableView { + /** + * Adds a sortable column. + * + * The column's sort descriptor is keyed by the identifier's raw value, so + * `sortDescriptorsDidChange` handlers can switch on the column id. + * + * @param {NSUserInterfaceItemIdentifier} id - Column identifier, also the sort key. + * @param {String} title - Header title. + * @param {CGFloat} width - Initial width in points. + * @param {CGFloat} [minWidth=40] - Minimum width in points. + * @param {NSTextAlignment} [alignment=.left] - Header alignment. + * @param {Bool} [ascendingFirst=false] - Whether the first click sorts ascending (names) rather than descending (sizes). + * @returns {NSTableColumn} The added column. + * + * @example + * table.addColumn(.colSize, title: L.colSize, width: 80, alignment: .right) + */ + @discardableResult + func addColumn(_ id: NSUserInterfaceItemIdentifier, title: String, width: CGFloat, minWidth: CGFloat = 40, + alignment: NSTextAlignment = .left, ascendingFirst: Bool = false) -> NSTableColumn { + let col = NSTableColumn(identifier: id) + col.title = title + col.width = width + col.minWidth = minWidth + col.headerCell.alignment = alignment + col.sortDescriptorPrototype = NSSortDescriptor(key: id.rawValue, ascending: ascendingFirst) + addTableColumn(col) + return col + } + + /** + * Returns a recycled cell view for `id`, or makes a new one. + * + * @param {NSUserInterfaceItemIdentifier} id - Reuse identifier. + * @param {() -> T} make - Builds a new cell when none can be reused. + * @returns {T} A cell of the requested type. + * + * @example + * let cell = tableView.cell(.colName) { TextCellView(identifier: .colName, alignment: .left, hasIcon: true) } + */ + func cell(_ id: NSUserInterfaceItemIdentifier, make: () -> T) -> T { + if let v = makeView(withIdentifier: id, owner: nil) as? T { return v } + return make() + } +} diff --git a/Sources/MacTree/UI/DeletionMarks.swift b/Sources/MacTree/UI/DeletionMarks.swift new file mode 100644 index 0000000..0480723 --- /dev/null +++ b/Sources/MacTree/UI/DeletionMarks.swift @@ -0,0 +1,52 @@ +import AppKit + +/** + * Table / outline row that outlines itself in red while its item is marked + * for permanent deletion. + */ +final class MarkableRowView: NSTableRowView { + /** Whether the row's item is marked for deletion; redraws on change. */ + var isMarked = false { + didSet { if isMarked != oldValue { needsDisplay = true } } + } + + /** + * Draws the standard row, then a red rounded border when marked. + * + * The border sits inside the row bounds so neighbouring rows never + * overlap it, and it is drawn over the selection highlight so a marked + * row stays recognisable while selected. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * rowView.isMarked = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard isMarked else { return } + let border = NSBezierPath(roundedRect: bounds.insetBy(dx: 1.5, dy: 1.5), xRadius: 4, yRadius: 4) + border.lineWidth = 2 + NSColor.systemRed.setStroke() + border.stroke() + } +} + +extension NSEvent { + /** + * Whether this key event is Delete (⌫) or Forward Delete (⌦) without ⌘, ⌥, ⌃ or ⇧. + * + * ⌘⌫ stays reserved for Move to Trash through the menu. The Fn / keypad + * flags that Forward Delete sets on some keyboards are ignored. + * + * @returns {Bool} True for a plain Delete key press. + * + * @example + * if event.isPlainDeleteKey { owner?.toggleDeletionMark() } + */ + var isPlainDeleteKey: Bool { + guard keyCode == 51 || keyCode == 117 else { return false } + let modifiers = modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.function, .numericPad]) + return modifiers.isEmpty + } +} diff --git a/Sources/MacTree/UI/ExtensionListController.swift b/Sources/MacTree/UI/ExtensionListController.swift new file mode 100644 index 0000000..67b6b23 --- /dev/null +++ b/Sources/MacTree/UI/ExtensionListController.swift @@ -0,0 +1,246 @@ +import AppKit + +/** Receives selections from the file-type list. */ +protocol ExtensionListDelegate: AnyObject { + /** + * Called when the selected file type changes. + * + * Not called for selection changes the list makes itself while reloading. + * + * @param {UInt16?} ext - Selected extension id, or nil when nothing is selected. + * + * @example + * func extensionList(didSelect ext: UInt16?) { treemap.highlightExt = ext } + */ + func extensionList(didSelect ext: UInt16?) + + /** + * Called when a file type is double-clicked. + * + * @param {UInt16} ext - The double-clicked extension id. + * + * @example + * func extensionList(didActivate ext: UInt16) { searchField.stringValue = "*." + ExtensionTable.shared.name(ext) } + */ + func extensionList(didActivate ext: UInt16) +} + +/** Per-extension totals with the colour used in the treemap. */ +final class ExtensionListController: NSObject, NSTableViewDataSource, NSTableViewDelegate { + /** The file-type table. */ + let table = NSTableView() + /** Scroll view hosting `table`; this is what goes into the window. */ + let scrollView = NSScrollView() + /** Receives selection and double-click events. */ + weak var delegate: ExtensionListDelegate? + + /** Rows, in the current sort order. */ + private var stats: [ExtStat] = [] + /** Sum of all rows in the current size mode; the base for the percentage column. */ + private var total: Int64 = 0 + /** Swatch and bar colours per extension. */ + private var colors: ExtColors? + /** Which size the Size and % columns show. */ + private var sizeMode: SizeMode = .allocated + /** Column id the rows are sorted by. */ + private var sortKey = NSUserInterfaceItemIdentifier.colSize.rawValue + /** Whether the current sort is ascending. */ + private var ascending = false + /** Set while the list changes its own selection, so the delegate is not notified. */ + private var suppressSelection = false + + /** + * Builds the table with Extension, %, Size and Files columns. + * + * Column widths and sort order are autosaved under "ExtTable". Double-click + * is routed to `doubleClicked()`. + * + * @example + * let exts = ExtensionListController() + * exts.delegate = self + */ + override init() { + super.init() + table.headerView = NSTableHeaderView() + table.usesAlternatingRowBackgroundColors = true + table.style = .fullWidth + table.rowHeight = 20 + table.intercellSpacing = NSSize(width: 6, height: 0) + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + table.addColumn(.colExt, title: L.colExtension, width: 92, minWidth: 60, ascendingFirst: true) + table.addColumn(.colPercent, title: L.colPercentTotal, width: 72, minWidth: 50) + table.addColumn(.colSize, title: L.colSize, width: 68, alignment: .right) + table.addColumn(.colCount, title: L.colFiles, width: 60, alignment: .right) + table.autosaveName = "ExtTable" + table.autosaveTableColumns = true + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(doubleClicked) + + scrollView.documentView = table + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + } + + /** + * Replaces the rows, colours and size mode, then reloads. + * + * The Size column title switches between "Size" and "Allocated" to match + * the mode. The selected extension, if still present, stays selected + * without notifying the delegate. + * + * @param {[ExtStat]} stats - Per-extension totals of the scan. + * @param {ExtColors?} colors - Colours for the swatches and bars; nil draws grey. + * @param {SizeMode} sizeMode - Which size to show and sort by. + * + * @example + * exts.set(stats: result.extStats, colors: colors, sizeMode: .allocated) + */ + func set(stats: [ExtStat], colors: ExtColors?, sizeMode: SizeMode) { + self.stats = stats + self.colors = colors + self.sizeMode = sizeMode + total = stats.reduce(0) { $0 + $1.metric(sizeMode) } + table.tableColumn(withIdentifier: .colSize)?.title = sizeMode == .allocated ? L.colAllocated : L.colSize + let selected = selectedExt + sort() + suppressSelection = true + table.reloadData() + if let selected, let row = self.stats.firstIndex(where: { $0.id == selected }) { + table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + suppressSelection = false + } + + /** Extension id of the selected row, or nil. */ + var selectedExt: UInt16? { + let row = table.selectedRow + return row >= 0 && row < stats.count ? stats[row].id : nil + } + + /** + * Sorts `stats` by the current sort key and direction. + * + * The Extension column sorts by display name, Files by count, and every + * other column (%, Size) by the current size mode. + * + * @example + * sortKey = NSUserInterfaceItemIdentifier.colCount.rawValue + * sort() + */ + private func sort() { + let mode = sizeMode + let asc = ascending + switch NSUserInterfaceItemIdentifier(sortKey) { + case .colExt: + stats.sort { asc ? $0.displayName < $1.displayName : $0.displayName > $1.displayName } + case .colCount: + stats.sort { asc ? $0.count < $1.count : $0.count > $1.count } + default: + stats.sort { asc ? $0.metric(mode) < $1.metric(mode) : $0.metric(mode) > $1.metric(mode) } + } + } + + /** + * Returns the number of file types. + * + * @param {NSTableView} tableView - The file-type table. + * @returns {Int} Row count. + * + * @example + * table.reloadData() // AppKit then asks numberOfRows(in:) + */ + func numberOfRows(in tableView: NSTableView) -> Int { stats.count } + + /** + * Builds the cell for one row and column. + * + * Extension shows a colour swatch and name, % a bar in the extension's + * colour relative to all files, Size the total in the current mode, and + * Files the file count. + * + * @param {NSTableView} tableView - The file-type table. + * @param {NSTableColumn?} tableColumn - The column being drawn. + * @param {Int} row - Row index into `stats`. + * @returns {NSView?} The configured cell, or nil for an unknown column or row. + * + * @example + * // Called by NSTableView for each visible cell after reloadData(). + */ + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let id = tableColumn?.identifier, row < stats.count else { return nil } + let s = stats[row] + switch id { + case .colExt: + let cell = tableView.cell(id) { SwatchCellView(identifier: id) } + cell.label.stringValue = s.displayName + cell.color = colors?.nsColor(s.id) ?? .gray + return cell + case .colPercent: + let cell = tableView.cell(id) { BarCellView(identifier: id) } + let f = total > 0 ? Double(s.metric(sizeMode)) / Double(total) : 0 + cell.fraction = f + cell.text = Fmt.percent(f) + cell.barColor = colors?.nsColor(s.id) ?? .gray + return cell + case .colSize: + let cell = tableView.cell(id) { TextCellView(identifier: id, alignment: .right, hasIcon: false) } + cell.set(Fmt.bytes(s.metric(sizeMode))) + return cell + default: + let cell = tableView.cell(id) { TextCellView(identifier: id, alignment: .right, hasIcon: false) } + cell.set(Fmt.count(s.count)) + return cell + } + } + + /** + * Re-sorts after a header click, keeping the selected extension selected. + * + * @param {NSTableView} tableView - The file-type table. + * @param {[NSSortDescriptor]} oldDescriptors - The previous sort descriptors (unused). + * + * @example + * // Called by NSTableView when the user clicks a column header. + */ + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let d = tableView.sortDescriptors.first, let key = d.key else { return } + sortKey = key + ascending = d.ascending + let selected = selectedExt + sort() + suppressSelection = true + tableView.reloadData() + if let selected, let row = stats.firstIndex(where: { $0.id == selected }) { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + suppressSelection = false + } + + /** + * Tells the delegate about user-made selection changes. + * + * @param {Notification} notification - The selection-change notification. + * + * @example + * // Called by NSTableView after the user selects a row. + */ + func tableViewSelectionDidChange(_ notification: Notification) { + guard !suppressSelection else { return } + delegate?.extensionList(didSelect: selectedExt) + } + + /** + * Forwards a double-click on a row to the delegate; clicks outside rows are ignored. + * + * @example + * table.doubleAction = #selector(doubleClicked) + */ + @objc private func doubleClicked() { + let row = table.clickedRow + guard row >= 0, row < stats.count else { return } + delegate?.extensionList(didActivate: stats[row].id) + } +} diff --git a/Sources/MacTree/UI/FileListController.swift b/Sources/MacTree/UI/FileListController.swift new file mode 100644 index 0000000..107afa2 --- /dev/null +++ b/Sources/MacTree/UI/FileListController.swift @@ -0,0 +1,620 @@ +import AppKit + +/** Table view that routes right-clicks and the space bar to its owner. */ +final class NodeTableView: NSTableView { + /** Receives context-menu and Quick Look requests. */ + weak var owner: NodeListOwner? + /** Maps a row index to the node shown in it. */ + var nodeProvider: ((Int) -> Node?)? + + /** Nodes in the selected rows. */ + var selectedNodes: [Node] { selectedRowIndexes.compactMap { nodeProvider?($0) } } + + /** + * Builds the context menu for a right-click. + * + * Right-clicking an unselected row selects it first, matching Finder, so + * the menu always acts on what is highlighted. Clicks outside any row get + * no menu. + * + * @param {NSEvent} event - The right-mouse-down event. + * @returns {NSMenu?} The owner's menu for the selected nodes, or nil. + * + * @example + * // Called by AppKit on right-click; the owner builds the actual menu. + */ + override func menu(for event: NSEvent) -> NSMenu? { + let row = self.row(at: convert(event.locationInWindow, from: nil)) + guard row >= 0 else { return nil } + if !selectedRowIndexes.contains(row) { + selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + return owner?.contextMenu(for: selectedNodes) + } + + /** + * Handles the list's own shortcuts; other keys keep their table behaviour. + * + * Space toggles Quick Look. Delete (⌫ or ⌦, without modifiers) marks or + * unmarks the selection for permanent deletion. + * + * @param {NSEvent} event - The key-down event. + * + * @example + * // Pressing Space in the File View opens the Quick Look panel. + */ + override func keyDown(with event: NSEvent) { + if event.charactersIgnoringModifiers == " " { + owner?.toggleQuickLook() + } else if event.isPlainDeleteKey { + owner?.toggleDeletionMark() + } else { + super.keyDown(with: event) + } + } +} + +/** + * Name filter for the File View. + * + * Terms separated by `;` or `|` are OR-ed. A term with `*` / `?` / `[` is a + * wildcard on the file name, `*.ext` matches an extension exactly, and + * anything else is a case-insensitive substring. + */ +struct FileFilter { + /** One parsed search term. */ + private enum Term { + /** `*.ext`: compare extension ids, the fastest check. */ + case ext(UInt16) + /** Glob pattern as a NUL-terminated C string for `fnmatch`. */ + case wildcard([CChar]) + /** Lowercased ASCII bytes, matched with a byte scan. */ + case asciiSubstring([UInt8]) + /** Non-ASCII text, matched with Foundation's case- and diacritic-insensitive search. */ + case substring(String) + } + + /** The parsed terms; empty means "match everything". */ + private let terms: [Term] + + /** Whether the filter has no terms and therefore lets every file through. */ + var isEmpty: Bool { terms.isEmpty } + + /** + * Parses search text into terms. + * + * Each term is classified once so matching millions of files stays cheap: + * `*.ext` (no other wildcard or dot) becomes an extension-id check, other + * wildcards go to `fnmatch`, ASCII text gets a byte scan and non-ASCII + * text (e.g. Korean) uses Foundation search. Blank terms are ignored. + * Interns unknown extensions into `ExtensionTable` as a side effect. + * + * @param {String} text - The search field's contents. + * + * @example + * let filter = FileFilter("*.mov; *.mp4 | cache") + */ + init(_ text: String) { + var terms: [Term] = [] + for raw in text.split(whereSeparator: { $0 == ";" || $0 == "|" }) { + let t = raw.trimmingCharacters(in: .whitespaces) + guard !t.isEmpty else { continue } + if t.hasPrefix("*."), t.count > 2, !t.dropFirst(2).contains(where: { "*?[.".contains($0) }) { + let ext = t.dropFirst(2).lowercased() + terms.append(.ext(ExtensionTable.shared.intern(ext))) + } else if t.contains(where: { "*?[".contains($0) }) { + terms.append(.wildcard(Array(t.utf8CString))) + } else if t.allSatisfy(\.isASCII) { + terms.append(.asciiSubstring(Array(t.lowercased().utf8))) + } else { + terms.append(.substring(t)) + } + } + self.terms = terms + } + + /** + * Tells whether a file matches any term. + * + * Only the file name is tested, never the path. Safe to call from + * several threads at once. + * + * @param {Node} node - The file to test. + * @returns {Bool} True if at least one term matches. + * + * @example + * FileFilter("*.mov").matches(movieNode) // true + */ + func matches(_ node: Node) -> Bool { + for term in terms { + switch term { + case .ext(let e): + if node.ext == e { return true } + case .wildcard(let pattern): + if node.name.withCString({ fnmatch(pattern, $0, FNM_CASEFOLD) == 0 }) { return true } + case .asciiSubstring(let needle): + if FileFilter.containsASCII(node.name, needle) { return true } + case .substring(let s): + if node.name.range(of: s, options: [.caseInsensitive, .diacriticInsensitive]) != nil { return true } + } + } + return false + } + + /** + * Case-insensitive ASCII substring search over a string's UTF-8 bytes. + * + * Folds A–Z to lowercase on the fly and compares against an already + * lowercased needle, avoiding allocation per file. Non-ASCII bytes in the + * haystack are compared as-is. + * + * @param {String} haystack - The file name to search. + * @param {[UInt8]} needle - Lowercased ASCII bytes to look for; must not be empty. + * @returns {Bool} True if the needle occurs in the haystack. + * + * @example + * FileFilter.containsASCII("DerivedData", Array("data".utf8)) // true + */ + private static func containsASCII(_ haystack: String, _ needle: [UInt8]) -> Bool { + var name = haystack + return name.withUTF8 { h in + let n = needle.count + guard n <= h.count else { return false } + let first = needle[0] + var i = 0 + let last = h.count - n + while i <= last { + var c = h[i] + if c >= 0x41 && c <= 0x5A { c |= 0x20 } + if c == first { + var j = 1 + while j < n { + var d = h[i + j] + if d >= 0x41 && d <= 0x5A { d |= 0x20 } + if d != needle[j] { break } + j += 1 + } + if j == n { return true } + } + i += 1 + } + return false + } + } + + /** + * Returns the files that match, keeping their order. + * + * Splits the input into chunks of 32,768 and filters them in parallel, + * then joins the chunks in order. An empty filter returns the input as is. + * + * @param {[Node]} nodes - Files in display order. + * @returns {[Node]} The matching files in the same order. + * + * @example + * let shown = FileFilter("*.dylib").apply(allFiles) + */ + func apply(_ nodes: [Node]) -> [Node] { + guard !isEmpty else { return nodes } + let chunk = 32_768 + let chunks = (nodes.count + chunk - 1) / chunk + guard chunks > 0 else { return [] } + var parts = [[Node]](repeating: [], count: chunks) + parts.withUnsafeMutableBufferPointer { out in + nodes.withUnsafeBufferPointer { src in + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let lo = c * chunk, hi = min(src.count, lo + chunk) + var local: [Node] = [] + for i in lo.. Void)? + /** Called when a background rebuild starts or ends. */ + var onBusyChanged: ((Bool) -> Void)? + + /** Files currently shown (sorted and filtered). */ + private(set) var rows: [Node] = [] + /** Every file under the root in the current sort order, before filtering. */ + private var all: [Node] = [] + /** The scan root whose files are listed. */ + private var root: Node? + /** Size used for the default size sort. */ + private var sizeMode: SizeMode = .allocated + /** The current search text. */ + private var filterText = "" + /** Column identifier of the current sort. */ + private var sortKey = NSUserInterfaceItemIdentifier.colAlloc.rawValue + /** Whether the current sort is ascending. */ + private var ascending = false + /** Incremented per request so late background results can be dropped. */ + private var generation = 0 + /** The file list is stale and must be rebuilt before it is shown. */ + private var needsRebuild = false + /** Serial background queue for collecting, sorting and filtering. */ + private let queue = DispatchQueue(label: "filelist", qos: .userInitiated) + /** Whether the File View tab is visible; becoming visible triggers a pending rebuild. */ + var isActive = false { didSet { if isActive && needsRebuild { rebuild() } } } + + /** + * Creates the table with its columns and scroll view. + * + * Columns: Name, Size, Allocated, Modified and Folder; widths and sort + * order are autosaved. Double-clicking a row asks the owner to Quick Look it. + * + * @example + * let files = FileListController() + * files.attach(owner: windowController) + */ + override init() { + super.init() + table.headerView = NSTableHeaderView() + table.usesAlternatingRowBackgroundColors = true + table.style = .fullWidth + table.rowHeight = 20 + table.intercellSpacing = NSSize(width: 6, height: 0) + table.allowsMultipleSelection = true + table.allowsColumnReordering = true + table.columnAutoresizingStyle = .noColumnAutoresizing + table.addColumn(.colName, title: L.colName, width: 280, minWidth: 100, ascendingFirst: true) + table.addColumn(.colSize, title: L.colSize, width: 80, alignment: .right) + table.addColumn(.colAlloc, title: L.colAllocated, width: 80, alignment: .right) + table.addColumn(.colModified, title: L.colModified, width: 150) + table.addColumn(.colPath, title: L.colPath, width: 480, minWidth: 100, ascendingFirst: true) + table.autosaveName = "FileTable" + table.autosaveTableColumns = true + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(doubleClicked) + table.nodeProvider = { [weak self] row in + guard let self, row >= 0, row < self.rows.count else { return nil } + return self.rows[row] + } + + scrollView.documentView = table + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + } + + /** + * Connects the list and its table to the object handling selection and menus. + * + * @param {NodeListOwner} owner - Usually the main window controller; held weakly. + * + * @example + * files.attach(owner: self) + */ + func attach(owner: NodeListOwner) { + self.owner = owner + table.owner = owner + } + + /** Files in the selected rows. */ + var selectedNodes: [Node] { table.selectedNodes } + /** Number of files under the root, before filtering. */ + var totalCount: Int { all.count } + /** Tells whether a file is marked for deletion; supplied by the window controller. */ + var isMarked: ((Node) -> Bool)? + + /** + * Updates the red deletion borders of the rows currently on screen. + * + * Rows scrolled in later get their state from `rowViewForRow`, so only + * the existing row views need touching; no reload is required. + * + * @example + * files.refreshMarks() // after the set of marked nodes changed + */ + func refreshMarks() { + table.enumerateAvailableRowViews { rowView, row in + guard let rowView = rowView as? MarkableRowView, row < rows.count else { return } + rowView.isMarked = isMarked?(rows[row]) ?? false + } + } + + /** + * Shows the files of a new tree, or clears the list. + * + * Clears the current rows at once and marks the list stale; the rebuild + * happens now if the tab is visible, otherwise when it is next shown. If + * the list is sorted by size, the sort follows the new size mode. + * + * @param {Node?} node - The new scan root, or nil to clear. + * @param {SizeMode} sizeMode - The size used for the default sort. + * + * @example + * files.setRoot(result.root, sizeMode: .allocated) + */ + func setRoot(_ node: Node?, sizeMode: SizeMode) { + root = node + self.sizeMode = sizeMode + if sortKey == NSUserInterfaceItemIdentifier.colAlloc.rawValue || sortKey == NSUserInterfaceItemIdentifier.colSize.rawValue { + sortKey = sizeMode == .allocated ? NSUserInterfaceItemIdentifier.colAlloc.rawValue + : NSUserInterfaceItemIdentifier.colSize.rawValue + } + generation += 1 + all = [] + rows = [] + table.reloadData() + onCountsChanged?(0, 0) + invalidate() + } + + /** + * Marks the list stale after the tree changed. + * + * Rebuilds right away if the tab is visible, otherwise when it is next shown. + * + * @example + * files.invalidate() // after moving files to the Trash + */ + func invalidate() { + needsRebuild = true + if isActive { rebuild() } + } + + /** + * Applies new search text. + * + * Filters the already sorted file list on the background queue; results + * from an older request are dropped. If the list is stale it is rebuilt + * (with the new filter) instead, or later when the tab becomes visible. + * Unchanged text does nothing. + * + * @param {String} text - The search field's contents; empty shows every file. + * + * @example + * files.setFilter("*.mov") + */ + func setFilter(_ text: String) { + guard text != filterText else { return } + filterText = text + if needsRebuild { if isActive { rebuild() }; return } + generation += 1 + let gen = generation, all = self.all, filter = FileFilter(text) + onBusyChanged?(true) + queue.async { [weak self] in + let matched = filter.apply(all) + DispatchQueue.main.async { + guard let self, gen == self.generation else { return } + self.show(matched, total: all.count) + } + } + } + + /** Set while selecting programmatically so the owner is not notified back. */ + private var suppressSelection = false + + /** + * Selects a file if it is in the current list, without notifying the owner. + * + * Used to mirror a treemap click. Searches the visible rows linearly and + * scrolls the match into view; clears the selection if the file is not + * listed (e.g. filtered out). + * + * @param {Node} node - The file to select. + * + * @example + * files.select(clickedNode) + */ + func select(_ node: Node) { + suppressSelection = true + defer { suppressSelection = false } + guard let row = rows.firstIndex(where: { $0 === node }) else { + table.deselectAll(nil) + return + } + table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + table.scrollRowToVisible(row) + } + + /** + * Collects, sorts and filters every file under the root in the background. + * + * The tree walk holds `treeLock` so main-thread mutations cannot change + * children arrays under it; sorting and filtering happen after the lock is + * released. The result is applied on the main thread only if no newer + * request was made in the meantime. + * + * @example + * rebuild() // via invalidate() or when the tab becomes visible + */ + private func rebuild() { + needsRebuild = false + generation += 1 + guard let root else { + all = [] + show([], total: 0) + return + } + let gen = generation + let key = sortKey, asc = ascending, mode = sizeMode, filter = FileFilter(filterText) + onBusyChanged?(true) + queue.async { [weak self] in + treeLock.lock() + var files: [Node] = [] + files.reserveCapacity(Int(root.fileCount)) + if root.isDir { + var stack: [Node] = [root] + while let n = stack.popLast() { + for c in n.children { + if c.isDir { stack.append(c) } else { files.append(c) } + } + } + } else { + files.append(root) + } + treeLock.unlock() + let cmp = NodeSorting.comparator(key: key, mode: mode) + files.sort { asc ? cmp($0, $1) : cmp($1, $0) } + let matched = filter.apply(files) + DispatchQueue.main.async { + guard let self, gen == self.generation else { return } + self.all = files + self.show(matched, total: files.count) + } + } + } + + /** + * Displays filtered rows and reports the counts. + * + * Ends the busy state and tells the owner how many files are shown out of + * how many exist. + * + * @param {[Node]} matched - Rows to display. + * @param {Int} total - Number of files before filtering. + * + * @example + * show(matched, total: all.count) + */ + private func show(_ matched: [Node], total: Int) { + rows = matched + table.reloadData() + onBusyChanged?(false) + onCountsChanged?(matched.count, total) + } + + // MARK: Table + + /** + * Reports the number of rows. + * + * @param {NSTableView} tableView - The file table. + * @returns {Int} Number of files currently shown. + * + * @example + * // Called by NSTableView after reloadData(). + */ + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + + /** + * Provides the cell view for one column of a row. + * + * The Name column shows the file-type icon; size columns are right-aligned + * and the Folder column is dimmed. Cell views are reused by identifier. + * + * @param {NSTableView} tableView - The file table. + * @param {NSTableColumn?} tableColumn - The column to fill. + * @param {Int} row - The row index into `rows`. + * @returns {NSView?} The configured cell, or nil for an invalid column or row. + * + * @example + * // Called by NSTableView for each visible cell while scrolling. + */ + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let id = tableColumn?.identifier, row < rows.count else { return nil } + let node = rows[row] + switch id { + case .colName: + let cell = tableView.cell(id) { TextCellView(identifier: id, alignment: .left, hasIcon: true) } + cell.set(node.name, icon: Icons.icon(for: node)) + return cell + default: + let cell = tableView.cell(id) { + TextCellView(identifier: id, alignment: (id == .colSize || id == .colAlloc) ? .right : .left, hasIcon: false) + } + cell.set(NodeSorting.text(for: node, column: id), dimmed: id == .colPath) + return cell + } + } + + /** + * Re-sorts after a column header click. + * + * Sorts the full list and re-applies the filter on the background queue, + * dropping the result if another request overtook it. + * + * @param {NSTableView} tableView - The file table. + * @param {[NSSortDescriptor]} oldDescriptors - The previous sort (unused). + * + * @example + * // Called by NSTableView when the user clicks the "Size" header. + */ + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let d = tableView.sortDescriptors.first, let key = d.key else { return } + sortKey = key + ascending = d.ascending + generation += 1 + let gen = generation, all = self.all, filter = FileFilter(filterText), mode = sizeMode + onBusyChanged?(true) + queue.async { [weak self] in + let cmp = NodeSorting.comparator(key: key, mode: mode) + let sorted = all.sorted { d.ascending ? cmp($0, $1) : cmp($1, $0) } + let matched = filter.apply(sorted) + DispatchQueue.main.async { + guard let self, gen == self.generation else { return } + self.all = sorted + self.show(matched, total: sorted.count) + } + } + } + + /** + * Provides the row view, which carries the deletion mark. + * + * @param {NSTableView} tableView - The file table. + * @param {Int} row - Row index into the filtered list. + * @returns {NSTableRowView?} A row view outlined in red when the file is marked for deletion. + * + * @example + * // Called by NSTableView before it fills a row's cells. + */ + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + let rowView = MarkableRowView() + if row < rows.count { rowView.isMarked = isMarked?(rows[row]) ?? false } + return rowView + } + + /** + * Forwards a user selection to the owner. + * + * Ignored while `select(_:)` changes the selection programmatically. + * + * @param {Notification} notification - The table's selection notification. + * + * @example + * // Called by NSTableView when the user clicks a row. + */ + func tableViewSelectionDidChange(_ notification: Notification) { + guard !suppressSelection else { return } + owner?.list(self, didSelect: table.selectedNodes) + } + + /** + * Opens Quick Look for a double-clicked file. + * + * Double-clicks on the header or empty space are ignored. + * + * @example + * // Sent by the table as its doubleAction. + */ + @objc private func doubleClicked() { + let row = table.clickedRow + guard row >= 0, row < rows.count else { return } + owner?.openOrQuickLook(rows[row]) + } +} diff --git a/Sources/MacTree/UI/MainWindowController+Actions.swift b/Sources/MacTree/UI/MainWindowController+Actions.swift new file mode 100644 index 0000000..3c5fc90 --- /dev/null +++ b/Sources/MacTree/UI/MainWindowController+Actions.swift @@ -0,0 +1,1346 @@ +import AppKit +import Quartz +import UniformTypeIdentifiers + +// MARK: - Scanning + +/** Scanning, tree updates, zoom, file actions and permissions for the main window. */ +extension MainWindowController { + /** + * Resets the summary, status bar and treemap header to the pre-scan state. + * + * Called once when the window is built, before any location is scanned. + * + * @example + * showIdle() + */ + func showIdle() { + summary.showIdle() + status.label.stringValue = L.ready + status.rightLabel.stringValue = "" + treemapHeader.pathLabel.stringValue = "" + updateZoomButtons() + } + + /** + * Toolbar Scan/Stop button: stops a running scan, otherwise scans the selected location. + * + * A folder rescan in progress also counts as running, so the button never + * starts a second scan on top of it. + * + * @param {Any?} sender - The toolbar button. + * + * @example + * scanButton.action = #selector(scanOrStop(_:)) + */ + @objc func scanOrStop(_ sender: Any?) { + if scanner != nil || subScanner != nil { + stopScan(nil) + } else { + startScan(selectedLocation) + } + } + + /** + * Scans the current scan root again from scratch (⌘R). + * + * Falls back to the selected location when nothing has been scanned yet. + * Ignored while a full scan is already running. + * + * @param {Any?} sender - The menu item or nil. + * + * @example + * rescanAll(nil) + */ + @objc func rescanAll(_ sender: Any?) { + guard scanner == nil else { return } + startScan(result.map { URL(fileURLWithPath: $0.rootPath) } ?? selectedLocation) + } + + /** + * Cancels the running full scan and any folder rescan (⌘.). + * + * A cancelled full scan still finishes with the partial tree gathered so + * far; a cancelled folder rescan is discarded. + * + * @param {Any?} sender - The menu item, button or nil. + * + * @example + * stopScan(nil) + */ + @objc func stopScan(_ sender: Any?) { + scanner?.cancel() + subScanner?.cancel() + } + + /** + * Starts a full scan of `url`, replacing whatever is shown. + * + * Cancels running scans, releases the current tree, adds the location to + * the picker if it is new, and shows live progress every 0.1 s. The scan + * runs on a background queue; its result is ignored if another scan was + * started meanwhile. If the size toggle flipped while scanning, the new + * tree is re-sorted before display, which is safe because no view holds it yet. + * + * @param {URL} url - Volume or folder to scan. + * + * @example + * startScan(URL(fileURLWithPath: "/Applications")) + */ + func startScan(_ url: URL) { + scanner?.cancel() + subScanner?.cancel() + subScanner = nil + releaseCurrentTree() + + let scanner = Scanner(path: url.path, sizeMode: sizeMode) + self.scanner = scanner + selectedLocation = url + let known = locationPopup.itemArray.contains { + ($0.representedObject as? URL)?.standardizedFileURL.path == url.standardizedFileURL.path + } + if !known { customLocations.insert(url, at: 0) } + rebuildLocationMenu() + let title = VolumeInfo.info(for: url).flatMap { v in v.url.path == scanner.rootPath ? v.name : nil } + ?? FileManager.default.displayName(atPath: scanner.rootPath) + summary.showVolume(title: title, path: scanner.rootPath, volume: VolumeInfo.info(for: url), scanned: nil) + summary.showProgress(scanner.progress) + treemap.placeholder = "\(L.scanning)…" + setScanButton(scanning: true) + window?.title = "\(L.appName) — \(title)" + + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self, weak scanner] _ in + guard let self, let scanner else { return } + let p = scanner.progress + self.summary.showProgress(p) + self.status.label.stringValue = p.currentPath + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let r = scanner.run() + DispatchQueue.main.async { + guard let self, self.scanner === scanner else { return } + if scanner.sizeMode != self.sizeMode { Scanner.sortAll(root: r.root, by: self.sizeMode) } + self.finishScan(r, title: title) + } + } + } + + /** + * Detaches every view from the current tree and frees it off the main thread. + * + * A full-disk tree holds millions of nodes, and node deinit recurses once + * per directory level, so the last reference is dropped on a utility + * thread with a 16 MB stack instead of stalling (or overflowing) the main thread. + * + * @example + * releaseCurrentTree() + */ + private func releaseCurrentTree() { + treemap.invalidate() + treemap.root = nil + treemap.selected = nil + tree.setRoot(nil, sizeMode: sizeMode) + files.setRoot(nil, sizeMode: sizeMode) + exts.set(stats: [], colors: nil, sizeMode: sizeMode) + selection = [] + clearMarks() + if result != nil { + var old: ScanResult? = result + result = nil + let t = Thread { if old != nil { old = nil } } + t.stackSize = 16 << 20 + t.qualityOfService = .utility + t.start() + } + } + + /** + * Shows a finished (or stopped) scan in every pane. + * + * Builds the extension colours, loads the tree, file list, file types and + * treemap, selects the root and reports totals, timing and any unreadable + * folders in the summary bar. + * + * @param {ScanResult} r - The scan result. + * @param {String} title - Display name of the scanned location. + * + * @example + * finishScan(scanner.run(), title: "Macintosh HD") + */ + private func finishScan(_ r: ScanResult, title: String) { + progressTimer?.invalidate() + progressTimer = nil + scanner = nil + setScanButton(scanning: false) + treemap.placeholder = L.ready + result = r + + colors = ExtColors(stats: r.extStats, mode: sizeMode) + tree.setRoot(r.root, sizeMode: sizeMode) + files.setRoot(r.root, sizeMode: sizeMode) + exts.set(stats: r.extStats, colors: colors, sizeMode: sizeMode) + treemap.sizeMode = sizeMode + treemap.highlightExt = nil + treemap.colors = colors + zoom(to: r.root) + selection = [r.root] + treemap.selected = r.root + summary.showVolume(title: title, path: r.rootPath, volume: r.volume, scanned: r.root.metric(sizeMode)) + summary.showResult(r, needsAccess: needsFullDiskAccess(for: r)) + updateStatus(for: r.root) + window?.makeFirstResponder(files.isActive ? files.table as NSView : tree.outline) + } + + // MARK: Rescan a subfolder + + /** + * Rescans one folder and splices the fresh subtree into the current tree. + * + * Uses the traversal policy of the original scan so the same volumes and + * firmlink exclusions apply. Rescanning the root is a full rescan. Ignored + * while any scan runs. Progress goes to the status bar; a cancelled rescan + * changes nothing. + * + * @param {Any?} sender - A context-menu item carrying the folder, or nil for the selection. + * + * @example + * rescanFolder(nil) // rescans the first selected folder + */ + @objc func rescanFolder(_ sender: Any?) { + guard scanner == nil, subScanner == nil, !isDeleting, let result, + let node = nodes(from: sender).first(where: { $0.isDir }) else { return } + if node === result.root { + rescanAll(sender) + return + } + let sc = Scanner(path: node.path, sizeMode: sizeMode, policy: result.policy) + subScanner = sc + setScanButton(scanning: true) + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self, weak sc] _ in + guard let self, let sc else { return } + let p = sc.progress + self.status.label.stringValue = "\(L.scanning)… \(Fmt.count(p.files)) \(L.files) · \(p.currentPath)" + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let r = sc.run() + DispatchQueue.main.async { + guard let self, self.subScanner === sc else { return } + self.progressTimer?.invalidate() + self.progressTimer = nil + self.subScanner = nil + self.setScanButton(scanning: false) + guard !r.cancelled else { return } + if sc.sizeMode != self.sizeMode { Scanner.sortAll(root: r.root, by: self.sizeMode) } + self.replace(node, with: r.root) + } + } + } + + /** + * Swaps a subtree for a freshly scanned one and fixes up every ancestor. + * + * Runs under `treeLock` so a background treemap layout never sees a + * half-updated tree. Ancestors get the size and count differences and are + * re-sorted along the path; extension totals are recomputed from scratch. + * Finally the new folder is revealed and selected. + * + * @param {Node} old - The node currently in the tree. + * @param {Node} new - The rescanned subtree root; renamed to `old.name` and attached in its place. + * + * @example + * replace(folder, with: rescan.root) + */ + private func replace(_ old: Node, with new: Node) { + guard let result, let parent = old.parent, + let index = parent.children.firstIndex(where: { $0 === old }) else { return } + treemap.invalidate() + treeLock.lock() + new.name = old.name + new.parent = parent + parent.children[index] = new + let dSize = new.size - old.size, dAlloc = new.alloc - old.alloc + let dFiles = new.fileCount - old.fileCount, dDirs = new.dirCount - old.dirCount + var a: Node? = parent + while let p = a { + p.size += dSize + p.alloc += dAlloc + p.fileCount += dFiles + p.dirCount += dDirs + if new.mtime > p.mtime { p.mtime = new.mtime } + a = p.parent + } + parent.sortChildren(by: sizeMode) + var c: Node = parent + while let gp = c.parent { gp.sortChildren(by: sizeMode); c = gp } + treeLock.unlock() + + result.extStats = ExtStats.compute(root: result.root) + treeDidChange(zoomFallback: parent) + tree.reveal(new) + list(tree, didSelect: [new]) + } + + // MARK: Trash + + /** + * Asks for confirmation, then moves the chosen items to the Trash (⌘⌫). + * + * The scan root is never trashed, and items whose ancestor is also chosen + * are skipped because trashing the ancestor already covers them. The + * confirmation sheet shows the total size and up to six paths. Ignored + * while a scan runs. + * + * @param {Any?} sender - A context-menu item carrying the nodes, or nil for the selection. + * + * @example + * moveToTrash(nil) // trashes the current selection after confirmation + */ + @objc func moveToTrash(_ sender: Any?) { + guard scanner == nil, subScanner == nil, !isDeleting, let window else { return } + let picked = nodes(from: sender) + let targets = picked.filter { n in + n.parent != nil && !picked.contains { $0 !== n && n.isDescendant(of: $0) } + } + guard !targets.isEmpty else { return } + let total = targets.reduce(Int64(0)) { $0 + $1.metric(sizeMode) } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = L.trashConfirmTitle(targets.count) + var body = L.trashConfirmBody(Fmt.bytes(total)) + body += "\n\n" + targets.prefix(6).map { $0.path }.joined(separator: "\n") + if targets.count > 6 { body += "\n…" } + alert.informativeText = body + alert.addButton(withTitle: L.moveToTrash) + alert.addButton(withTitle: L.cancel) + alert.beginSheetModal(for: window) { [weak self] response in + guard let self, response == .alertFirstButtonReturn else { return } + self.performTrash(targets) + } + } + + /** + * Moves items to the Trash and removes the successful ones from the tree. + * + * Each item is trashed independently; failures (for example on volumes + * without a Trash) are collected and shown in one alert, up to eight lines. + * + * @param {[Node]} targets - Items to trash; none may contain another. + * + * @example + * performTrash([bigFolder, oldArchive]) + */ + private func performTrash(_ targets: [Node]) { + var removed: [Node] = [] + var errors: [String] = [] + for n in targets { + do { + try FileManager.default.trashItem(at: n.url, resultingItemURL: nil) + removed.append(n) + } catch { + errors.append("\(n.name): \(error.localizedDescription)") + } + } + if !removed.isEmpty { removeFromTree(removed) } + if !errors.isEmpty, let window { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = L.trashFailed + alert.informativeText = errors.prefix(8).joined(separator: "\n") + alert.beginSheetModal(for: window) + } + } + + /** + * Removes nodes from the tree in place and refreshes every pane. + * + * Runs under `treeLock`. Subtracts each removed subtree from its ancestors + * and re-sorts along the path. Per-extension totals are kept in step by + * subtracting the removed subtree's tallies instead of walking the whole + * tree again; extensions left with no files are dropped. Afterwards the + * parent of the last removed node is revealed and selected. + * + * @param {[Node]} removed - Nodes already deleted from disk; none may contain another. + * + * @example + * removeFromTree([trashedFolder]) + */ + func removeFromTree(_ removed: [Node]) { + guard let result else { return } + treemap.invalidate() + var fallback: Node = result.root + treeLock.lock() + for n in removed { + guard let parent = n.parent, let idx = parent.children.firstIndex(where: { $0 === n }) else { continue } + for s in ExtStats.compute(root: n) { + if let i = result.extStats.firstIndex(where: { $0.id == s.id }) { + result.extStats[i].size -= s.size + result.extStats[i].alloc -= s.alloc + result.extStats[i].count -= s.count + } + } + parent.children.remove(at: idx) + let dirs = n.isDir ? n.dirCount + 1 : 0 + var a: Node? = parent + while let p = a { + p.size -= n.size + p.alloc -= n.alloc + p.fileCount -= n.fileCount + p.dirCount -= dirs + a = p.parent + } + var c: Node = parent + while let gp = c.parent { gp.sortChildren(by: sizeMode); c = gp } + fallback = parent + } + treeLock.unlock() + result.extStats.removeAll { $0.count <= 0 } + treeDidChange(zoomFallback: fallback) + tree.reveal(fallback) + list(tree, didSelect: [fallback]) + } + + /** + * Refreshes every pane after the tree was changed in place. + * + * Reloads the file types and tree (keeping expansion and selection), marks + * the file list stale, re-renders the treemap, drops deletion marks on + * removed nodes and updates the scanned total and file/folder counts. If the treemap was zoomed into a folder that no longer exists, it + * moves to `zoomFallback`. + * + * @param {Node} zoomFallback - Folder to show if the treemap's folder was removed. + * + * @example + * treeDidChange(zoomFallback: parent) + */ + private func treeDidChange(zoomFallback: Node) { + guard let result else { return } + exts.set(stats: result.extStats, colors: colors, sizeMode: sizeMode) + tree.reloadPreservingState() + files.invalidate() + if let z = treemap.root, !isAttached(z) { + zoom(to: zoomFallback) + } + treemap.setNeedsRender() + pruneMarks() + summary.showVolume(title: summary.titleLabel.stringValue, path: result.rootPath, + volume: VolumeInfo.info(for: URL(fileURLWithPath: result.rootPath)), + scanned: result.root.metric(sizeMode)) + summary.showResult(result, needsAccess: needsFullDiskAccess(for: result)) + } + + /** + * Tells whether a node is still reachable from the scan root. + * + * A trashed or replaced node keeps its parent pointer, so this checks + * each parent really still lists the node among its children. + * + * @param {Node} node - The node to check. + * @returns {Bool} False if the node or one of its ancestors was detached. + * + * @example + * if !isAttached(zoomedFolder) { zoom(to: result.root) } + */ + func isAttached(_ node: Node) -> Bool { + guard let root = result?.root else { return false } + var c = node + while c !== root { + guard let p = c.parent, p.children.contains(where: { $0 === c }) else { return false } + c = p + } + return true + } + + // MARK: Size mode / view mode + + /** + * Switches between logical and allocated size (toolbar toggle or View menu). + * + * Persists the choice, re-sorts the whole tree under `treeLock`, rebuilds + * extension colours and refreshes every pane. During a scan only the + * setting changes; the finished tree is sorted to match when it arrives. + * + * @param {Any?} sender - A View-menu item (its tag is the mode) or the segmented control. + * + * @example + * sizeModeControl.action = #selector(sizeModeChanged(_:)) + */ + @objc func sizeModeChanged(_ sender: Any?) { + let mode: SizeMode + if let item = sender as? NSMenuItem { + mode = SizeMode(rawValue: item.tag) ?? .allocated + } else { + mode = SizeMode(rawValue: sizeModeControl.selectedSegment) ?? .allocated + } + sizeModeControl.selectedSegment = mode.rawValue + guard mode != sizeMode else { return } + sizeMode = mode + guard let result, scanner == nil else { return } + treemap.invalidate() + treeLock.lock() + Scanner.sortAll(root: result.root, by: mode) + treeLock.unlock() + colors = ExtColors(stats: result.extStats, mode: mode) + tree.setSizeMode(mode) + files.setRoot(result.root, sizeMode: mode) + exts.set(stats: result.extStats, colors: colors, sizeMode: mode) + treemap.sizeMode = mode + treemap.colors = colors + summary.showVolume(title: summary.titleLabel.stringValue, path: result.rootPath, volume: result.volume, + scanned: result.root.metric(mode)) + if let n = selection.first { updateStatus(for: n) } + } + + /** + * Switches between Tree View and File View (toolbar or ⌘1 / ⌘2). + * + * @param {Any?} sender - A View-menu item (tag 0 or 1) or the segmented control. + * + * @example + * viewControl.action = #selector(viewModeChanged(_:)) + */ + @objc func viewModeChanged(_ sender: Any?) { + let index: Int + if let item = sender as? NSMenuItem { index = item.tag } else { index = viewControl.selectedSegment } + showTab(index) + } + + /** + * Shows the tree (0) or file list (1) and focuses it. + * + * Activating the file list builds it on first use. The status bar's right + * side shows the file count only while the file list is visible. + * + * @param {Int} index - 0 for Tree View, 1 for File View. + * + * @example + * showTab(1) + */ + func showTab(_ index: Int) { + viewControl.selectedSegment = index + tabView.selectTabViewItem(at: index) + files.isActive = index == 1 + window?.makeFirstResponder(index == 0 ? tree.outline as NSView : files.table) + if index == 0 { + status.rightLabel.stringValue = "" + } else if files.totalCount > 0 { + updateFileCount(shown: files.rows.count, total: files.totalCount) + } + } + + /** + * Applies the search field's text to the File View. + * + * A non-empty search switches to File View while keeping keyboard focus + * in the search field so typing can continue. + * + * @param {Any?} sender - The search field or nil. + * + * @example + * searchField.stringValue = "*.mov" + * searchChanged(nil) + */ + @objc func searchChanged(_ sender: Any?) { + let text = searchField.stringValue + files.setFilter(text) + if !text.isEmpty && tabView.indexOfTabViewItem(tabView.selectedTabViewItem!) != 1 { + showTab(1) + window?.makeFirstResponder(searchField) + } + } + + /** + * Moves keyboard focus to the search field (⌘F). + * + * @param {Any?} sender - The menu item. + * + * @example + * focusSearch(nil) + */ + @objc func focusSearch(_ sender: Any?) { + window?.makeFirstResponder(searchField) + } + + /** + * Shows how many files the File View lists, when it is visible. + * + * @param {Int} shown - Files matching the current filter. + * @param {Int} total - All files in the scan. + * + * @example + * updateFileCount(shown: 4_771, total: 1_076_094) + */ + func updateFileCount(shown: Int, total: Int) { + guard files.isActive else { return } + status.rightLabel.stringValue = shown == total ? L.filesMatched(total) : L.filesShown(shown, total) + } + + // MARK: Treemap zoom + + /** + * Makes `node` the whole treemap, unzoomed. + * + * Files are ignored unless they are the scan root. If `node` is already + * the treemap's folder, only the continuous zoom is reset. + * + * @param {Node} node - Folder to show. + * + * @example + * zoom(to: result.root) + */ + func zoom(to node: Node) { + guard node.isDir || node.parent == nil else { return } + if treemap.root === node { treemap.resetZoom() } else { treemap.root = node } + updateTreemapHeader() + } + + /** + * Updates the header above the treemap. + * + * Shows the path of whatever fills the view (the treemap's focus, or its + * folder) and, when zoomed beyond ×1.05, the zoom factor with one decimal + * below ×10 and none from ×10 up. Also refreshes the zoom buttons. + * + * @example + * updateTreemapHeader() // "/Applications/Unity ×8.0" + */ + func updateTreemapHeader() { + var text = (treemap.focusNode ?? treemap.root)?.path ?? "" + if treemap.zoomScale > 1.05 { + text += treemap.zoomScale < 10 ? String(format: " ×%.1f", treemap.zoomScale) + : String(format: " ×%.0f", treemap.zoomScale) + } + treemapHeader.pathLabel.stringValue = text + updateZoomButtons() + } + + /** Whether the treemap shows less than the whole scan (another folder or zoomed in). */ + var treemapIsZoomed: Bool { + treemap.root != nil && (treemap.root !== result?.root || treemap.zoomScale > 1) + } + + /** + * Enables the treemap's Up and Whole-tree buttons only when they would do something. + * + * @example + * updateZoomButtons() + */ + func updateZoomButtons() { + treemapHeader.upButton.isEnabled = treemapIsZoomed + treemapHeader.homeButton.isEnabled = treemapIsZoomed + } + + /** + * Zooms the treemap out one step (Up button, ⌘↑, or scrolling out past the whole folder). + * + * When zoomed in, first returns to the whole folder. Otherwise moves up to + * the parent folder, starting framed on the previous folder so zooming + * out continues smoothly. Does nothing at the scan root. + * + * @param {Any?} sender - The button, menu item or nil. + * + * @example + * zoomOut(nil) + */ + @objc func zoomOut(_ sender: Any?) { + if treemap.zoomScale > 1 { + treemap.resetZoom() + } else if let z = treemap.root, let p = z.parent { + treemap.show(p, framing: z) + } + updateTreemapHeader() + } + + /** + * Shows the whole scan in the treemap, unzoomed (⌘0 or the grid button). + * + * @param {Any?} sender - The button, menu item or nil. + * + * @example + * zoomReset(nil) + */ + @objc func zoomReset(_ sender: Any?) { + guard let result else { return } + zoom(to: result.root) + } + + /** + * Context menu "Zoom Treemap Here": shows a folder on its own in the treemap. + * + * For a file, its folder is used. A folder inside the current treemap + * grows from its current rectangle; anything else is shown directly. + * + * @param {Any?} sender - A context-menu item carrying the node, or nil for the selection. + * + * @example + * zoomHere(nil) + */ + @objc func zoomHere(_ sender: Any?) { + guard let node = nodes(from: sender).first else { return } + let folder = node.isDir ? node : (node.parent ?? node) + if let root = treemap.root, folder.isDescendant(of: root), folder !== root { + treemap.showFolder(folder) + updateTreemapHeader() + } else { + zoom(to: folder) + } + } + + // MARK: File actions + + /** + * Returns the nodes an action applies to. + * + * Context-menu items carry their nodes as `representedObject`; menu-bar + * items and key equivalents act on the current selection. + * + * @param {Any?} sender - The item that triggered the action. + * @returns {[Node]} The context-menu payload, or the selection. + * + * @example + * let targets = nodes(from: sender) + */ + func nodes(from sender: Any?) -> [Node] { + if let item = sender as? NSMenuItem, let nodes = item.representedObject as? [Node] { return nodes } + return selection + } + + /** + * Opens the items with their default applications. + * + * @param {Any?} sender - A context-menu item carrying the nodes, or nil for the selection. + * + * @example + * openItems(nil) + */ + @objc func openItems(_ sender: Any?) { + for n in nodes(from: sender) { NSWorkspace.shared.open(n.url) } + } + + /** + * Selects the items in a Finder window (⇧⌘R). + * + * @param {Any?} sender - A context-menu item carrying the nodes, or nil for the selection. + * + * @example + * revealInFinder(nil) + */ + @objc func revealInFinder(_ sender: Any?) { + let urls = nodes(from: sender).map(\.url) + guard !urls.isEmpty else { return } + NSWorkspace.shared.activateFileViewerSelecting(urls) + } + + /** + * Edit › Copy (⌘C) when a list or the treemap has focus: copies the selected paths. + * + * Text fields handle ⌘C themselves earlier in the responder chain. + * + * @param {Any?} sender - The menu item. + * + * @example + * copy(nil) + */ + @objc func copy(_ sender: Any?) { + copyPath(sender) + } + + /** + * Puts the items' paths on the pasteboard, one per line (⌥⌘C). + * + * @param {Any?} sender - A context-menu item carrying the nodes, or nil for the selection. + * + * @example + * copyPath(nil) + */ + @objc func copyPath(_ sender: Any?) { + let paths = nodes(from: sender).map(\.path) + guard !paths.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) + } + + /** + * Toggles the Quick Look panel (⌘Y). + * + * A context-menu item first makes its nodes the selection so the panel + * previews them. + * + * @param {Any?} sender - A context-menu item carrying the nodes, or nil for the selection. + * + * @example + * quickLook(nil) + */ + @objc func quickLook(_ sender: Any?) { + if let item = sender as? NSMenuItem, let nodes = item.representedObject as? [Node] { selection = nodes } + toggleQuickLook() + } + + /** + * Asks for a destination and exports the whole scan as CSV (⌘E). + * + * The export runs on a background queue with row counts shown in the + * status bar; failures are shown as an alert sheet. The default file name + * uses the volume or folder name. + * + * @param {Any?} sender - The menu item. + * + * @example + * exportCSV(nil) + */ + @objc func exportCSV(_ sender: Any?) { + guard let result, let window else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.commaSeparatedText] + let base = result.root.parent == nil && result.rootPath == "/" ? "Macintosh HD" : (result.rootPath as NSString).lastPathComponent + panel.nameFieldStringValue = "MacTree - \(base).csv" + panel.beginSheetModal(for: window) { [weak self] response in + guard let self, response == .OK, let url = panel.url else { return } + let root = result.root + self.status.label.stringValue = "\(L.exporting)…" + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let outcome = Result { + try CSVExporter.export(root: root, to: url) { rows in + DispatchQueue.main.async { + self?.status.label.stringValue = "\(L.exporting)… \(Fmt.count(rows))" + } + } + } + DispatchQueue.main.async { + guard let self else { return } + switch outcome { + case .success(let rows): + self.status.label.stringValue = L.exported(rows, url.path) + case .failure(let error): + let alert = NSAlert(error: error) + alert.messageText = L.exportFailed + if let window = self.window { alert.beginSheetModal(for: window) } + } + } + } + } + } + + // MARK: Permissions + + /** + * Tells whether a Full Disk Access grant would reveal folders this scan could not read. + * + * True only if some folder failed with a privacy error (EPERM) and this + * process does not have access; root-only folders need more than that. + * + * @param {ScanResult} r - The scan to check. + * @returns {Bool} True if asking for Full Disk Access would help. + * + * @example + * summary.showResult(r, needsAccess: needsFullDiskAccess(for: r)) + */ + func needsFullDiskAccess(for r: ScanResult) -> Bool { + r.denied.contains { $0.isPrivacyProtected } && !FullDiskAccess.isGranted + } + + /** + * Shows the Full Disk Access sheet at launch until access is granted or the user opts out. + * + * @example + * DispatchQueue.main.async { wc.requestFullDiskAccessIfNeeded() } + */ + func requestFullDiskAccessIfNeeded() { + guard !FullDiskAccess.isGranted, !FullDiskAccess.promptSuppressed else { return } + presentFullDiskAccessSheet() + } + + /** + * App menu "Full Disk Access…": opens the permission sheet on demand. + * + * @param {Any?} sender - The menu item. + * + * @example + * showFullDiskAccess(nil) + */ + @objc func showFullDiskAccess(_ sender: Any?) { + presentFullDiskAccessSheet() + } + + /** + * Presents the Full Disk Access sheet on the main window. + * + * Does nothing while another sheet is attached. When the sheet reports a + * grant and the last scan skipped folders, the scan is repeated so those + * folders are picked up. + * + * @example + * presentFullDiskAccessSheet() + */ + func presentFullDiskAccessSheet() { + guard let window, window.attachedSheet == nil else { return } + let sheet = FullDiskAccessSheet(relaunchPath: result?.rootPath) + activeSheet = sheet + sheet.onFinish = { [weak self] granted in + guard let self else { return } + self.activeSheet = nil + if granted, let r = self.result, r.deniedCount > 0, self.scanner == nil, self.subScanner == nil { + self.rescanAll(nil) + } + } + sheet.begin(on: window) + } + + /** + * Handles a click on the summary bar's unreadable-folders note. + * + * Asks for Full Disk Access if that would help; otherwise lists the + * protected system folders that were skipped. + * + * @example + * summary.onWarningClicked = { [weak self] in self?.showUnreadableFolders() } + */ + func showUnreadableFolders() { + guard let result, let window, window.attachedSheet == nil else { return } + if needsFullDiskAccess(for: result) { + presentFullDiskAccessSheet() + return + } + let sheet = DeniedFoldersSheet(folders: result.denied, totalCount: result.deniedCount) + activeSheet = sheet + sheet.onClose = { [weak self] in self?.activeSheet = nil } + sheet.begin(on: window) + } + + /** + * Shows a node's path, sizes and counts (folders) or date (files) in the status bar. + * + * @param {Node?} node - The node to describe; nil clears the status line. + * + * @example + * updateStatus(for: treemap.hovered ?? selection.first) + */ + func updateStatus(for node: Node?) { + guard let node else { + status.label.stringValue = "" + return + } + var parts = [node.path, "\(L.colSize) \(Fmt.bytes(node.size))", "\(L.colAllocated) \(Fmt.bytes(node.alloc))"] + if node.isDir { + parts.append("\(Fmt.count(Int(node.fileCount))) \(L.files), \(Fmt.count(Int(node.dirCount))) \(L.folders)") + } else if let d = node.modificationDate { + parts.append(Fmt.date(d)) + } + status.label.stringValue = parts.joined(separator: " · ") + } +} + +// MARK: - Menu validation + +/** Enables menu items only when their action can run. */ +extension MainWindowController: NSMenuItemValidation { + /** + * Enables or disables a menu item and sets its check mark. + * + * Item actions need nodes to act on and most are blocked during scans and + * permanent deletions. + * Move to Trash is disabled while a text field is being edited, so ⌘⌫ in + * the search field stays a text edit instead of trashing the selection. + * The size-mode and view-mode items get a check mark for the current choice. + * + * @param {NSMenuItem} menuItem - The item AppKit is about to show. + * @returns {Bool} Whether the item is enabled. + * + * @example + * // AppKit calls this before showing each menu: + * let enabled = validateMenuItem(trashItem) + */ + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + let hasNodes = !nodes(from: menuItem).isEmpty + let busy = scanner != nil || subScanner != nil || isDeleting + switch menuItem.action { + case #selector(openItems(_:)), #selector(revealInFinder(_:)), #selector(copyPath(_:)), + #selector(copy(_:)), #selector(quickLook(_:)): + return hasNodes + case #selector(moveToTrash(_:)): + if menuItem.representedObject == nil, window?.firstResponder is NSText { return false } + return hasNodes && !busy && nodes(from: menuItem).contains { $0.parent != nil } + case #selector(rescanFolder(_:)): + return !busy && nodes(from: menuItem).contains { $0.isDir } + case #selector(rescanAll(_:)), #selector(exportCSV(_:)): + return !busy && result != nil + case #selector(stopScan(_:)): + return busy + case #selector(zoomOut(_:)), #selector(zoomReset(_:)): + return treemapIsZoomed + case #selector(zoomHere(_:)): + return hasNodes + case #selector(toggleDeletionMarkFromMenu(_:)): + return !isDeleting && nodes(from: menuItem).contains { $0.parent != nil } + case #selector(deleteMarkedPermanently(_:)): + return !busy && !deletionTargets.isEmpty + case #selector(sizeModeChanged(_:)): + menuItem.state = menuItem.tag == sizeMode.rawValue ? .on : .off + return scanner == nil + case #selector(viewModeChanged(_:)): + menuItem.state = menuItem.tag == viewControl.selectedSegment ? .on : .off + return true + default: + return true + } + } +} + +// MARK: - Selection sync + +/** Keeps the tree, file list, file types and treemap in step with each other. */ +extension MainWindowController: NodeListOwner, TreemapViewDelegate, ExtensionListDelegate { + /** + * A list's selection changed: highlight it in the treemap and status bar. + * + * If the first node lies outside the treemap's folder, the treemap goes + * back to the whole scan; when zoomed in, it pans to show the node. Also + * refreshes an open Quick Look panel. + * + * @param {AnyObject} source - The tree or file-list controller. + * @param {[Node]} nodes - The newly selected nodes. + * + * @example + * list(tree, didSelect: [folder]) + */ + func list(_ source: AnyObject, didSelect nodes: [Node]) { + selection = nodes + guard let first = nodes.first else { return } + if let root = treemap.root, !first.isDescendant(of: root), let r = result?.root { + zoom(to: r) + } + treemap.selected = first + treemap.reveal(first) + updateStatus(for: first) + refreshQuickLook() + } + + /** + * Builds the right-click menu shared by the tree, file list and treemap. + * + * Every item carries `nodes` so the action targets what was clicked. + * Zoom Here needs a single node, Rescan a single folder, and Show Files of + * This Type a single file. The deletion-mark item reads "Unmark" when all + * of the nodes are already marked. + * + * @param {[Node]} nodes - The clicked (or selected) nodes. + * @returns {NSMenu?} The menu, or nil when there is nothing to act on. + * + * @example + * let menu = contextMenu(for: outline.selectedNodes) + */ + func contextMenu(for nodes: [Node]) -> NSMenu? { + guard !nodes.isEmpty else { return nil } + let menu = NSMenu() + /** + * Appends an action item that targets `nodes`. + * + * @param {String} title - Localised item title. + * @param {Selector} action - Window-controller action to invoke. + * @param {String} symbol - SF Symbol name for the item's icon. + * + * @example + * add(L.copyPath, #selector(copyPath(_:)), symbol: "doc.on.clipboard") + */ + func add(_ title: String, _ action: Selector, symbol: String) { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.representedObject = nodes + item.target = self + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + menu.addItem(item) + } + add(L.open, #selector(openItems(_:)), symbol: "arrow.up.forward.app") + add(L.revealInFinder, #selector(revealInFinder(_:)), symbol: "folder") + add(L.quickLook, #selector(quickLook(_:)), symbol: "eye") + add(L.copyPath, #selector(copyPath(_:)), symbol: "doc.on.clipboard") + menu.addItem(.separator()) + if nodes.count == 1 { + add(L.zoomTreemap, #selector(zoomHere(_:)), symbol: "plus.magnifyingglass") + } + if nodes.count == 1, nodes[0].isDir { + add(L.rescanFolder, #selector(rescanFolder(_:)), symbol: "arrow.clockwise") + } + if nodes.count == 1, !nodes[0].isDir { + add(L.showFilesOfType, #selector(showFilesOfType(_:)), symbol: "line.3.horizontal.decrease.circle") + } + menu.addItem(.separator()) + let allMarked = nodes.allSatisfy { isMarked($0) } + add(allMarked ? L.unmarkForDeletion : L.markForDeletion, #selector(toggleDeletionMarkFromMenu(_:)), symbol: "xmark.bin") + add(L.moveToTrash, #selector(moveToTrash(_:)), symbol: "trash") + return menu + } + + /** + * Context menu "Show Files of This Type": lists every file with the clicked file's extension. + * + * Files without an extension are ignored. + * + * @param {Any?} sender - A context-menu item carrying the file. + * + * @example + * showFilesOfType(menuItem) + */ + @objc func showFilesOfType(_ sender: Any?) { + guard let node = nodes(from: sender).first, !node.isDir, node.ext != 0 else { return } + extensionList(didActivate: node.ext) + } + + /** + * Double-clicked file in a list: previews it with Quick Look. + * + * Quick Look is used instead of opening the file so a double-click can + * never launch an application or installer by accident. + * + * @param {Node} node - The double-clicked file. + * + * @example + * openOrQuickLook(file) + */ + func openOrQuickLook(_ node: Node) { + selection = [node] + toggleQuickLook() + } + + /** + * An item was clicked in the treemap: select it everywhere. + * + * Expands the tree to the item and, if the File View is showing, selects + * it there too. + * + * @param {TreemapView} view - The treemap. + * @param {Node} node - The clicked item. + * + * @example + * treemap(treemap, didSelect: file) + */ + func treemap(_ view: TreemapView, didSelect node: Node) { + selection = [node] + view.selected = node + tree.reveal(node) + if files.isActive { files.select(node) } + updateStatus(for: node) + refreshQuickLook() + } + + /** + * The pointer moved over a treemap item: describe it in the status bar. + * + * Leaving the treemap falls back to describing the selection. + * + * @param {TreemapView} view - The treemap. + * @param {Node?} node - The hovered item, or nil when none. + * + * @example + * treemap(treemap, didHover: nil) + */ + func treemap(_ view: TreemapView, didHover node: Node?) { + updateStatus(for: node ?? selection.first) + } + + /** + * A folder was double-clicked in the treemap: show it on its own. + * + * @param {TreemapView} view - The treemap. + * @param {Node} node - The folder to show. + * + * @example + * treemap(treemap, didZoomTo: folder) + */ + func treemap(_ view: TreemapView, didZoomTo node: Node) { + view.showFolder(node) + updateTreemapHeader() + } + + /** + * The user kept zooming out past the whole folder: go up a level. + * + * @param {TreemapView} view - The treemap. + * + * @example + * treemapDidRequestZoomOut(treemap) + */ + func treemapDidRequestZoomOut(_ view: TreemapView) { + zoomOut(nil) + } + + /** + * The treemap's zoom factor or focus folder changed: refresh the header. + * + * @param {TreemapView} view - The treemap. + * + * @example + * treemapViewportDidChange(treemap) + */ + func treemapViewportDidChange(_ view: TreemapView) { + updateTreemapHeader() + } + + /** + * Supplies the right-click menu for a treemap item. + * + * @param {TreemapView} view - The treemap. + * @param {Node} node - The item under the pointer. + * @returns {NSMenu?} The shared context menu for that item. + * + * @example + * let menu = treemap(treemap, menuFor: file) + */ + func treemap(_ view: TreemapView, menuFor node: Node) -> NSMenu? { + contextMenu(for: [node]) + } + + /** + * A file type was selected: highlight its files in the treemap. + * + * @param {UInt16?} ext - The extension id, or nil to clear the highlight. + * + * @example + * extensionList(didSelect: movID) + */ + func extensionList(didSelect ext: UInt16?) { + treemap.highlightExt = ext + } + + /** + * A file type was double-clicked: list its files in the File View. + * + * Fills the search field with `*.ext`. Files without an extension cannot + * be expressed as a pattern, so that row is ignored. + * + * @param {UInt16} ext - The extension id. + * + * @example + * extensionList(didActivate: movID) // File View filtered by "*.mov" + */ + func extensionList(didActivate ext: UInt16) { + let name = ExtensionTable.shared.name(ext) + guard !name.isEmpty else { return } + searchField.stringValue = "*." + name + searchChanged(nil) + showTab(1) + } +} + +// MARK: - Quick Look + +/** Drives the Quick Look panel from the current selection. */ +extension MainWindowController: QLPreviewPanelDataSource, QLPreviewPanelDelegate { + /** + * Shows or hides the Quick Look panel for the selection (Space). + * + * Does nothing when nothing is selected. + * + * @example + * toggleQuickLook() + */ + func toggleQuickLook() { + guard !selection.isEmpty else { return } + if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { + QLPreviewPanel.shared().orderOut(nil) + } else { + QLPreviewPanel.shared().makeKeyAndOrderFront(nil) + } + } + + /** + * Makes an open Quick Look panel preview the new selection. + * + * Leaves the panel closed if it is not showing. + * + * @example + * refreshQuickLook() + */ + func refreshQuickLook() { + guard QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible else { return } + QLPreviewPanel.shared().reloadData() + } + + /** + * Tells Quick Look this window controller can feed the panel. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * @returns {Bool} Always true. + * + * @example + * // Quick Look asks the responder chain when the panel opens: + * acceptsPreviewPanelControl(QLPreviewPanel.shared()) // true + */ + override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { true } + + /** + * Takes over the panel as its data source and delegate. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * + * @example + * // Called by Quick Look after acceptsPreviewPanelControl(_:) returns true. + * beginPreviewPanelControl(QLPreviewPanel.shared()) + */ + override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) { + panel.dataSource = self + panel.delegate = self + } + + /** + * Releases the panel when another controller takes it or it closes. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * + * @example + * // Called by Quick Look when control moves elsewhere. + * endPreviewPanelControl(QLPreviewPanel.shared()) + */ + override func endPreviewPanelControl(_ panel: QLPreviewPanel!) { + panel.dataSource = nil + panel.delegate = nil + } + + /** + * Reports how many items the panel can page through. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * @returns {Int} The number of selected nodes. + * + * @example + * numberOfPreviewItems(in: QLPreviewPanel.shared()) // selection.count + */ + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { selection.count } + + /** + * Supplies the file to preview at a position in the selection. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * @param {Int} index - Position in the selection. + * @returns {QLPreviewItem} The node's file URL. + * + * @example + * let item = previewPanel(QLPreviewPanel.shared(), previewItemAt: 0) + */ + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! { + selection[index].url as NSURL + } + + /** + * Forwards key presses from the panel to the visible list. + * + * Lets the arrow keys move the selection in the tree or file list while + * the panel is key, so the preview follows along. + * + * @param {QLPreviewPanel} panel - The shared preview panel. + * @param {NSEvent} event - The event the panel received. + * @returns {Bool} True if a key-down was forwarded; false for other events. + * + * @example + * // Called by Quick Look for events in the panel. + * previewPanel(QLPreviewPanel.shared(), handle: downArrowEvent) + */ + func previewPanel(_ panel: QLPreviewPanel!, handle event: NSEvent!) -> Bool { + guard event.type == .keyDown else { return false } + let target: NSView = files.isActive ? files.table : tree.outline + target.keyDown(with: event) + return true + } +} diff --git a/Sources/MacTree/UI/MainWindowController+Debug.swift b/Sources/MacTree/UI/MainWindowController+Debug.swift new file mode 100644 index 0000000..2f6566b --- /dev/null +++ b/Sources/MacTree/UI/MainWindowController+Debug.swift @@ -0,0 +1,145 @@ +import AppKit + +/** Command-line hooks used to exercise the UI headlessly (`--snapshot`). */ +extension MainWindowController { + /** + * Applies the debug flags that drive the UI after a scan finishes. + * + * Used together with `--snapshot` to put the window into a specific state + * before it is captured. Supported flags: `--size-mode logical|allocated`, + * `--select `, `--zoom `, `--debug-remove ` (runs the + * in-place tree update used after Move to Trash without touching the disk), + * `--highlight-ext `, `--search `, `--tab files`, + * `--debug-wheel in,in,out` with optional `--debug-wheel-at x,y` (2× zoom + * steps 0.3 s apart around a point, the treemap's centre by default), + * `--show-fda-sheet`, `--show-denied-sheet`, `--debug-mark ` + * (repeatable; toggles a deletion mark) and `--debug-delete-marked` + * (deletes the marked items from disk without asking — test folders only). + * + * @param {[String]} args - The process arguments. + * + * @example + * wc.applyDebugArguments(["MacTree", "--scan", "/Applications", "--tab", "files", "--search", "*.dylib"]) + */ + func applyDebugArguments(_ args: [String]) { + /** + * Returns the argument that follows a flag. + * + * @param {String} flag - The flag to look for, such as "--select". + * @returns {String?} The next argument, or nil if the flag is absent or last. + * + * @example + * let path = value("--select") + */ + func value(_ flag: String) -> String? { + args.firstIndex(of: flag).flatMap { $0 + 1 < args.count ? args[$0 + 1] : nil } + } + if let mode = value("--size-mode") { + sizeModeControl.selectedSegment = mode == "logical" ? 0 : 1 + sizeModeChanged(sizeModeControl) + } + if let path = value("--select"), let node = findNode(path) { + treemap(treemap, didSelect: node) + } + if let path = value("--zoom"), let node = findNode(path) { + zoom(to: node) + } + if let path = value("--debug-remove"), let node = findNode(path) { + removeFromTree([node]) + } + if let ext = value("--highlight-ext") { + let id = ExtensionTable.shared.intern(ext) + treemap.highlightExt = id + } + if let text = value("--search") { + searchField.stringValue = text + searchChanged(nil) + } + if value("--tab") == "files" { showTab(1) } + if let steps = value("--debug-wheel") { + let at = value("--debug-wheel-at")?.split(separator: ",").compactMap { Double($0) } + let center = at?.count == 2 ? NSPoint(x: at![0], y: at![1]) + : NSPoint(x: treemap.bounds.midX, y: treemap.bounds.midY) + for (i, step) in steps.split(separator: ",").enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3 * Double(i + 1)) { [weak self] in + self?.treemap.zoom(by: step == "in" ? 2 : 0.5, at: center) + } + } + } + for (i, arg) in args.enumerated() where arg == "--debug-mark" && i + 1 < args.count { + if let node = findNode(args[i + 1]) { toggleDeletionMark(for: [node]) } + } + if args.contains("--debug-delete-marked") { + performPermanentDelete(deletionTargets) + } + if args.contains("--show-fda-sheet") { presentFullDiskAccessSheet() } + if args.contains("--show-denied-sheet"), let result, let window { + let sheet = DeniedFoldersSheet(folders: result.denied, totalCount: result.deniedCount) + activeSheet = sheet + sheet.begin(on: window) + } + } + + /** + * Resolves a path to a node in the current scan. + * + * Accepts an absolute path under the scan root or a path relative to it, + * matching one name per component. + * + * @param {String} path - Absolute or root-relative path. + * @returns {Node?} The matching node, or nil if there is no scan or a component is missing. + * + * @example + * let xcode = findNode("/Applications/Xcode.app") + */ + func findNode(_ path: String) -> Node? { + guard let root = result?.root else { return nil } + var node: Node? = root + let rel = path.hasPrefix(root.path) ? String(path.dropFirst(root.path.count)) : path + for part in rel.split(separator: "/") { + node = node?.children.first { $0.name == part } + } + return node + } + + /** + * Writes the current window (or its sheet) to a PNG file. + * + * Three capture modes: `--capture-sheet` renders only the attached sheet + * via `cacheDisplay`; `--capture-window` asks the window server for this + * window alone through `screencapture -l`, which includes the toolbar + * (`cacheDisplay` cannot draw the toolbar's glass items) but needs the + * Screen Recording permission; otherwise the window's frame view is + * rendered with `cacheDisplay`. Failures are silent. + * + * @param {String} path - Destination PNG path. + * + * @example + * wc.saveSnapshot(to: "/tmp/mactree.png") + */ + func saveSnapshot(to path: String) { + if CommandLine.arguments.contains("--capture-sheet"), let sheet = window?.attachedSheet, + let view = sheet.contentView?.superview { + view.layoutSubtreeIfNeeded() + if let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) { + view.cacheDisplay(in: view.bounds, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) + } + return + } + if CommandLine.arguments.contains("--capture-window"), let window { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + p.arguments = ["-x", "-o", "-l", String(window.windowNumber), path] + try? p.run() + p.waitUntilExit() + return + } + guard let frameView = window?.contentView?.superview else { return } + frameView.layoutSubtreeIfNeeded() + let rect = frameView.bounds + guard let rep = frameView.bitmapImageRepForCachingDisplay(in: rect) else { return } + frameView.cacheDisplay(in: rect, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) + } +} diff --git a/Sources/MacTree/UI/MainWindowController+Deletion.swift b/Sources/MacTree/UI/MainWindowController+Deletion.swift new file mode 100644 index 0000000..e131798 --- /dev/null +++ b/Sources/MacTree/UI/MainWindowController+Deletion.swift @@ -0,0 +1,232 @@ +import AppKit + +/** + * Permanent deletion: Delete marks items (red borders in every pane), and the + * toolbar's Delete Permanently button removes them from disk after one + * confirmation. + */ +extension MainWindowController { + /** + * Marked items that would actually be deleted. + * + * Leaves out the scan root and any item inside another marked folder, + * since deleting the folder already covers it. + */ + var deletionTargets: [Node] { + let marked = markedForDeletion + return marked.filter { n in + n.parent != nil && !marked.contains { $0 !== n && n.isDescendant(of: $0) } + } + } + + /** + * Tells whether a node is marked for deletion. + * + * @param {Node} node - The node to check. + * @returns {Bool} True if the node itself is marked (its ancestors are not considered). + * + * @example + * rowView.isMarked = isMarked(node) + */ + func isMarked(_ node: Node) -> Bool { + markedIDs.contains(ObjectIdentifier(node)) + } + + /** + * Delete key in the tree or file list: toggles the mark on the selection. + * + * @example + * toggleDeletionMark() // after the user pressed Delete + */ + func toggleDeletionMark() { + toggleDeletionMark(for: selection) + } + + /** + * Delete key in the treemap: toggles the mark on the clicked item. + * + * @param {TreemapView} view - The treemap that received the key. + * + * @example + * treemapDidRequestToggleMark(treemap) + */ + func treemapDidRequestToggleMark(_ view: TreemapView) { + toggleDeletionMark(for: selection) + } + + /** + * Context menu "Mark for Deletion" / "Unmark for Deletion". + * + * @param {Any?} sender - A context-menu item carrying the nodes. + * + * @example + * toggleDeletionMarkFromMenu(menuItem) + */ + @objc func toggleDeletionMarkFromMenu(_ sender: Any?) { + toggleDeletionMark(for: nodes(from: sender)) + } + + /** + * Marks nodes for deletion, or unmarks them if they are all marked already. + * + * The scan root can never be marked. While a deletion is running nothing + * changes and the system beeps. Afterwards every pane redraws its red + * borders and the status bar shows the marked count and total size. + * + * @param {[Node]} nodes - The nodes to toggle. + * + * @example + * toggleDeletionMark(for: [bigFolder]) + */ + func toggleDeletionMark(for nodes: [Node]) { + let candidates = nodes.filter { $0.parent != nil } + guard !candidates.isEmpty, !isDeleting else { + NSSound.beep() + return + } + if candidates.allSatisfy(isMarked) { + let ids = Set(candidates.map(ObjectIdentifier.init)) + markedForDeletion.removeAll { ids.contains(ObjectIdentifier($0)) } + } else { + markedForDeletion += candidates.filter { !isMarked($0) } + } + marksDidChange() + let targets = deletionTargets + if targets.isEmpty { + updateStatus(for: selection.first) + } else { + let total = targets.reduce(Int64(0)) { $0 + $1.metric(sizeMode) } + status.label.stringValue = L.markedSummary(targets.count, Fmt.bytes(total)) + } + } + + /** + * Pushes the current marks to every pane and the toolbar button. + * + * Rebuilds the lookup set, then redraws the treemap borders and the + * visible list rows and refreshes the Delete Permanently button. + * + * @example + * markedForDeletion.removeAll(); marksDidChange() + */ + func marksDidChange() { + markedIDs = Set(markedForDeletion.map(ObjectIdentifier.init)) + treemap.marked = markedForDeletion + tree.refreshMarks() + files.refreshMarks() + updateDeleteButton() + } + + /** + * Drops marks on nodes that are no longer in the tree. + * + * Needed after Move to Trash, a folder rescan or a deletion, which detach + * nodes (and everything under them) from the tree. + * + * @example + * pruneMarks() // from treeDidChange + */ + func pruneMarks() { + guard !markedForDeletion.isEmpty else { return } + markedForDeletion.removeAll { !isAttached($0) } + marksDidChange() + } + + /** + * Removes every mark, e.g. when a new scan replaces the tree. + * + * @example + * clearMarks() + */ + func clearMarks() { + markedForDeletion = [] + marksDidChange() + } + + /** + * Toolbar "Delete Permanently": confirms, then deletes the marked items. + * + * The confirmation lists the count, the total size and up to six paths, + * and says the deletion skips the Trash and cannot be undone. Cancel is + * the default button, so pressing Return never deletes anything. Ignored + * while a scan or another deletion runs. + * + * @param {Any?} sender - The toolbar button. + * + * @example + * deleteMarkedPermanently(nil) + */ + @objc func deleteMarkedPermanently(_ sender: Any?) { + let targets = deletionTargets + guard !targets.isEmpty, scanner == nil, subScanner == nil, !isDeleting, let window else { return } + let total = targets.reduce(Int64(0)) { $0 + $1.metric(sizeMode) } + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = L.deleteConfirmTitle(targets.count) + var body = L.deleteConfirmBody(Fmt.bytes(total)) + body += "\n\n" + targets.prefix(6).map { $0.path }.joined(separator: "\n") + if targets.count > 6 { body += "\n…" } + alert.informativeText = body + alert.addButton(withTitle: L.cancel) + let delete = alert.addButton(withTitle: L.deletePermanently) + delete.hasDestructiveAction = true + alert.beginSheetModal(for: window) { [weak self] response in + guard let self, response == .alertSecondButtonReturn else { return } + self.performPermanentDelete(targets) + } + } + + /** + * Deletes items from disk in the background, then updates the tree. + * + * Paths are captured on the main thread; `FileManager.removeItem` runs on + * a background queue because large folders take a while. Successfully + * deleted items are removed from the tree, which also drops their marks. + * Failures are listed in an alert, noting that a folder may have been + * partly deleted before the error. If a new scan replaced the tree in the + * meantime, only the alert is shown. + * + * @param {[Node]} targets - Items to delete; none may contain another. + * + * @example + * performPermanentDelete(deletionTargets) + */ + func performPermanentDelete(_ targets: [Node]) { + isDeleting = true + updateDeleteButton() + status.label.stringValue = L.deleting + let items = targets.map { ($0, $0.url) } + let resultAtStart = result + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + var deleted: [Node] = [] + var errors: [String] = [] + let fm = FileManager() + for (node, url) in items { + do { + try fm.removeItem(at: url) + deleted.append(node) + } catch { + errors.append("\(url.path): \(error.localizedDescription)") + } + } + DispatchQueue.main.async { + guard let self else { return } + self.isDeleting = false + if self.result === resultAtStart, !deleted.isEmpty { + self.removeFromTree(deleted) + } else { + self.pruneMarks() + self.updateDeleteButton() + } + if !errors.isEmpty, let window = self.window { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = L.deleteFailed + alert.informativeText = errors.prefix(8).joined(separator: "\n") + "\n\n" + L.deletePartialHint + alert.beginSheetModal(for: window) + } + } + } + } +} diff --git a/Sources/MacTree/UI/MainWindowController.swift b/Sources/MacTree/UI/MainWindowController.swift new file mode 100644 index 0000000..e1ecd6e --- /dev/null +++ b/Sources/MacTree/UI/MainWindowController.swift @@ -0,0 +1,600 @@ +import AppKit + +/** Identifiers of the main window's toolbar items. */ +private extension NSToolbarItem.Identifier { + /** Volume / folder picker. */ + static let location = NSToolbarItem.Identifier("location") + /** Scan / Stop button. */ + static let scan = NSToolbarItem.Identifier("scan") + /** Tree View / File View switch. */ + static let viewMode = NSToolbarItem.Identifier("viewMode") + /** Size / Allocated switch. */ + static let sizeMode = NSToolbarItem.Identifier("sizeMode") + /** Delete Permanently button, left of the search field. */ + static let deletePermanently = NSToolbarItem.Identifier("deletePermanently") + /** File search field. */ + static let search = NSToolbarItem.Identifier("search") +} + +/** + * Owns the single main window and coordinates every pane. + * + * Holds the current scan result and selection, runs scans, and keeps the + * tree, file list, extension list, treemap, summary and status bar in sync. + * Actions, selection sync and Quick Look live in extensions in other files. + */ +final class MainWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate, NSSplitViewDelegate { + /** Top strip with the location, space usage and scan statistics. */ + let summary = SummaryBar() + /** Bottom status line with the hovered or selected item. */ + let status = StatusBar() + /** The Tree View pane. */ + let tree = TreeController() + /** The File View pane. */ + let files = FileListController() + /** The per-extension totals pane. */ + let exts = ExtensionListController() + /** The cushion treemap. */ + let treemap = TreemapView() + /** Breadcrumb and zoom buttons above the treemap. */ + let treemapHeader = TreemapHeader() + /** Tabless container switching between the Tree View and the File View. */ + let tabView = NSTabView() + /** Splits the lists (top) from the treemap (bottom). */ + let mainSplit = NSSplitView() + /** Splits the tree / file list (left) from the extension list (right). */ + let topSplit = NSSplitView() + + /** Toolbar popup listing volumes, recent folders and "Choose Folder…". */ + let locationPopup = NSPopUpButton(frame: .zero, pullsDown: false) + /** Toolbar button that starts a scan or stops the running one. */ + let scanButton = NSButton() + /** Toolbar switch between the Tree View and the File View. */ + let viewControl = NSSegmentedControl() + /** Toolbar switch between logical and allocated size. */ + let sizeModeControl = NSSegmentedControl() + /** Toolbar button that permanently deletes the items marked with Delete; disabled while nothing is marked. */ + let deleteButton = NSButton() + /** Toolbar search field that filters the File View. */ + let searchField = NSSearchField() + + /** The finished scan being shown, if any. */ + var result: ScanResult? + /** The full scan in progress, if any. */ + var scanner: Scanner? + /** A "Rescan This Folder" scan in progress, if any. */ + var subScanner: Scanner? + /** Refreshes progress text while a scan runs. */ + var progressTimer: Timer? + /** Colour assignment per extension for the current result. */ + var colors: ExtColors? + /** Nodes that menu actions and Quick Look apply to. */ + var selection: [Node] = [] + /** The location the Scan button scans. */ + var selectedLocation: URL = URL(fileURLWithPath: "/") + /** Folders picked by the user this session, listed in the location popup. */ + var customLocations: [URL] = [] + /** Keeps the controller of an open sheet alive. */ + var activeSheet: NSWindowController? + /** Nodes marked for permanent deletion, in the order they were marked. */ + var markedForDeletion: [Node] = [] + /** Identities of `markedForDeletion`, for fast lookups while drawing rows. */ + var markedIDs: Set = [] + /** True while marked items are being deleted in the background. */ + var isDeleting = false + + /** + * Size used for percentages, ordering and the treemap; persisted in user defaults. + * + * Allocated size is the default because sparse images and iCloud placeholders + * would otherwise dominate the view. + */ + var sizeMode: SizeMode = (UserDefaults.standard.object(forKey: "sizeMode") as? Int).flatMap(SizeMode.init) ?? .allocated { + didSet { UserDefaults.standard.set(sizeMode.rawValue, forKey: "sizeMode") } + } + + /** + * Creates the main window with its toolbar and panes. + * + * Restores the autosaved window frame (centring the window on first launch), + * shows the idle state, and starts listening for volume mounts and unmounts + * so the location popup stays current. + * + * @example + * let wc = MainWindowController() + * wc.showWindow(nil) + */ + init() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1320, height: 880), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.title = L.appName + window.minSize = NSSize(width: 900, height: 600) + window.toolbarStyle = .unified + window.titleVisibility = .hidden + super.init(window: window) + window.delegate = self + buildToolbar() + buildContent() + rebuildLocationMenu() + window.setFrameAutosaveName("MainWindow") + if !window.setFrameUsingName("MainWindow") { window.center() } + showIdle() + + let nc = NSWorkspace.shared.notificationCenter + nc.addObserver(self, selector: #selector(volumesChanged), name: NSWorkspace.didMountNotification, object: nil) + nc.addObserver(self, selector: #selector(volumesChanged), name: NSWorkspace.didUnmountNotification, object: nil) + } + + /** + * Unsupported; the window is built in code. + * + * @param {NSCoder} coder - Unused. + * @returns {MainWindowController?} Never returns. + * + * @example + * // Not used: MainWindowController is never loaded from a nib. + */ + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + + /** + * Builds the window content: summary bar, split panes, treemap and status bar. + * + * Wires the panes to this controller, stacks the tree / file tabs next to the + * extension list, and puts the treemap with its header below. Split positions + * are autosaved; on first launch the lists get about half the height and the + * extension list about 380 points, after which the saved positions take over. + * + * @example + * buildContent() // called once from init() + */ + private func buildContent() { + guard let window else { return } + let content = NSView() + window.contentView = content + + tree.attach(owner: self) + files.attach(owner: self) + tree.isMarked = { [weak self] in self?.isMarked($0) ?? false } + files.isMarked = { [weak self] in self?.isMarked($0) ?? false } + exts.delegate = self + treemap.delegate = self + summary.onWarningClicked = { [weak self] in self?.showUnreadableFolders() } + files.onCountsChanged = { [weak self] shown, total in self?.updateFileCount(shown: shown, total: total) } + files.onBusyChanged = { [weak self] busy in + if busy { self?.status.rightLabel.stringValue = "…" } + } + + tabView.tabViewType = .noTabsNoBorder + let treeTab = NSTabViewItem(identifier: "tree") + treeTab.view = tree.scrollView + let fileTab = NSTabViewItem(identifier: "files") + fileTab.view = files.scrollView + tabView.addTabViewItem(treeTab) + tabView.addTabViewItem(fileTab) + + topSplit.isVertical = true + topSplit.dividerStyle = .thin + topSplit.delegate = self + topSplit.addArrangedSubview(tabView) + topSplit.addArrangedSubview(exts.scrollView) + topSplit.setHoldingPriority(.init(240), forSubviewAt: 0) + topSplit.setHoldingPriority(.init(260), forSubviewAt: 1) + topSplit.autosaveName = "TopSplit" + + let treemapPane = NSView() + for v in [treemapHeader, treemap] as [NSView] { + v.translatesAutoresizingMaskIntoConstraints = false + treemapPane.addSubview(v) + } + NSLayoutConstraint.activate([ + treemapHeader.topAnchor.constraint(equalTo: treemapPane.topAnchor), + treemapHeader.leadingAnchor.constraint(equalTo: treemapPane.leadingAnchor), + treemapHeader.trailingAnchor.constraint(equalTo: treemapPane.trailingAnchor), + treemap.topAnchor.constraint(equalTo: treemapHeader.bottomAnchor), + treemap.leadingAnchor.constraint(equalTo: treemapPane.leadingAnchor), + treemap.trailingAnchor.constraint(equalTo: treemapPane.trailingAnchor), + treemap.bottomAnchor.constraint(equalTo: treemapPane.bottomAnchor), + ]) + treemapHeader.upButton.target = self + treemapHeader.upButton.action = #selector(zoomOut(_:)) + treemapHeader.homeButton.target = self + treemapHeader.homeButton.action = #selector(zoomReset(_:)) + + mainSplit.isVertical = false + mainSplit.dividerStyle = .thin + mainSplit.delegate = self + mainSplit.addArrangedSubview(topSplit) + mainSplit.addArrangedSubview(treemapPane) + mainSplit.setHoldingPriority(.init(260), forSubviewAt: 0) + mainSplit.setHoldingPriority(.init(240), forSubviewAt: 1) + mainSplit.autosaveName = "MainSplit" + + for v in [summary, mainSplit, status] as [NSView] { + v.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(v) + } + NSLayoutConstraint.activate([ + summary.topAnchor.constraint(equalTo: content.safeAreaLayoutGuide.topAnchor), + summary.leadingAnchor.constraint(equalTo: content.leadingAnchor), + summary.trailingAnchor.constraint(equalTo: content.trailingAnchor), + mainSplit.topAnchor.constraint(equalTo: summary.bottomAnchor), + mainSplit.leadingAnchor.constraint(equalTo: content.leadingAnchor), + mainSplit.trailingAnchor.constraint(equalTo: content.trailingAnchor), + status.topAnchor.constraint(equalTo: mainSplit.bottomAnchor), + status.leadingAnchor.constraint(equalTo: content.leadingAnchor), + status.trailingAnchor.constraint(equalTo: content.trailingAnchor), + status.bottomAnchor.constraint(equalTo: content.bottomAnchor), + exts.scrollView.widthAnchor.constraint(greaterThanOrEqualToConstant: 220), + tabView.widthAnchor.constraint(greaterThanOrEqualToConstant: 400), + topSplit.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), + treemapPane.heightAnchor.constraint(greaterThanOrEqualToConstant: 120), + ]) + + let freshMain = UserDefaults.standard.object(forKey: "NSSplitView Subview Frames MainSplit") == nil + let freshTop = UserDefaults.standard.object(forKey: "NSSplitView Subview Frames TopSplit") == nil + content.layoutSubtreeIfNeeded() + if freshMain { mainSplit.setPosition(mainSplit.bounds.height * 0.52, ofDividerAt: 0) } + if freshTop { topSplit.setPosition(max(400, topSplit.bounds.width - 380), ofDividerAt: 0) } + } + + // MARK: - Toolbar + + /** + * Configures the toolbar controls and installs the toolbar. + * + * The toolbar is attached last because it measures its item views as soon as + * it is installed; attaching it earlier gives zero-sized items and AppKit + * layout warnings. + * + * @example + * buildToolbar() // called once from init() + */ + private func buildToolbar() { + locationPopup.target = self + locationPopup.action = #selector(locationChosen(_:)) + locationPopup.controlSize = .regular + + scanButton.bezelStyle = .toolbar + scanButton.setButtonType(.momentaryPushIn) + scanButton.imagePosition = .imageLeading + scanButton.target = self + scanButton.action = #selector(scanOrStop(_:)) + setScanButton(scanning: false) + + viewControl.segmentCount = 2 + viewControl.setLabel(L.treeView, forSegment: 0) + viewControl.setLabel(L.fileView, forSegment: 1) + viewControl.setImage(NSImage(systemSymbolName: "list.bullet.indent", accessibilityDescription: nil), forSegment: 0) + viewControl.setImage(NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil), forSegment: 1) + viewControl.trackingMode = .selectOne + viewControl.selectedSegment = 0 + viewControl.target = self + viewControl.action = #selector(viewModeChanged(_:)) + + sizeModeControl.segmentCount = 2 + sizeModeControl.setLabel(L.sizeModeLogical, forSegment: 0) + sizeModeControl.setLabel(L.sizeModeAllocated, forSegment: 1) + sizeModeControl.trackingMode = .selectOne + sizeModeControl.selectedSegment = sizeMode.rawValue + sizeModeControl.target = self + sizeModeControl.action = #selector(sizeModeChanged(_:)) + sizeModeControl.toolTip = L.t("Measure percentages and the treemap by logical size or by allocated (on-disk) size", + "비율과 트리맵을 논리 크기 또는 실제 디스크 할당 크기로 계산") + + deleteButton.bezelStyle = .toolbar + deleteButton.setButtonType(.momentaryPushIn) + deleteButton.imagePosition = .imageLeading + deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) + deleteButton.target = self + deleteButton.action = #selector(deleteMarkedPermanently(_:)) + updateDeleteButton() + + for control in [scanButton, viewControl, sizeModeControl] as [NSControl] { control.sizeToFit() } + + searchField.placeholderString = L.searchPlaceholder + searchField.sendsSearchStringImmediately = false + searchField.sendsWholeSearchString = false + searchField.target = self + searchField.action = #selector(searchChanged(_:)) + + let toolbar = NSToolbar(identifier: "MainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = false + window?.toolbar = toolbar + } + + /** + * Lists the toolbar items in display order. + * + * @param {NSToolbar} toolbar - The main window's toolbar. + * @returns {[NSToolbarItem.Identifier]} Location, Scan, view switch, size switch, Delete Permanently and search. + * + * @example + * let ids = toolbarDefaultItemIdentifiers(window.toolbar!) + */ + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.location, .scan, .space, .viewMode, .sizeMode, .flexibleSpace, .deletePermanently, .search] + } + + /** + * Lists the items the toolbar may contain; the same as the defaults since customisation is off. + * + * @param {NSToolbar} toolbar - The main window's toolbar. + * @returns {[NSToolbarItem.Identifier]} The default identifiers. + * + * @example + * let allowed = toolbarAllowedItemIdentifiers(window.toolbar!) + */ + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } + + /** + * Creates the toolbar item for an identifier, wrapping the matching control. + * + * The location popup gets a fixed 260-point width so its title never + * resizes the toolbar; the search item uses `NSSearchToolbarItem`. + * + * @param {NSToolbar} toolbar - The main window's toolbar. + * @param {NSToolbarItem.Identifier} id - The item to create. + * @param {Bool} flag - Whether the item is going into the toolbar (always true here). + * @returns {NSToolbarItem?} The item, or nil for identifiers this window does not use. + * + * @example + * let item = toolbar(window.toolbar!, itemForItemIdentifier: .scan, willBeInsertedIntoToolbar: true) + */ + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier id: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch id { + case .location: + let item = NSToolbarItem(itemIdentifier: id) + item.label = L.location + item.view = locationPopup + locationPopup.widthAnchor.constraint(equalToConstant: 260).isActive = true + return item + case .scan: + let item = NSToolbarItem(itemIdentifier: id) + item.label = L.scan + item.view = scanButton + return item + case .viewMode: + let item = NSToolbarItem(itemIdentifier: id) + item.label = L.treeView + item.view = viewControl + return item + case .sizeMode: + let item = NSToolbarItem(itemIdentifier: id) + item.label = L.sizeModeLabel + item.view = sizeModeControl + return item + case .deletePermanently: + let item = NSToolbarItem(itemIdentifier: id) + item.label = L.deletePermanently + item.view = deleteButton + return item + case .search: + let item = NSSearchToolbarItem(itemIdentifier: id) + item.searchField = searchField + item.preferredWidthForSearchField = 300 + return item + default: + return nil + } + } + + /** + * Switches the Scan button between its Scan and Stop appearance. + * + * Updates the title, symbol and tooltip, then resizes the button to fit. + * Also refreshes the Delete Permanently button, which is disabled during scans. + * + * @param {Bool} scanning - True while a full or folder scan is running. + * + * @example + * setScanButton(scanning: true) // shows "Stop" + */ + func setScanButton(scanning: Bool) { + scanButton.title = scanning ? L.stop : L.scan + scanButton.image = NSImage(systemSymbolName: scanning ? "stop.fill" : "play.fill", accessibilityDescription: nil) + scanButton.toolTip = scanning ? L.stop : L.scan + scanButton.sizeToFit() + updateDeleteButton() + } + + /** + * Refreshes the Delete Permanently button from the marked items. + * + * Enabled (and tinted red) only while something is marked and no scan or + * deletion is running. The title shows the number of marked items and + * the tooltip their total size, or how to mark items when there are none. + * + * @example + * updateDeleteButton() // after marking, deleting or starting a scan + */ + func updateDeleteButton() { + let targets = deletionTargets + let busy = scanner != nil || subScanner != nil || isDeleting + deleteButton.isEnabled = !targets.isEmpty && !busy + deleteButton.contentTintColor = deleteButton.isEnabled ? .systemRed : nil + deleteButton.title = targets.isEmpty ? L.deletePermanently : L.deletePermanentlyCount(targets.count) + let total = targets.reduce(Int64(0)) { $0 + $1.metric(sizeMode) } + deleteButton.toolTip = targets.isEmpty ? L.deleteHint : L.markedSummary(targets.count, Fmt.bytes(total)) + deleteButton.sizeToFit() + } + + // MARK: - Locations + + /** + * Refreshes the location popup when a volume is mounted or unmounted. + * + * @param {Notification} note - The workspace mount or unmount notification. + * + * @example + * // Posted by NSWorkspace when a USB drive is plugged in. + */ + @objc private func volumesChanged(_ note: Notification) { + rebuildLocationMenu() + } + + /** + * Rebuilds the location popup's menu. + * + * Lists mounted volumes with their free and total space, then the home folder + * and folders picked this session, then "Choose Folder…". Duplicate paths are + * listed once. Re-selects `selectedLocation` if it is in the menu. + * + * @example + * customLocations.insert(url, at: 0) + * rebuildLocationMenu() + */ + func rebuildLocationMenu() { + let menu = NSMenu() + var seen = Set() + /** + * Appends a menu item for a location unless its path is already listed. + * + * The item carries the URL as its represented object and shows the + * Finder icon of the location at 16 points. + * + * @param {URL} url - The volume or folder. + * @param {String} title - The menu item title. + * + * @example + * add(FileManager.default.homeDirectoryForCurrentUser, title: "~") + */ + func add(_ url: URL, title: String) { + guard seen.insert(url.standardizedFileURL.path).inserted else { return } + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.representedObject = url + let icon = NSWorkspace.shared.icon(forFile: url.path) + icon.size = NSSize(width: 16, height: 16) + item.image = icon + menu.addItem(item) + } + for v in VolumeInfo.mounted() { + add(v.url, title: "\(v.name) \(Fmt.bytes(v.available)) \(L.free) / \(Fmt.bytes(v.total))") + } + menu.addItem(.separator()) + add(FileManager.default.homeDirectoryForCurrentUser, title: FileManager.default.homeDirectoryForCurrentUser.path) + for url in customLocations { add(url, title: url.path) } + menu.addItem(.separator()) + let choose = NSMenuItem(title: L.chooseFolder, action: nil, keyEquivalent: "") + choose.representedObject = "choose" + menu.addItem(choose) + locationPopup.menu = menu + selectLocationItem(selectedLocation) + } + + /** + * Selects the popup item for a location, matching standardised paths. + * + * Leaves the current selection alone when the location is not listed. + * + * @param {URL} url - The location to show as selected. + * + * @example + * selectLocationItem(URL(fileURLWithPath: "/")) + */ + private func selectLocationItem(_ url: URL) { + let path = url.standardizedFileURL.path + if let item = locationPopup.itemArray.first(where: { ($0.representedObject as? URL)?.standardizedFileURL.path == path }) { + locationPopup.select(item) + } + } + + /** + * Handles a pick from the location popup. + * + * Choosing a location starts scanning it right away. "Choose Folder…" puts + * the popup back on the current location and opens the folder picker instead. + * + * @param {NSPopUpButton} sender - The location popup. + * + * @example + * // Sent by locationPopup when the user picks "Macintosh HD". + */ + @objc private func locationChosen(_ sender: NSPopUpButton) { + let item = sender.selectedItem + if item?.representedObject as? String == "choose" { + selectLocationItem(selectedLocation) + chooseFolder(nil) + return + } + guard let url = item?.representedObject as? URL else { return } + selectedLocation = url + startScan(url) + } + + /** + * Asks for a folder in a sheet, then scans it. + * + * The chosen folder is remembered in `customLocations` for the rest of the + * session so it stays in the location popup. Cancelling does nothing. + * + * @param {Any?} sender - The menu item or control that sent the action. + * + * @example + * chooseFolder(nil) // same as File › Scan Folder… (⌘O) + */ + @objc func chooseFolder(_ sender: Any?) { + guard let window else { return } + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = L.scan + panel.directoryURL = selectedLocation + panel.beginSheetModal(for: window) { [weak self] response in + guard let self, response == .OK, let url = panel.url else { return } + if !self.customLocations.contains(url) { self.customLocations.insert(url, at: 0) } + self.selectedLocation = url + self.rebuildLocationMenu() + self.startScan(url) + } + } + + // MARK: - Split view + + /** + * Sets the smallest allowed divider position. + * + * Keeps at least 160 points for the lists above the treemap, and at least + * 400 points for the tree / file list left of the extension list. + * + * @param {NSSplitView} splitView - The split view being dragged. + * @param {CGFloat} proposedMinimumPosition - AppKit's proposed minimum. + * @param {Int} dividerIndex - The divider being moved (always 0 here). + * @returns {CGFloat} The minimum divider position. + * + * @example + * // Called by NSSplitView while the user drags a divider. + */ + func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposedMinimumPosition: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + splitView === mainSplit ? max(proposedMinimumPosition, 160) : max(proposedMinimumPosition, 400) + } + + /** + * Sets the largest allowed divider position. + * + * Keeps at least 120 points for the treemap and at least 220 points for the + * extension list. + * + * @param {NSSplitView} splitView - The split view being dragged. + * @param {CGFloat} proposedMaximumPosition - AppKit's proposed maximum. + * @param {Int} dividerIndex - The divider being moved (always 0 here). + * @returns {CGFloat} The maximum divider position. + * + * @example + * // Called by NSSplitView while the user drags a divider. + */ + func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposedMaximumPosition: CGFloat, + ofSubviewAt dividerIndex: Int) -> CGFloat { + let extent = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height + return min(proposedMaximumPosition, extent - (splitView === mainSplit ? 120 : 220)) + } +} diff --git a/Sources/MacTree/UI/PermissionSheets.swift b/Sources/MacTree/UI/PermissionSheets.swift new file mode 100644 index 0000000..0dbbe4c --- /dev/null +++ b/Sources/MacTree/UI/PermissionSheets.swift @@ -0,0 +1,808 @@ +import AppKit + +/** + * The app's own icon, draggable into System Settings' Full Disk Access list. + * + * Dragging it hands the app bundle's file URL to System Settings, which is + * the easiest way to add an app that is not listed yet. + */ +final class DraggableAppIconView: NSView, NSDraggingSource { + /** Shows the application icon. */ + private let iconView = NSImageView() + /** "Drag into the list" hint under the icon. */ + private let caption = NSTextField(labelWithString: L.fdaDragHint) + + /** + * Builds the tile: a rounded, bordered box with the app icon and a caption. + * + * The tile sizes itself to 110 × 96 points with Auto Layout. The image + * view stops accepting drops so it cannot be mistaken for a drop target. + * + * @param {NSRect} frameRect - Initial frame; usually `.zero`, since constraints size the tile. + * + * @example + * let tile = DraggableAppIconView() + * stack.addArrangedSubview(tile) + */ + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 10 + layer?.borderWidth = 1 + toolTip = L.fdaDragHint + + iconView.image = NSApp.applicationIconImage + iconView.imageScaling = .scaleProportionallyUpOrDown + iconView.unregisterDraggedTypes() + caption.font = .systemFont(ofSize: 10.5) + caption.textColor = .secondaryLabelColor + caption.alignment = .center + for v in [iconView, caption] as [NSView] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: 110), + heightAnchor.constraint(equalToConstant: 96), + iconView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + iconView.centerXAnchor.constraint(equalTo: centerXAnchor), + iconView.widthAnchor.constraint(equalToConstant: 56), + iconView.heightAnchor.constraint(equalToConstant: 56), + caption.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 4), + caption.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + caption.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + ]) + } + + /** + * Not supported; the tile is only created in code. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called: the view is not used in nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Refreshes the layer colours from the current appearance. + * + * AppKit calls this when the view needs redisplay, including after a + * light/dark switch, so the background and border track the system colours. + * + * @example + * tile.needsDisplay = true // AppKit then calls updateLayer() + */ + override func updateLayer() { + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + layer?.borderColor = NSColor.separatorColor.cgColor + } + + /** Draws through `updateLayer()` instead of `draw(_:)`. */ + override var wantsUpdateLayer: Bool { true } + + /** + * Makes the whole tile the drag handle. + * + * Any point inside the bounds returns the tile itself, so the image view + * and caption never take the click that should start a drag. + * + * @param {NSPoint} point - Point in the superview's coordinate system. + * @returns {NSView?} This view when the point is inside it, otherwise nil. + * + * @example + * let hit = window.contentView?.hitTest(clickPoint) // the tile, not its image view + */ + override func hitTest(_ point: NSPoint) -> NSView? { + let local = superview.map { convert(point, from: $0) } ?? point + return bounds.contains(local) ? self : nil + } + + /** + * Shows an open-hand cursor over the tile to hint that it can be dragged. + * + * @example + * window.invalidateCursorRects(for: tile) // AppKit calls resetCursorRects() + */ + override func resetCursorRects() { + addCursorRect(bounds, cursor: .openHand) + } + + /** + * Starts dragging the app bundle. + * + * The pasteboard carries `Bundle.main.bundleURL`, which System Settings' + * Full Disk Access list accepts as a new entry. The drag image is the icon. + * + * @param {NSEvent} event - The mouse-down event that starts the drag. + * + * @example + * // Pressing on the tile and moving the mouse starts the drag. + */ + override func mouseDown(with event: NSEvent) { + let item = NSDraggingItem(pasteboardWriter: Bundle.main.bundleURL as NSURL) + item.setDraggingFrame(iconView.frame, contents: iconView.image) + beginDraggingSession(with: [item], event: event, source: self) + } + + /** + * Allows the drag only into other applications. + * + * Dropping inside MacTree itself would mean nothing, so no operations are + * offered there. + * + * @param {NSDraggingSession} session - The active drag. + * @param {NSDraggingContext} context - Whether the target is inside or outside this app. + * @returns {NSDragOperation} Copy/link/generic outside the app, none inside. + * + * @example + * // Called by AppKit while the user drags the tile over System Settings. + */ + func draggingSession(_ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + context == .outsideApplication ? [.copy, .link, .generic] : [] + } +} + +/** + * Numbered circle for the steps list. + * + * The digit is centred on its cap height, and the view reports that baseline + * so a stack view with first-baseline alignment can line it up with text. + */ +final class StepBadge: NSView { + /** The digit shown in the circle. */ + private let number: String + /** Font of the digit. */ + private let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .bold) + /** Circle diameter in points. */ + private let diameter: CGFloat = 18 + + /** + * Creates an 18-point badge for one step number. + * + * The badge refuses to stretch in either direction, so stack views keep + * it circular. + * + * @param {Int} number - The step number to show. + * + * @example + * let row = NSStackView(views: [StepBadge(number: 1), label]) + */ + init(number: Int) { + self.number = "\(number)" + super.init(frame: NSRect(x: 0, y: 0, width: 18, height: 18)) + translatesAutoresizingMaskIntoConstraints = false + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + } + + /** + * Not supported; badges are only created in code. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called: the view is not used in nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** A fixed square the size of the circle. */ + override var intrinsicContentSize: NSSize { NSSize(width: diameter, height: diameter) } + + /** Baseline of the digit, measured from the top edge. */ + override var firstBaselineOffsetFromTop: CGFloat { diameter / 2 + font.capHeight / 2 } + /** Baseline of the digit, measured from the bottom edge. */ + override var lastBaselineOffsetFromBottom: CGFloat { diameter / 2 - font.capHeight / 2 } + + /** + * Draws the accent-coloured circle and the white digit. + * + * `draw(at:)` positions the bottom of the text line, not its baseline, so + * the origin is lowered by the font's descender to put the baseline where + * the cap height is centred in the circle. + * + * @param {NSRect} dirtyRect - The area to redraw; the whole badge is always drawn. + * + * @example + * badge.needsDisplay = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + NSColor.controlAccentColor.setFill() + NSBezierPath(ovalIn: bounds).fill() + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: NSColor.white] + let s = number as NSString + let width = s.size(withAttributes: attrs).width + let baseline = bounds.midY - font.capHeight / 2 + s.draw(at: NSPoint(x: bounds.midX - width / 2, y: baseline + font.descender), withAttributes: attrs) + } +} + +/** + * Explains Full Disk Access, opens System Settings and waits for the grant. + * + * The sheet has three states: waiting, "granted but needs a relaunch", and + * granted. While waiting it polls once a second and whenever the app becomes + * active again. + */ +final class FullDiskAccessSheet: NSWindowController { + /** Called once when the sheet closes; `true` if access is now granted. */ + var onFinish: ((Bool) -> Void)? + + /** Spins while waiting for the grant. */ + private let statusSpinner = NSProgressIndicator() + /** Green check shown once the grant is detected. */ + private let statusIcon = NSImageView() + /** Current state in words. */ + private let statusLabel = NSTextField(labelWithString: L.fdaWaiting) + /** "Still waiting? Relaunch" hint with its button. */ + private let relaunchRow = NSStackView() + /** "Don't ask at launch" checkbox. */ + private let dontAsk = NSButton(checkboxWithTitle: L.fdaDontAsk, target: nil, action: nil) + /** Closes the sheet without the grant. */ + private let laterButton = NSButton(title: L.fdaLater, target: nil, action: nil) + /** Primary button: open settings, then relaunch or done depending on the state. */ + private let openButton = NSButton(title: L.fdaOpenSettings, target: nil, action: nil) + /** Polls for the grant while waiting. */ + private var timer: Timer? + /** Set once the sheet has closed, so late callbacks do nothing. */ + private var finished = false + /** Whether a fresh-process probe is still running. */ + private var probing = false + /** Folder to rescan after a relaunch, if any. */ + private let relaunchPath: String? + + /** + * Creates the sheet and lays out its content. + * + * Nothing is shown until `begin(on:)` attaches it to a window. + * + * @param {String?} relaunchPath - Folder to rescan if the user relaunches from here. + * + * @example + * let sheet = FullDiskAccessSheet(relaunchPath: result?.rootPath) + */ + init(relaunchPath: String?) { + self.relaunchPath = relaunchPath + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 540, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + super.init(window: window) + buildContent() + } + + /** + * Not supported; the sheet is only created in code. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called: the controller is not loaded from a nib. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Presents the sheet on `parent` and starts watching for the grant. + * + * If access is already granted, it shows the granted state straight away + * and waits for Done. Otherwise it polls every second and re-checks + * whenever the app becomes active. The caller must keep the controller + * alive until `onFinish` runs. + * + * @param {NSWindow} parent - Window to attach the sheet to. + * + * @example + * activeSheet = sheet + * sheet.begin(on: window) + */ + func begin(on parent: NSWindow) { + guard let window else { return } + parent.beginSheet(window) + if FullDiskAccess.isGranted { + showGranted(autoClose: false) + } else { + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.check() } + NotificationCenter.default.addObserver(self, selector: #selector(appBecameActive), + name: NSApplication.didBecomeActiveNotification, object: nil) + } + } + + /** + * Builds the sheet's views. + * + * Layout: a header with a shield icon, title and explanation; the numbered + * steps next to the draggable app tile; the status row with its (initially + * hidden) relaunch hint; and a footer with the checkbox and buttons. + * Escape maps to Not Now and Return to the primary button. The window is + * sized to fit the content. + * + * @example + * buildContent() // called once from init + */ + private func buildContent() { + guard let window else { return } + + let icon = NSImageView(image: NSImage(systemSymbolName: "lock.shield.fill", accessibilityDescription: nil)!) + icon.symbolConfiguration = .init(pointSize: 40, weight: .regular) + icon.contentTintColor = .controlAccentColor + + let title = NSTextField(labelWithString: L.fdaTitle) + title.font = .systemFont(ofSize: 17, weight: .semibold) + let body = NSTextField(wrappingLabelWithString: L.fdaBody) + body.textColor = .secondaryLabelColor + body.preferredMaxLayoutWidth = 420 + + let header = NSStackView(views: [icon, NSStackView(views: [title, body], orientation: .vertical, alignment: .leading)]) + header.alignment = .top + header.spacing = 14 + + let steps = NSStackView(views: [ + step(1, L.fdaStep1), + step(2, L.fdaStep2), + step(3, L.fdaStep3), + ], orientation: .vertical, alignment: .leading) + steps.spacing = 8 + let dragTile = DraggableAppIconView() + let stepsRow = NSStackView(views: [steps, dragTile]) + stepsRow.alignment = .centerY + stepsRow.spacing = 16 + + statusSpinner.style = .spinning + statusSpinner.controlSize = .small + statusSpinner.startAnimation(nil) + statusIcon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil) + statusIcon.contentTintColor = .systemGreen + statusIcon.isHidden = true + statusLabel.font = .systemFont(ofSize: 12, weight: .medium) + let status = NSStackView(views: [statusSpinner, statusIcon, statusLabel]) + status.spacing = 6 + + let hint = NSTextField(labelWithString: L.fdaRelaunchHint) + hint.font = .systemFont(ofSize: 11) + hint.textColor = .secondaryLabelColor + let relaunch = NSButton(title: L.fdaRelaunch, target: self, action: #selector(relaunchApp)) + relaunch.bezelStyle = .inline + relaunch.controlSize = .small + relaunchRow.setViews([hint, relaunch], in: .leading) + relaunchRow.spacing = 6 + relaunchRow.isHidden = true + + dontAsk.target = self + dontAsk.state = FullDiskAccess.promptSuppressed ? .on : .off + dontAsk.action = #selector(dontAskChanged) + laterButton.target = self + laterButton.action = #selector(later) + laterButton.keyEquivalent = "\u{1b}" + openButton.target = self + openButton.action = #selector(openSettings) + openButton.keyEquivalent = "\r" + let spacer = NSView() + spacer.setContentHuggingPriority(.init(1), for: .horizontal) + let footer = NSStackView(views: [dontAsk, spacer, laterButton, openButton]) + footer.spacing = 8 + + let statusGroup = NSStackView(views: [status, relaunchRow], orientation: .vertical, alignment: .leading) + statusGroup.spacing = 6 + let root = NSStackView(views: [header, stepsRow, statusGroup, footer], orientation: .vertical, alignment: .leading) + root.spacing = 18 + root.setCustomSpacing(22, after: statusGroup) + root.edgeInsets = NSEdgeInsets(top: 22, left: 24, bottom: 18, right: 24) + root.translatesAutoresizingMaskIntoConstraints = false + footer.translatesAutoresizingMaskIntoConstraints = false + + let content = NSView() + content.addSubview(root) + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: content.leadingAnchor), + root.trailingAnchor.constraint(equalTo: content.trailingAnchor), + root.topAnchor.constraint(equalTo: content.topAnchor), + root.bottomAnchor.constraint(equalTo: content.bottomAnchor), + content.widthAnchor.constraint(equalToConstant: 540), + footer.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48), + ]) + window.contentView = content + window.setContentSize(content.fittingSize) + window.initialFirstResponder = openButton + } + + /** + * Builds one numbered step: a badge followed by wrapping text. + * + * The row uses first-baseline alignment, so the digit sits on the same + * line as the first line of the text even when the text wraps. + * + * @param {Int} n - Step number. + * @param {String} text - Instruction text. + * @returns {NSView} The row view. + * + * @example + * steps.addArrangedSubview(step(1, L.fdaStep1)) + */ + private func step(_ n: Int, _ text: String) -> NSView { + let label = NSTextField(wrappingLabelWithString: text) + label.preferredMaxLayoutWidth = 350 + let badge = StepBadge(number: n) + let row = NSStackView(views: [badge, label]) + row.alignment = .firstBaseline + row.spacing = 8 + return row + } + + // MARK: State + + /** + * Re-checks when the user comes back, typically from System Settings. + * + * Also reveals the relaunch hint, because a grant made while the app was + * running may only apply after a relaunch. + * + * @example + * // Posted by AppKit as NSApplication.didBecomeActiveNotification. + */ + @objc private func appBecameActive() { + if !finished && FullDiskAccess.canRelaunch { relaunchRow.isHidden = false } + check() + } + + /** + * Checks whether access has been granted and updates the sheet. + * + * First checks this process; if that succeeds, shows the granted state + * and closes shortly after. Otherwise it asks a freshly started child + * process, because this process may be refused only because it started + * before the grant; if the child has access, the sheet switches to the + * "relaunch to apply" state. Only one probe runs at a time. + * + * @example + * timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.check() } + */ + private func check() { + guard !finished, !probing else { return } + if FullDiskAccess.isGranted { + showGranted(autoClose: true) + return + } + probing = true + FullDiskAccess.probeInFreshProcess { [weak self] granted in + guard let self else { return } + self.probing = false + if granted && !self.finished { self.showNeedsRelaunch() } + } + } + + /** + * Switches to the "granted, relaunch to apply" state. + * + * macOS applies a new Full Disk Access grant only to processes started + * after it. Stops polling, turns the primary button into Relaunch and + * brings the app forward. Does nothing if polling already stopped. + * + * @example + * FullDiskAccess.probeInFreshProcess { if $0 { self.showNeedsRelaunch() } } + */ + private func showNeedsRelaunch() { + guard timer != nil else { return } + timer?.invalidate() + timer = nil + statusSpinner.stopAnimation(nil) + statusSpinner.isHidden = true + statusIcon.isHidden = false + statusLabel.stringValue = L.fdaGrantedNeedsRelaunch + relaunchRow.isHidden = true + dontAsk.isHidden = true + openButton.title = L.fdaRelaunch + openButton.action = #selector(relaunchApp) + NSApp.activate() + } + + /** + * Switches to the granted state. + * + * Stops polling and leaves only a Done button. With `autoClose`, the app + * comes forward and the sheet closes itself after 1.2 seconds, reporting + * success. + * + * @param {Bool} autoClose - Whether to close automatically (the grant was just detected). + * + * @example + * if FullDiskAccess.isGranted { showGranted(autoClose: true) } + */ + private func showGranted(autoClose: Bool) { + timer?.invalidate() + timer = nil + statusSpinner.stopAnimation(nil) + statusSpinner.isHidden = true + statusIcon.isHidden = false + statusLabel.stringValue = L.fdaGranted + relaunchRow.isHidden = true + laterButton.isHidden = true + dontAsk.isHidden = true + openButton.title = L.fdaDone + openButton.action = #selector(done) + if autoClose { + NSApp.activate() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.finish(granted: true) } + } + } + + // MARK: Actions + + /** + * Opens the Full Disk Access pane in System Settings. + * + * The sheet stays open and keeps polling for the grant. + * + * @example + * openButton.action = #selector(openSettings) + */ + @objc private func openSettings() { + FullDiskAccess.openSettings() + } + + /** + * Closes the sheet without the grant ("Not Now"). + * + * @example + * laterButton.action = #selector(later) + */ + @objc private func later() { finish(granted: false) } + + /** + * Closes the sheet from the granted state, re-checking access first. + * + * @example + * openButton.action = #selector(done) + */ + @objc private func done() { finish(granted: FullDiskAccess.isGranted) } + + /** + * Saves the "Don't ask at launch" checkbox immediately. + * + * @example + * dontAsk.action = #selector(dontAskChanged) + */ + @objc private func dontAskChanged() { + FullDiskAccess.promptSuppressed = dontAsk.state == .on + } + + /** + * Quits and reopens the app so a new grant takes effect. + * + * Rescans `relaunchPath` after the relaunch when one was given. + * + * @example + * openButton.action = #selector(relaunchApp) + */ + @objc private func relaunchApp() { + FullDiskAccess.relaunch(scanning: relaunchPath) + } + + /** + * Closes the sheet once and reports the outcome. + * + * Stops polling, removes the notification observer, ends the sheet and + * calls `onFinish`. Later calls do nothing. + * + * @param {Bool} granted - Whether access is now granted. + * + * @example + * finish(granted: FullDiskAccess.isGranted) + */ + private func finish(granted: Bool) { + guard !finished, let window else { return } + finished = true + timer?.invalidate() + timer = nil + NotificationCenter.default.removeObserver(self) + window.sheetParent?.endSheet(window) + onFinish?(granted) + } +} + +/** Lists folders that stayed unreadable (system-protected or root-only). */ +final class DeniedFoldersSheet: NSWindowController, NSTableViewDataSource, NSTableViewDelegate { + /** Called after the sheet closes, so the owner can release it. */ + var onClose: (() -> Void)? + /** The recorded unreadable folders. */ + private let folders: [DeniedFolder] + /** How many folders were unreadable in total (the list may be capped). */ + private let totalCount: Int + /** Two-column table: path and reason. */ + private let table = NSTableView() + + /** + * Creates the sheet for a scan's unreadable folders. + * + * Nothing is shown until `begin(on:)` attaches it to a window. + * + * @param {[DeniedFolder]} folders - The recorded folders to list. + * @param {Int} totalCount - Total number of unreadable folders, shown in the title. + * + * @example + * let sheet = DeniedFoldersSheet(folders: result.denied, totalCount: result.deniedCount) + */ + init(folders: [DeniedFolder], totalCount: Int) { + self.folders = folders + self.totalCount = totalCount + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 440), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.minSize = NSSize(width: 480, height: 320) + super.init(window: window) + buildContent() + } + + /** + * Not supported; the sheet is only created in code. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called: the controller is not loaded from a nib. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Presents the sheet on `parent`. + * + * The caller must keep the controller alive until `onClose` runs. + * + * @param {NSWindow} parent - Window to attach the sheet to. + * + * @example + * activeSheet = sheet + * sheet.begin(on: window) + */ + func begin(on parent: NSWindow) { + guard let window else { return } + parent.beginSheet(window) + } + + /** + * Builds the title, explanation, folder table and buttons. + * + * Double-clicking a row, or the Show in Finder button, reveals the + * folders; Return closes the sheet. + * + * @example + * buildContent() // called once from init + */ + private func buildContent() { + guard let window else { return } + let title = NSTextField(labelWithString: L.deniedTitle(totalCount)) + title.font = .systemFont(ofSize: 15, weight: .semibold) + let body = NSTextField(wrappingLabelWithString: L.deniedBody) + body.textColor = .secondaryLabelColor + + let pathCol = NSTableColumn(identifier: .colPath) + pathCol.title = L.colPath + pathCol.width = 440 + let reasonCol = NSTableColumn(identifier: .colName) + reasonCol.title = L.deniedReason + reasonCol.width = 150 + table.addTableColumn(pathCol) + table.addTableColumn(reasonCol) + table.usesAlternatingRowBackgroundColors = true + table.style = .fullWidth + table.rowHeight = 20 + table.allowsMultipleSelection = true + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(reveal) + let scroll = NSScrollView() + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + + let revealButton = NSButton(title: L.revealInFinder, target: self, action: #selector(reveal)) + let closeButton = NSButton(title: L.close, target: self, action: #selector(closeSheet(_:))) + closeButton.keyEquivalent = "\r" + let spacer = NSView() + spacer.setContentHuggingPriority(.init(1), for: .horizontal) + let footer = NSStackView(views: [revealButton, spacer, closeButton]) + + let root = NSStackView(views: [title, body, scroll, footer], orientation: .vertical, alignment: .leading) + root.spacing = 10 + root.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 16, right: 20) + root.translatesAutoresizingMaskIntoConstraints = false + let content = NSView() + content.addSubview(root) + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: content.leadingAnchor), + root.trailingAnchor.constraint(equalTo: content.trailingAnchor), + root.topAnchor.constraint(equalTo: content.topAnchor), + root.bottomAnchor.constraint(equalTo: content.bottomAnchor), + scroll.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -40), + footer.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -40), + body.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -40), + scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 200), + ]) + window.contentView = content + } + + /** + * Reports the number of listed folders. + * + * @param {NSTableView} tableView - The folder table. + * @returns {Int} One row per recorded folder. + * + * @example + * table.reloadData() // AppKit calls numberOfRows(in:) + */ + func numberOfRows(in tableView: NSTableView) -> Int { folders.count } + + /** + * Provides the cell for a path or reason column. + * + * The path cell also shows the full path as a tooltip. The reason reads + * "Protected by macOS" for EPERM and "Administrator (root) only" otherwise. + * + * @param {NSTableView} tableView - The folder table. + * @param {NSTableColumn?} tableColumn - The column being drawn. + * @param {Int} row - Row index into `folders`. + * @returns {NSView?} A text cell, or nil without a column. + * + * @example + * // Called by AppKit for each visible cell. + */ + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let id = tableColumn?.identifier else { return nil } + let cell = tableView.cell(id) { TextCellView(identifier: id, alignment: .left, hasIcon: false) } + let f = folders[row] + if id == .colPath { + cell.set(f.path) + cell.toolTip = f.path + } else { + cell.set(f.isPrivacyProtected ? L.reasonProtected : L.reasonRootOnly, dimmed: true) + } + return cell + } + + /** + * Reveals folders in Finder. + * + * A double-clicked row outside the selection is revealed on its own; + * otherwise every selected row is revealed. + * + * @example + * table.doubleAction = #selector(reveal) + */ + @objc private func reveal() { + let rows = table.clickedRow >= 0 && !table.selectedRowIndexes.contains(table.clickedRow) + ? IndexSet(integer: table.clickedRow) : table.selectedRowIndexes + let urls = rows.map { URL(fileURLWithPath: folders[$0].path) } + if !urls.isEmpty { NSWorkspace.shared.activateFileViewerSelecting(urls) } + } + + /** + * Ends the sheet and notifies the owner. + * + * @param {Any?} sender - The Close button. + * + * @example + * closeButton.action = #selector(closeSheet(_:)) + */ + @objc private func closeSheet(_ sender: Any?) { + guard let window else { return } + window.sheetParent?.endSheet(window) + onClose?() + } +} + +private extension NSStackView { + /** + * Creates a stack view with its orientation and alignment in one call. + * + * @param {[NSView]} views - Arranged subviews, in order. + * @param {NSUserInterfaceLayoutOrientation} orientation - Horizontal or vertical stacking. + * @param {NSLayoutConstraint.Attribute} alignment - Cross-axis alignment of the views. + * + * @example + * let column = NSStackView(views: [title, body], orientation: .vertical, alignment: .leading) + */ + convenience init(views: [NSView], orientation: NSUserInterfaceLayoutOrientation, + alignment: NSLayoutConstraint.Attribute) { + self.init(views: views) + self.orientation = orientation + self.alignment = alignment + } +} diff --git a/Sources/MacTree/UI/SummaryBar.swift b/Sources/MacTree/UI/SummaryBar.swift new file mode 100644 index 0000000..d8ae428 --- /dev/null +++ b/Sources/MacTree/UI/SummaryBar.swift @@ -0,0 +1,436 @@ +import AppKit + +/** + * Horizontal bar showing total / used / free space with the scanned share highlighted. + * + * The grey part is the space the volume reports as used; the accent-coloured + * part is how much of it the scan accounted for. + */ +final class UsageBarView: NSView { + /** Volume capacity in bytes; 0 hides the bar's contents. */ + var total: Int64 = 0 { didSet { needsDisplay = true } } + /** Bytes the volume reports as used. */ + var used: Int64 = 0 { didSet { needsDisplay = true } } + /** Bytes found by the scan so far (in the current size mode). */ + var scanned: Int64 = 0 { didSet { needsDisplay = true } } + + /** Fixed 10 pt height; the width comes from constraints. */ + override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: 10) } + + /** + * Draws the rounded track, the used share and the scanned share. + * + * With no capacity only the empty track is drawn. The scanned share is + * capped at the used share (or the total when used is unknown), since a + * logical-size scan can exceed what the disk reports as used. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw (the whole bar is always drawn). + * + * @example + * usageBar.scanned = 400_000_000_000 // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + let r = bounds.insetBy(dx: 0.5, dy: 0.5) + let track = NSBezierPath(roundedRect: r, xRadius: 4, yRadius: 4) + NSColor.quaternaryLabelColor.withAlphaComponent(0.3).setFill() + track.fill() + guard total > 0 else { return } + NSGraphicsContext.saveGraphicsState() + track.addClip() + let usedW = r.width * CGFloat(Double(min(used, total)) / Double(total)) + let scannedW = r.width * CGFloat(Double(min(scanned, used > 0 ? used : total)) / Double(total)) + NSColor.systemGray.withAlphaComponent(0.55).setFill() + NSRect(x: r.minX, y: r.minY, width: usedW, height: r.height).fill() + NSColor.controlAccentColor.setFill() + NSRect(x: r.minX, y: r.minY, width: scannedW, height: r.height).fill() + NSGraphicsContext.restoreGraphicsState() + } +} + +/** Top strip: location, space usage, scan statistics and progress. */ +final class SummaryBar: NSView { + /** Volume or folder name. */ + let titleLabel = NSTextField(labelWithString: L.appName) + /** Full path of the scanned location. */ + let detailLabel = NSTextField(labelWithString: L.ready) + /** Total / used / scanned bar. */ + let usageBar = UsageBarView() + /** Numbers under the bar: total, used, free and scanned. */ + let usageLabel = NSTextField(labelWithString: "") + /** File and folder counts plus scan time, or live progress while scanning. */ + let statsLabel = NSTextField(labelWithString: "") + /** Spins while a scan runs. */ + let spinner = NSProgressIndicator() + /** Note about unreadable folders; clicking it calls `onWarningClicked`. */ + let warningButton = NSButton() + + /** Called when the unreadable-folders note is clicked. */ + var onWarningClicked: (() -> Void)? + + /** + * Builds the strip's labels, bar, spinner and warning button and lays them out. + * + * The left third holds the title and path, the middle the usage bar, and + * the right edge the statistics, spinner and warning. The strip is a fixed + * 52 pt tall; long texts truncate instead of pushing neighbours. + * + * @param {NSRect} frameRect - Initial frame; normally `.zero` with Auto Layout. + * + * @example + * let summary = SummaryBar() + * summary.translatesAutoresizingMaskIntoConstraints = false + */ + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingMiddle + detailLabel.font = .systemFont(ofSize: 11) + detailLabel.textColor = .secondaryLabelColor + detailLabel.lineBreakMode = .byTruncatingMiddle + usageLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + usageLabel.textColor = .secondaryLabelColor + usageLabel.lineBreakMode = .byTruncatingTail + statsLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + statsLabel.textColor = .secondaryLabelColor + statsLabel.alignment = .right + statsLabel.lineBreakMode = .byTruncatingHead + + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + + warningButton.bezelStyle = .inline + warningButton.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: nil) + warningButton.imagePosition = .imageLeading + warningButton.contentTintColor = .systemOrange + warningButton.font = .systemFont(ofSize: 11) + warningButton.target = self + warningButton.action = #selector(warningClicked) + warningButton.isHidden = true + + for v in [titleLabel, detailLabel, usageBar, usageLabel, statsLabel, spinner, warningButton] as [NSView] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + detailLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + statsLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + usageLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let left = NSLayoutGuide() + addLayoutGuide(left) + NSLayoutConstraint.activate([ + left.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + left.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.34), + titleLabel.leadingAnchor.constraint(equalTo: left.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: left.trailingAnchor), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), + detailLabel.leadingAnchor.constraint(equalTo: left.leadingAnchor), + detailLabel.trailingAnchor.constraint(lessThanOrEqualTo: left.trailingAnchor), + detailLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 1), + + usageBar.leadingAnchor.constraint(equalTo: left.trailingAnchor, constant: 16), + usageBar.topAnchor.constraint(equalTo: topAnchor, constant: 12), + usageBar.heightAnchor.constraint(equalToConstant: 10), + usageBar.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.30), + usageLabel.leadingAnchor.constraint(equalTo: usageBar.leadingAnchor), + usageLabel.trailingAnchor.constraint(equalTo: usageBar.trailingAnchor), + usageLabel.topAnchor.constraint(equalTo: usageBar.bottomAnchor, constant: 4), + + spinner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), + spinner.topAnchor.constraint(equalTo: topAnchor, constant: 9), + statsLabel.leadingAnchor.constraint(greaterThanOrEqualTo: usageBar.trailingAnchor, constant: 16), + statsLabel.trailingAnchor.constraint(equalTo: spinner.leadingAnchor, constant: -6), + statsLabel.topAnchor.constraint(equalTo: topAnchor, constant: 9), + warningButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + warningButton.topAnchor.constraint(equalTo: statsLabel.bottomAnchor, constant: 3), + warningButton.leadingAnchor.constraint(greaterThanOrEqualTo: usageBar.trailingAnchor, constant: 16), + + heightAnchor.constraint(equalToConstant: 52), + ]) + } + + /** + * Unsupported: the strip is built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Draws the hairline separator along the bottom edge. + * + * The view is not flipped, so y = 0 is the bottom. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * summary.needsDisplay = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + NSColor.separatorColor.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 1).fill() + } + + /** + * Resets the strip to its "nothing scanned yet" state. + * + * Stops the spinner, shows the app name and the getting-started hint, and + * clears the statistics, usage bar and warning. + * + * @example + * summary.showIdle() + */ + func showIdle() { + spinner.stopAnimation(nil) + titleLabel.stringValue = L.appName + detailLabel.stringValue = L.ready + statsLabel.stringValue = "" + usageLabel.stringValue = "" + usageBar.total = 0 + warningButton.isHidden = true + } + + /** + * Shows the scanned location and its volume's space figures. + * + * Without volume information (or a zero capacity) the bar and its label + * are cleared. The "scanned" figure is appended only when `scanned` is + * non-nil, which is how callers hide it while a scan is still running. + * + * @param {String} title - Volume or folder name shown in large type. + * @param {String} path - Full path shown under the title. + * @param {VolumeInfo?} volume - Capacity figures of the containing volume, if known. + * @param {Int64?} scanned - Bytes found by the scan, or nil to omit that figure. + * + * @example + * summary.showVolume(title: "Macintosh HD", path: "/", volume: VolumeInfo.info(for: url), scanned: nil) + */ + func showVolume(title: String, path: String, volume: VolumeInfo?, scanned: Int64?) { + titleLabel.stringValue = title + detailLabel.stringValue = path + if let v = volume, v.total > 0 { + usageBar.total = v.total + usageBar.used = v.used + usageBar.scanned = scanned ?? 0 + var parts = ["\(L.total) \(Fmt.bytes(v.total))", "\(L.used) \(Fmt.bytes(v.used))", "\(L.free) \(Fmt.bytes(v.available))"] + if let scanned { parts.append("\(L.scanned) \(Fmt.bytes(scanned))") } + usageLabel.stringValue = parts.joined(separator: " · ") + } else { + usageBar.total = 0 + usageLabel.stringValue = "" + } + } + + /** + * Shows live scan progress. + * + * Starts the spinner, writes the running file / folder / byte counts, hides + * the warning and grows the bar's scanned share. Called about ten times a + * second from the progress timer. + * + * @param {Scanner.Progress} p - A snapshot of the scanner's counters. + * + * @example + * summary.showProgress(scanner.progress) + */ + func showProgress(_ p: Scanner.Progress) { + spinner.startAnimation(nil) + statsLabel.stringValue = "\(L.scanning)… \(Fmt.count(p.files)) \(L.files) · \(Fmt.count(p.dirs)) \(L.folders) · \(Fmt.bytes(p.bytes))" + warningButton.isHidden = true + usageBar.scanned = p.bytes + } + + /** + * Shows the final statistics of a finished or stopped scan. + * + * Stops the spinner and writes the file and folder totals and the scan + * time, noting when results are partial. When folders could not be read, + * the warning button appears: an orange call to action if a Full Disk + * Access grant would reveal them, otherwise a quiet grey note about + * protected system folders. + * + * @param {ScanResult} r - The completed scan. + * @param {Bool} needsAccess - Whether Full Disk Access would make the unreadable folders readable. + * + * @example + * summary.showResult(result, needsAccess: needsFullDiskAccess(for: result)) + */ + func showResult(_ r: ScanResult, needsAccess: Bool) { + spinner.stopAnimation(nil) + var s = "\(Fmt.count(Int(r.root.fileCount))) \(L.files) · \(Fmt.count(Int(r.root.dirCount))) \(L.folders) · \(Fmt.seconds(r.elapsed))" + if r.cancelled { s += " " + L.cancelledNote } + statsLabel.stringValue = s + if r.deniedCount > 0 { + warningButton.title = " " + (needsAccess ? L.deniedNeedsAccess(r.deniedCount) : L.deniedSystem(r.deniedCount)) + warningButton.image = NSImage(systemSymbolName: needsAccess ? "exclamationmark.triangle.fill" : "info.circle", + accessibilityDescription: nil) + warningButton.contentTintColor = needsAccess ? .systemOrange : .secondaryLabelColor + warningButton.isHidden = false + } else { + warningButton.isHidden = true + } + } + + /** + * Forwards a click on the warning button to `onWarningClicked`. + * + * @example + * summary.onWarningClicked = { controller.showUnreadableFolders() } + */ + @objc private func warningClicked() { onWarningClicked?() } +} + +/** Bottom status line: hovered or selected item on the left, file counts on the right. */ +final class StatusBar: NSView { + /** Left-aligned text; truncates in the middle so both ends of a path stay visible. */ + let label = NSTextField(labelWithString: "") + /** Right-aligned text, e.g. the File View match count. */ + let rightLabel = NSTextField(labelWithString: "") + + /** + * Builds the two labels and lays them out in a fixed 22 pt strip. + * + * The right label never compresses; the left one gives way and truncates. + * + * @param {NSRect} frameRect - Initial frame; normally `.zero` with Auto Layout. + * + * @example + * let status = StatusBar() + * status.label.stringValue = "/Applications" + */ + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + for l in [label, rightLabel] { + l.translatesAutoresizingMaskIntoConstraints = false + l.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + l.textColor = .secondaryLabelColor + addSubview(l) + } + label.lineBreakMode = .byTruncatingMiddle + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + rightLabel.alignment = .right + rightLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + rightLabel.leadingAnchor.constraint(greaterThanOrEqualTo: label.trailingAnchor, constant: 12), + rightLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + rightLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 22), + ]) + } + + /** + * Unsupported: the status bar is built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Draws the hairline separator along the top edge. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * status.needsDisplay = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + NSColor.separatorColor.setFill() + NSRect(x: 0, y: bounds.height - 1, width: bounds.width, height: 1).fill() + } +} + +/** Breadcrumb above the treemap with zoom controls. */ +final class TreemapHeader: NSView { + /** Zooms out (whole folder first, then one folder up). */ + let upButton = NSButton() + /** Returns to the whole scan. */ + let homeButton = NSButton() + /** Path of what fills the treemap, plus the zoom factor. */ + let pathLabel = NSTextField(labelWithString: "") + /** Mouse and wheel usage hint on the right. */ + let hintLabel = NSTextField(labelWithString: L.treemapHint) + + /** + * Builds the zoom buttons, path label and hint and lays them out in a 26 pt strip. + * + * The path truncates at its head so the deepest folder stays readable; the + * hint has the lowest compression priority and disappears first when narrow. + * + * @param {NSRect} frameRect - Initial frame; normally `.zero` with Auto Layout. + * + * @example + * let header = TreemapHeader() + * header.upButton.target = controller + */ + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + upButton.image = NSImage(systemSymbolName: "arrow.up.left", accessibilityDescription: L.zoomOut) + upButton.toolTip = L.zoomOut + homeButton.image = NSImage(systemSymbolName: "square.grid.2x2", accessibilityDescription: L.zoomReset) + homeButton.toolTip = L.zoomReset + for b in [upButton, homeButton] { + b.bezelStyle = .accessoryBarAction + b.isBordered = true + b.controlSize = .small + } + pathLabel.font = .systemFont(ofSize: 11, weight: .medium) + pathLabel.lineBreakMode = .byTruncatingHead + pathLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + hintLabel.font = .systemFont(ofSize: 10.5) + hintLabel.textColor = .tertiaryLabelColor + hintLabel.lineBreakMode = .byTruncatingTail + hintLabel.setContentCompressionResistancePriority(.init(100), for: .horizontal) + for v in [upButton, homeButton, pathLabel, hintLabel] as [NSView] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + NSLayoutConstraint.activate([ + homeButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + homeButton.centerYAnchor.constraint(equalTo: centerYAnchor), + upButton.leadingAnchor.constraint(equalTo: homeButton.trailingAnchor, constant: 4), + upButton.centerYAnchor.constraint(equalTo: centerYAnchor), + pathLabel.leadingAnchor.constraint(equalTo: upButton.trailingAnchor, constant: 8), + pathLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + hintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: pathLabel.trailingAnchor, constant: 12), + hintLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + hintLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 26), + ]) + } + + /** + * Unsupported: the header is built in code only. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Never called; there are no nibs or storyboards. + */ + required init?(coder: NSCoder) { fatalError() } + + /** + * Fills the window background and draws hairlines along the top and bottom edges. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * header.needsDisplay = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + bounds.fill() + NSColor.separatorColor.setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 1).fill() + NSRect(x: 0, y: bounds.height - 1, width: bounds.width, height: 1).fill() + } +} diff --git a/Sources/MacTree/UI/TreeController.swift b/Sources/MacTree/UI/TreeController.swift new file mode 100644 index 0000000..799111f --- /dev/null +++ b/Sources/MacTree/UI/TreeController.swift @@ -0,0 +1,623 @@ +import AppKit + +/** Actions the node lists forward to the window controller. */ +protocol NodeListOwner: AnyObject { + /** + * Reports that the user changed the selection in a list. + * + * Programmatic selection changes made by the lists themselves are not reported. + * + * @param {AnyObject} source - The list that changed (tree or file list controller). + * @param {[Node]} nodes - The newly selected nodes, possibly empty. + * + * @example + * owner?.list(self, didSelect: outline.selectedNodes) + */ + func list(_ source: AnyObject, didSelect nodes: [Node]) + + /** + * Builds the context menu for a set of nodes. + * + * @param {[Node]} nodes - The nodes the menu should act on. + * @returns {NSMenu?} The menu, or nil to show none. + * + * @example + * return owner?.contextMenu(for: selectedNodes) + */ + func contextMenu(for nodes: [Node]) -> NSMenu? + + /** + * Opens or closes the Quick Look panel for the current selection. + * + * @example + * owner?.toggleQuickLook() // on the space bar + */ + func toggleQuickLook() + + /** + * Previews a double-clicked file. + * + * @param {Node} node - The file that was double-clicked. + * + * @example + * owner?.openOrQuickLook(rows[row]) + */ + func openOrQuickLook(_ node: Node) + + /** + * Marks the selection for permanent deletion, or unmarks it. + * + * Sent when the user presses Delete in a list. If every selected node is + * already marked the marks are removed, otherwise the unmarked ones are + * added. Nothing is deleted until the user confirms Delete Permanently. + * + * @example + * owner?.toggleDeletionMark() // Delete key in the Tree View + */ + func toggleDeletionMark() +} + +/** Outline view that routes right-clicks and the space bar to its owner. */ +final class NodeOutlineView: NSOutlineView { + /** Receives context-menu and Quick Look requests. */ + weak var owner: NodeListOwner? + /** Nodes in the selected rows. */ + var selectedNodes: [Node] { selectedRowIndexes.compactMap { item(atRow: $0) as? Node } } + + /** + * Builds the context menu for a right-click. + * + * Right-clicking an unselected row selects it first, matching Finder, so + * the menu always acts on what is highlighted. Clicks outside any row get + * no menu. + * + * @param {NSEvent} event - The right-mouse-down event. + * @returns {NSMenu?} The owner's menu for the selected nodes, or nil. + * + * @example + * // Called by AppKit on right-click; the owner builds the actual menu. + */ + override func menu(for event: NSEvent) -> NSMenu? { + let row = self.row(at: convert(event.locationInWindow, from: nil)) + guard row >= 0 else { return nil } + if !selectedRowIndexes.contains(row) { + selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + return owner?.contextMenu(for: selectedNodes) + } + + /** + * Handles the list's own shortcuts; other keys keep their outline behaviour. + * + * Space toggles Quick Look. Delete (⌫ or ⌦, without modifiers) marks or + * unmarks the selection for permanent deletion. + * + * @param {NSEvent} event - The key-down event. + * + * @example + * // Pressing Space in the Tree View opens the Quick Look panel. + */ + override func keyDown(with event: NSEvent) { + if event.charactersIgnoringModifiers == " " { + owner?.toggleQuickLook() + } else if event.isPlainDeleteKey { + owner?.toggleDeletionMark() + } else { + super.keyDown(with: event) + } + } +} + +/** + * The "Tree View": folders and files with WizTree's columns. + * + * The scan root is the single top-level row. Children are shown in the tree's + * own order (largest first by the size mode) unless another column is sorted, + * in which case sorted copies are cached per expanded folder, so the tree + * itself is never reordered by the outline. + */ +final class TreeController: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + /** The outline view. */ + let outline = NodeOutlineView() + /** Scroll view hosting the outline, placed in the Tree View tab. */ + let scrollView = NSScrollView() + /** Receives selection, context-menu and Quick Look requests. */ + weak var owner: NodeListOwner? + + /** The scan root shown as the top-level row. */ + private(set) var root: Node? + /** Size used for percentages and the natural order. */ + var sizeMode: SizeMode = .allocated + + /** Column identifier of the current sort. */ + private var sortKey = NSUserInterfaceItemIdentifier.colAlloc.rawValue + /** Whether the current sort is ascending. */ + private var ascending = false + /** Sorted children per folder for non-natural sorts; cleared on any change. */ + private var sortedCache: [ObjectIdentifier: [Node]] = [:] + /** Set while changing the selection programmatically so the owner is not notified back. */ + private var suppressSelection = false + /** Tells whether a node is marked for deletion; supplied by the window controller. */ + var isMarked: ((Node) -> Bool)? + + /** + * Creates the outline with WizTree's columns and its scroll view. + * + * Columns: Name, % of Parent, Size, Allocated, Items, Files, Folders and + * Modified; widths and sort order are autosaved. Double-clicking a folder + * expands or collapses it, double-clicking a file previews it. + * + * @example + * let tree = TreeController() + * tree.attach(owner: windowController) + */ + override init() { + super.init() + outline.headerView = NSTableHeaderView() + outline.usesAlternatingRowBackgroundColors = true + outline.style = .fullWidth + outline.rowHeight = 20 + outline.intercellSpacing = NSSize(width: 6, height: 0) + outline.allowsMultipleSelection = true + outline.allowsColumnReordering = true + outline.columnAutoresizingStyle = .noColumnAutoresizing + outline.indentationPerLevel = 14 + outline.autoresizesOutlineColumn = false + outline.gridStyleMask = [] + + let name = outline.addColumn(.colName, title: L.colName, width: 300, minWidth: 120, ascendingFirst: true) + outline.outlineTableColumn = name + outline.addColumn(.colPercent, title: L.colPercent, width: 110, minWidth: 60) + outline.addColumn(.colSize, title: L.colSize, width: 80, alignment: .right) + outline.addColumn(.colAlloc, title: L.colAllocated, width: 80, alignment: .right) + outline.addColumn(.colItems, title: L.colItems, width: 70, alignment: .right) + outline.addColumn(.colFiles, title: L.colFiles, width: 70, alignment: .right) + outline.addColumn(.colFolders, title: L.colFolders, width: 60, alignment: .right) + outline.addColumn(.colModified, title: L.colModified, width: 150) + outline.autosaveName = "TreeOutline" + outline.autosaveTableColumns = true + + outline.dataSource = self + outline.delegate = self + outline.target = self + outline.doubleAction = #selector(doubleClicked) + + scrollView.documentView = outline + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + } + + /** + * Connects the tree and its outline to the object handling selection and menus. + * + * @param {NodeListOwner} owner - Usually the main window controller; held weakly. + * + * @example + * tree.attach(owner: self) + */ + func attach(owner: NodeListOwner) { + self.owner = owner + outline.owner = owner + } + + /** + * Shows a new tree, or clears the outline. + * + * The root is expanded and selected without notifying the owner. A size + * sort follows the new size mode. + * + * @param {Node?} node - The new scan root, or nil to clear. + * @param {SizeMode} sizeMode - The size used for percentages and ordering. + * + * @example + * tree.setRoot(result.root, sizeMode: .allocated) + */ + func setRoot(_ node: Node?, sizeMode: SizeMode) { + self.sizeMode = sizeMode + root = node + alignSortKeyWithSizeMode() + sortedCache.removeAll() + suppressSelection = true + outline.reloadData() + if let node { + outline.expandItem(node) + outline.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + suppressSelection = false + } + + /** + * Reloads after the tree was mutated in place (trash, rescan, size mode change). + * + * Drops cached sort orders and restores the selection for nodes that are + * still in the tree, without notifying the owner. Expanded folders stay + * expanded because the nodes keep their identity. + * + * @example + * tree.reloadPreservingState() + */ + func reloadPreservingState() { + sortedCache.removeAll() + let selected = outline.selectedNodes + suppressSelection = true + outline.reloadData() + let rows = IndexSet(selected.map { outline.row(forItem: $0) }.filter { $0 >= 0 }) + outline.selectRowIndexes(rows, byExtendingSelection: false) + suppressSelection = false + } + + /** + * Switches between logical and allocated size. + * + * Percentages are recomputed on reload, and a size sort switches to the + * matching size column. + * + * @param {SizeMode} mode - The new size mode. + * + * @example + * tree.setSizeMode(.logical) + */ + func setSizeMode(_ mode: SizeMode) { + sizeMode = mode + alignSortKeyWithSizeMode() + reloadPreservingState() + } + + /** + * Makes a size-column sort follow the Size / Allocated toggle. + * + * When the outline is sorted by Size or Allocated (or not sorted yet), the + * sort moves to the column of the current size mode and the header shows + * the indicator there. Sorts by other columns are left alone. Setting the + * sort descriptors triggers `sortDescriptorsDidChange`, which reloads. + * + * @example + * alignSortKeyWithSizeMode() // after sizeMode changed + */ + private func alignSortKeyWithSizeMode() { + let sizeKeys = [NSUserInterfaceItemIdentifier.colSize.rawValue, NSUserInterfaceItemIdentifier.colAlloc.rawValue] + guard sizeKeys.contains(sortKey) || outline.sortDescriptors.isEmpty else { return } + sortKey = sizeMode == .allocated ? sizeKeys[1] : sizeKeys[0] + let descriptor = NSSortDescriptor(key: sortKey, ascending: ascending) + if outline.sortDescriptors.first?.key != sortKey { + outline.sortDescriptors = [descriptor] + } + } + + /** + * Expands the path to a node, selects it and scrolls it into view. + * + * Used to mirror a treemap click; the owner is not notified. Nodes outside + * the current root are ignored. Expanding a folder with many children + * makes the outline load all of them, which can take a moment. + * + * @param {Node} node - The node to show. + * + * @example + * tree.reveal(clickedNode) + */ + func reveal(_ node: Node) { + guard let root, node.isDescendant(of: root) else { return } + suppressSelection = true + for a in node.ancestors.reversed() { outline.expandItem(a) } + let row = outline.row(forItem: node) + if row >= 0 { + outline.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + outline.scrollRowToVisible(row) + } + suppressSelection = false + } + + /** Nodes in the selected rows. */ + var selectedNodes: [Node] { outline.selectedNodes } + + /** + * Updates the red deletion borders of the rows currently on screen. + * + * Rows scrolled in later get their state from `rowViewForItem`, so only + * the existing row views need touching; no reload is required. + * + * @example + * tree.refreshMarks() // after the set of marked nodes changed + */ + func refreshMarks() { + outline.enumerateAvailableRowViews { rowView, row in + guard let rowView = rowView as? MarkableRowView, let node = outline.item(atRow: row) as? Node else { return } + rowView.isMarked = isMarked?(node) ?? false + } + } + + // MARK: Ordering + + /** Whether the current sort matches the tree's own order (size mode, largest first). */ + private var usesNaturalOrder: Bool { + !ascending && (sortKey == NSUserInterfaceItemIdentifier.colPercent.rawValue + || sortKey == (sizeMode == .allocated ? NSUserInterfaceItemIdentifier.colAlloc.rawValue + : NSUserInterfaceItemIdentifier.colSize.rawValue)) + } + + /** + * Returns a folder's children in display order. + * + * Uses `children` directly for the natural order; otherwise sorts a copy + * once per folder and caches it until the next reload. + * + * @param {Node} node - The folder. + * @returns {[Node]} The children in the current sort order. + * + * @example + * let first = children(of: folder).first + */ + private func children(of node: Node) -> [Node] { + if usesNaturalOrder { return node.children } + let key = ObjectIdentifier(node) + if let cached = sortedCache[key] { return cached } + let sorted = NodeSorting.sorted(node.children, key: sortKey, ascending: ascending, mode: sizeMode) + sortedCache[key] = sorted + return sorted + } + + /** + * Re-sorts after a column header click. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {[NSSortDescriptor]} oldDescriptors - The previous sort (unused). + * + * @example + * // Called by NSOutlineView when the user clicks the "Name" header. + */ + func outlineView(_ outlineView: NSOutlineView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let d = outlineView.sortDescriptors.first, let key = d.key else { return } + sortKey = key + ascending = d.ascending + reloadPreservingState() + } + + // MARK: Data source + + /** + * Reports the number of children of an item. + * + * The invisible top level has exactly one row (the scan root) once a tree is set. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {Any?} item - A node, or nil for the top level. + * @returns {Int} The number of child rows. + * + * @example + * // Called by NSOutlineView when loading or expanding rows. + */ + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + guard root != nil else { return 0 } + guard let node = item as? Node else { return 1 } + return node.children.count + } + + /** + * Returns the child at an index of an item, in display order. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {Int} index - The child index. + * @param {Any?} item - A node, or nil for the top level. + * @returns {Any} The child node (the scan root for the top level). + * + * @example + * // Called by NSOutlineView for each visible row. + */ + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + guard let node = item as? Node else { return root! } + return children(of: node)[index] + } + + /** + * Tells whether an item shows a disclosure triangle. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {Any} item - A node. + * @returns {Bool} True for folders that have at least one entry. + * + * @example + * // Called by NSOutlineView when drawing a row. + */ + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + guard let node = item as? Node else { return false } + return node.isDir && !node.children.isEmpty + } + + // MARK: Delegate + + /** + * Provides the cell view for one column of a row. + * + * Unreadable folders, other volumes and iCloud placeholders (whose data is + * not on disk) are greyed out, and their name shows the full path as a + * tooltip. The % of Parent bar is coloured by depth; the scan root shows 100 %. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {NSTableColumn?} tableColumn - The column to fill. + * @param {Any} item - The node for the row. + * @returns {NSView?} The configured cell, or nil for an unknown item or column. + * + * @example + * // Called by NSOutlineView for each visible cell while scrolling. + */ + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? Node, let id = tableColumn?.identifier else { return nil } + let dim = !node.flags.isDisjoint(with: [.denied, .otherVolume, .dataless]) + switch id { + case .colName: + let cell = outlineView.cell(id) { TextCellView(identifier: id, alignment: .left, hasIcon: true) } + cell.set(node.name, icon: Icons.icon(for: node), dimmed: dim) + cell.toolTip = dim ? node.path : nil + return cell + case .colPercent: + let cell = outlineView.cell(id) { BarCellView(identifier: id) } + let parentValue = node.parent.map { Double($0.metric(sizeMode)) } ?? Double(node.metric(sizeMode)) + let f = parentValue > 0 ? Double(node.metric(sizeMode)) / parentValue : 0 + cell.fraction = f + cell.text = Fmt.percent(f) + cell.barColor = NodeSorting.barColor(depth: outlineView.level(forItem: node)) + return cell + default: + let cell = outlineView.cell(id) { + TextCellView(identifier: id, alignment: id == .colModified ? .left : .right, hasIcon: false) + } + cell.set(NodeSorting.text(for: node, column: id), dimmed: dim) + return cell + } + } + + /** + * Provides the row view, which carries the deletion mark. + * + * @param {NSOutlineView} outlineView - The tree outline. + * @param {Any} item - The node for the row. + * @returns {NSTableRowView?} A row view outlined in red when the node is marked for deletion. + * + * @example + * // Called by NSOutlineView before it fills a row's cells. + */ + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + let rowView = MarkableRowView() + if let node = item as? Node { rowView.isMarked = isMarked?(node) ?? false } + return rowView + } + + /** + * Forwards a user selection to the owner. + * + * Ignored while the controller changes the selection itself. + * + * @param {Notification} notification - The outline's selection notification. + * + * @example + * // Called by NSOutlineView when the user clicks a row. + */ + func outlineViewSelectionDidChange(_ notification: Notification) { + guard !suppressSelection else { return } + owner?.list(self, didSelect: outline.selectedNodes) + } + + /** + * Handles a double-click: toggles a folder, previews a file. + * + * Double-clicks on the header or empty space are ignored. + * + * @example + * // Sent by the outline as its doubleAction. + */ + @objc private func doubleClicked() { + let row = outline.clickedRow + guard row >= 0, let node = outline.item(atRow: row) as? Node else { return } + if node.isDir { + if outline.isItemExpanded(node) { outline.collapseItem(node) } else { outline.expandItem(node) } + } else { + owner?.openOrQuickLook(node) + } + } +} + +/** Column text and comparison shared by the tree and file lists. */ +enum NodeSorting { + /** + * Returns the text a node shows in a column. + * + * Count columns are blank for files. The Folder column shows the parent + * folder's path; unknown columns fall back to the name. + * + * @param {Node} node - The row's node. + * @param {NSUserInterfaceItemIdentifier} column - The column identifier. + * @returns {String} The cell text. + * + * @example + * NodeSorting.text(for: node, column: .colSize) // "12.3 GB" + */ + static func text(for node: Node, column: NSUserInterfaceItemIdentifier) -> String { + switch column { + case .colSize: return Fmt.bytes(node.size) + case .colAlloc: return Fmt.bytes(node.alloc) + case .colItems: return node.isDir ? Fmt.count(node.itemCount) : "" + case .colFiles: return node.isDir ? Fmt.count(Int(node.fileCount)) : "" + case .colFolders: return node.isDir ? Fmt.count(Int(node.dirCount)) : "" + case .colModified: return Fmt.date(node.modificationDate) + case .colPath: return node.parent?.path ?? "" + default: return node.name + } + } + + /** + * Returns nodes sorted by a column. + * + * @param {[Node]} nodes - The nodes to sort; the array is not modified. + * @param {String} key - Column identifier raw value, as used in sort descriptors. + * @param {Bool} ascending - Smallest first when true, largest first when false. + * @param {SizeMode} mode - Size used by the % of Parent column. + * @returns {[Node]} A sorted copy. + * + * @example + * let byName = NodeSorting.sorted(folder.children, key: "name", ascending: true, mode: .allocated) + */ + static func sorted(_ nodes: [Node], key: String, ascending: Bool, mode: SizeMode) -> [Node] { + let cmp = comparator(key: key, mode: mode) + return nodes.sorted { ascending ? cmp($0, $1) : cmp($1, $0) } + } + + /** + * Returns the "less than" ordering for a column key. + * + * Names and folder paths compare like Finder (numbers in names in numeric + * order). Size and Allocated break ties with the other size. Unknown keys, + * including % of Parent, order by the size mode's metric. + * + * @param {String} key - Column identifier raw value. + * @param {SizeMode} mode - Size used for the fallback ordering. + * @returns {(Node, Node) -> Bool} A strict "less than" predicate; swap the arguments for descending. + * + * @example + * let cmp = NodeSorting.comparator(key: "alloc", mode: .allocated) + * files.sort { cmp($1, $0) } // largest first + */ + static func comparator(key: String, mode: SizeMode) -> (Node, Node) -> Bool { + switch NSUserInterfaceItemIdentifier(key) { + case .colName: + return { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + case .colPath: + return { ($0.parent?.path ?? "").localizedStandardCompare($1.parent?.path ?? "") == .orderedAscending } + case .colSize: return { $0.size != $1.size ? $0.size < $1.size : $0.alloc < $1.alloc } + case .colAlloc: return { $0.alloc != $1.alloc ? $0.alloc < $1.alloc : $0.size < $1.size } + case .colItems: return { $0.itemCount < $1.itemCount } + case .colFiles: return { $0.fileCount < $1.fileCount } + case .colFolders: return { $0.dirCount < $1.dirCount } + case .colModified: return { $0.mtime < $1.mtime } + default: + return mode == .allocated ? { $0.alloc < $1.alloc } : { $0.size < $1.size } + } + } + + /** % of Parent bar colours, cycled by outline depth so neighbouring levels differ. */ + private static let depthColors: [NSColor] = [ + NSColor(srgbRed: 0.55, green: 0.42, blue: 0.95, alpha: 1), + NSColor(srgbRed: 0.32, green: 0.56, blue: 0.98, alpha: 1), + NSColor(srgbRed: 0.20, green: 0.72, blue: 0.80, alpha: 1), + NSColor(srgbRed: 0.30, green: 0.75, blue: 0.45, alpha: 1), + NSColor(srgbRed: 0.90, green: 0.70, blue: 0.20, alpha: 1), + NSColor(srgbRed: 0.95, green: 0.50, blue: 0.30, alpha: 1), + ] + + /** + * Returns the % of Parent bar colour for an outline depth. + * + * Negative depths are treated as 0; colours repeat every six levels. + * + * @param {Int} depth - Outline level, 0 for the scan root. + * @returns {NSColor} The bar colour. + * + * @example + * cell.barColor = NodeSorting.barColor(depth: outlineView.level(forItem: node)) + */ + static func barColor(depth: Int) -> NSColor { + depthColors[max(0, depth) % depthColors.count] + } +} diff --git a/Sources/MacTree/UI/TreemapView.swift b/Sources/MacTree/UI/TreemapView.swift new file mode 100644 index 0000000..3512212 --- /dev/null +++ b/Sources/MacTree/UI/TreemapView.swift @@ -0,0 +1,992 @@ +import AppKit +import Synchronization + +/** Receives the treemap's user interactions and viewport changes. */ +protocol TreemapViewDelegate: AnyObject { + /** + * Called when the user clicks an item (or right-clicks it before its menu opens). + * + * @param {TreemapView} view - The treemap that was clicked. + * @param {Node} node - The deepest laid-out node under the click. + * + * @example + * func treemap(_ view: TreemapView, didSelect node: Node) { tree.reveal(node) } + */ + func treemap(_ view: TreemapView, didSelect node: Node) + + /** + * Called when the node under the pointer changes. + * + * @param {TreemapView} view - The treemap being hovered. + * @param {Node?} node - The node now under the pointer, or nil when it left the treemap. + * + * @example + * func treemap(_ view: TreemapView, didHover node: Node?) { updateStatus(for: node) } + */ + func treemap(_ view: TreemapView, didHover node: Node?) + + /** + * Called on double-click to show a folder on its own. + * + * The folder is the child of the currently framed folder along the clicked path. + * + * @param {TreemapView} view - The treemap that was double-clicked. + * @param {Node} node - The folder to make the whole treemap. + * + * @example + * func treemap(_ view: TreemapView, didZoomTo node: Node) { view.showFolder(node) } + */ + func treemap(_ view: TreemapView, didZoomTo node: Node) + + /** + * Called when the user keeps zooming out while the whole folder is already shown. + * + * The delegate typically moves up a folder level. + * + * @param {TreemapView} view - The treemap asking to zoom out. + * + * @example + * func treemapDidRequestZoomOut(_ view: TreemapView) { zoomOut(nil) } + */ + func treemapDidRequestZoomOut(_ view: TreemapView) + + /** + * Asks for the context menu of an item. + * + * @param {TreemapView} view - The treemap that was right-clicked. + * @param {Node} node - The node under the pointer. + * @returns {NSMenu?} The menu to show, or nil for none. + * + * @example + * func treemap(_ view: TreemapView, menuFor node: Node) -> NSMenu? { contextMenu(for: [node]) } + */ + func treemap(_ view: TreemapView, menuFor node: Node) -> NSMenu? + + /** + * Called when the zoom factor or the folder filling the view changed. + * + * @param {TreemapView} view - The treemap whose viewport changed. + * + * @example + * func treemapViewportDidChange(_ view: TreemapView) { updateTreemapHeader() } + */ + func treemapViewportDidChange(_ view: TreemapView) + + /** + * Called when Delete is pressed while the treemap has focus. + * + * The delegate marks the current selection for permanent deletion, or + * unmarks it when it is already marked. + * + * @param {TreemapView} view - The treemap that received the key. + * + * @example + * func treemapDidRequestToggleMark(_ view: TreemapView) { toggleDeletionMark() } + */ + func treemapDidRequestToggleMark(_ view: TreemapView) +} + +/** Displays the cushion treemap with continuous zoom; rendering happens off the main thread. */ +final class TreemapView: NSView { + /** Receives selection, hover, zoom and menu requests. */ + weak var delegate: TreemapViewDelegate? + + /** + * The directory laid out as the whole treemap. + * + * Changing it resets the zoom (or applies the viewport prepared by + * `show(_:framing:)`), drops the stale layout and starts a new render. + */ + var root: Node? { + didSet { + guard root !== oldValue else { return } + let vp = pendingViewport + pendingViewport = nil + zoomScale = vp?.scale ?? 1 + zoomOffset = vp?.offset ?? .zero + clampViewport() + layout = nil + hovered = nil + focusNode = root + setNeedsRender() + delegate?.treemapViewportDidChange(self) + } + } + /** Which size determines each rectangle's area; changing it re-renders. */ + var sizeMode: SizeMode = .allocated { didSet { if sizeMode != oldValue { setNeedsRender() } } } + /** Colour per extension; setting it re-renders. */ + var colors: ExtColors? { didSet { setNeedsRender() } } + /** Extension drawn in colour while everything else is dimmed, or nil for none. */ + var highlightExt: UInt16? { didSet { if highlightExt != oldValue { setNeedsRender() } } } + /** Node outlined as the current selection. */ + var selected: Node? { didSet { if selected !== oldValue { needsDisplay = true } } } + /** Text shown while there is nothing to draw. */ + var placeholder: String = L.ready { didSet { needsDisplay = true } } + /** Nodes marked for permanent deletion, outlined in red. */ + var marked: [Node] = [] { didSet { needsDisplay = true } } + + /** + * Continuous zoom inside `root`: 1 shows the whole folder. The layout is drawn + * `zoomScale` times the view size and the view shows it from `zoomOffset`. + */ + private(set) var zoomScale: CGFloat = 1 + /** Top-left corner of the view inside the zoomed layout, in points. */ + private(set) var zoomOffset: CGPoint = .zero + /** Largest zoom factor; deep enough to see single small files on a full disk. */ + static let maxZoom: CGFloat = 10_000 + /** The deepest folder (or file) that fills the view. */ + private(set) var focusNode: Node? + /** Viewport to apply when `root` changes next, set by `show(_:framing:)`. */ + private var pendingViewport: (scale: CGFloat, offset: CGPoint)? + + /** Node under the pointer; changes redraw and notify the delegate. */ + private(set) var hovered: Node? { + didSet { + if hovered !== oldValue { + needsDisplay = true + delegate?.treemap(self, didHover: hovered) + } + } + } + + /** The last rendered picture. */ + private var image: CGImage? + /** The layout matching `image`, used for hit testing and outlines. */ + private var layout: TreemapLayout? + /** Zoom factor `image` was rendered at. */ + private var imageScale: CGFloat = 1 + /** Viewport offset `image` was rendered at. */ + private var imageOffset: CGPoint = .zero + /** View size `image` was rendered for, in points. */ + private var imageSize: CGSize = .zero + /** While switching folders, where the previous picture is drawn until the new one lands. */ + private var transitionFrame: CGRect? + + /** Bumped whenever a render is requested; results from older generations are discarded. */ + private var generation = 0 + /** Whether a render is running on `renderQueue`. */ + private var renderInFlight = false + /** Whether another render was requested while one was running. */ + private var renderPending = false + /** Serial queue that runs layout and shading. */ + private let renderQueue = DispatchQueue(label: "treemap.render", qos: .userInitiated) + /** Tracking area for hover and exit events. */ + private var trackingArea: NSTrackingArea? + + /** Top-left origin, matching the renderer's pixel coordinates. */ + override var isFlipped: Bool { true } + /** Accepts first responder so clicks take focus from the lists. */ + override var acceptsFirstResponder: Bool { true } + /** The view paints every pixel, so AppKit need not draw behind it. */ + override var isOpaque: Bool { true } + + /** + * Creates the treemap view. + * + * Layer-backed and redrawn only on `needsDisplay`, since the picture comes + * from the background renderer. + * + * @param {NSRect} frameRect - Initial frame; usually `.zero` with Auto Layout. + * + * @example + * let treemap = TreemapView(frame: .zero) + */ + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + } + + /** + * Unsupported: the view is only built in code. + * + * @param {NSCoder} coder - Unused. + * + * @example + * // Not used; the view is created with init(frame:). + */ + required init?(coder: NSCoder) { fatalError() } + + // MARK: Rendering + + /** + * Prepares for a tree mutation or replacement. + * + * Cancels in-flight renders and drops the layout, which references nodes. + * The old picture stays on screen until the next render lands, to avoid + * flashing. Call before changing the tree, then `setNeedsRender()` after. + * + * @example + * treemap.invalidate() + * treeLock.lock(); parent.children.remove(at: i); treeLock.unlock() + * treemap.setNeedsRender() + */ + func invalidate() { + generation += 1 + for t in inFlight { t.cancel() } + layout = nil + hovered = nil + needsDisplay = true + } + + /** + * Requests a new render with the current root, size, viewport and colours. + * + * Requests are coalesced: at most one render runs at a time and at most one + * more is queued behind it, always with the latest state. + * + * @example + * treemap.highlightExt = movID + * treemap.setNeedsRender() + */ + func setNeedsRender() { + generation += 1 + if renderInFlight { + renderPending = true + } else { + startRender() + } + } + + /** + * Starts a render on the background queue. + * + * Captures the current viewport and view size so the result can be mapped + * correctly even if the viewport changed while rendering. Results from an + * outdated generation are dropped; a queued request starts the next render. + * With no root or colours, the picture is cleared instead. + * + * @example + * startRender() + */ + private func startRender() { + guard let root, let colors, bounds.width >= 1, bounds.height >= 1 else { + image = nil + layout = nil + transitionFrame = nil + needsDisplay = true + return + } + let backing = window?.backingScaleFactor ?? 2 + let scale = zoomScale, offset = zoomOffset, size = bounds.size + let params = TreemapRenderer.Params( + width: Int((size.width * backing).rounded()), + height: Int((size.height * backing).rounded()), + sizeMode: sizeMode, colors: colors, highlightExt: highlightExt, + scale: Double(scale), offsetX: Double(offset.x * backing), offsetY: Double(offset.y * backing)) + let gen = generation + renderInFlight = true + renderPending = false + let token = CancelToken() + inFlight.append(token) + renderQueue.async { [weak self] in + let result = TreemapRenderer.render(root: root, params: params) { token.isCancelled } + DispatchQueue.main.async { + guard let self else { return } + self.inFlight.removeAll { $0 === token } + self.renderInFlight = false + if let result, gen == self.generation { + self.image = result.0 + self.layout = result.1 + self.imageScale = scale + self.imageOffset = offset + self.imageSize = size + self.transitionFrame = nil + let focus = result.1.items.isEmpty ? root : result.1.items[result.1.focusIndex].node + if focus !== self.focusNode { + self.focusNode = focus + self.delegate?.treemapViewportDidChange(self) + } + self.needsDisplay = true + self.refreshHover() + } + if self.renderPending || gen != self.generation { + self.startRender() + } + } + } + } + + /** Thread-safe cancellation flag shared between the view and one render. */ + private final class CancelToken: @unchecked Sendable { + /** Set once the render is no longer wanted. */ + private let flag = Atomic(false) + /** Whether `cancel()` has been called. */ + var isCancelled: Bool { flag.load(ordering: .relaxed) } + + /** + * Marks the render as unwanted. + * + * The renderer polls the flag during layout and gives up early. + * + * @example + * token.cancel() + */ + func cancel() { flag.store(true, ordering: .relaxed) } + } + /** Tokens of renders still running, cancelled by `invalidate()`. */ + private var inFlight: [CancelToken] = [] + + /** + * Resizes the view and keeps looking at the same part of the layout. + * + * The zoom offset is scaled with the size change and clamped, then a new + * render is requested. Until it lands, the old picture is stretched. + * + * @param {NSSize} newSize - The new frame size. + * + * @example + * // Called by AppKit during window or split-view resizing. + * treemap.setFrameSize(NSSize(width: 1200, height: 400)) + */ + override func setFrameSize(_ newSize: NSSize) { + let old = frame.size + super.setFrameSize(newSize) + guard newSize != old else { return } + if old.width > 0, old.height > 0 { + zoomOffset.x *= newSize.width / old.width + zoomOffset.y *= newSize.height / old.height + clampViewport() + } + setNeedsRender() + } + + /** + * Re-renders when the backing scale changes, e.g. after moving to another display. + * + * @example + * // Called by AppKit when the window moves between Retina and non-Retina screens. + */ + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + setNeedsRender() + } + + // MARK: Picture ↔ view mapping + + /** + * Maps a point of the rendered picture into the view under the current viewport. + * + * This lets zoom and pan respond instantly while the next render is on its + * way. When the view was resized since the render, the picture is simply + * stretched to the new size. + * + * @param {CGPoint} p - Point in picture coordinates (points). + * @returns {CGPoint} The same spot in current view coordinates. + * + * @example + * let origin = pictureToView(.zero) + */ + private func pictureToView(_ p: CGPoint) -> CGPoint { + if imageSize != bounds.size, imageSize.width > 0, imageSize.height > 0 { + return CGPoint(x: p.x * bounds.width / imageSize.width, y: p.y * bounds.height / imageSize.height) + } + let k = zoomScale / imageScale + return CGPoint(x: (p.x + imageOffset.x) * k - zoomOffset.x, y: (p.y + imageOffset.y) * k - zoomOffset.y) + } + + /** + * Maps a view point back into the rendered picture. + * + * Inverse of `pictureToView(_:)`, used for hit testing against the layout + * that belongs to the current picture. + * + * @param {CGPoint} v - Point in view coordinates. + * @returns {CGPoint} The same spot in picture coordinates (points). + * + * @example + * let p = viewToPicture(convert(event.locationInWindow, from: nil)) + */ + private func viewToPicture(_ v: CGPoint) -> CGPoint { + if imageSize != bounds.size, bounds.width > 0, bounds.height > 0 { + return CGPoint(x: v.x * imageSize.width / bounds.width, y: v.y * imageSize.height / bounds.height) + } + let k = zoomScale / imageScale + return CGPoint(x: (v.x + zoomOffset.x) / k - imageOffset.x, y: (v.y + zoomOffset.y) / k - imageOffset.y) + } + + /** + * Draws the picture, then the hover and selection outlines. + * + * The picture is placed through the current viewport (or the transition + * frame while switching folders). Outlines come from the picture's layout, + * so they are skipped while a zoom or pan preview is showing. A selection + * outline around the entire view says nothing and is skipped as well. + * Nodes marked for deletion get a red border and tint on top, drawn only + * when the node itself is laid out (not a merged or culled ancestor). + * Without a picture, the placeholder text is drawn when there is no root. + * + * @param {NSRect} dirtyRect - The area AppKit asks to redraw. + * + * @example + * treemap.needsDisplay = true // AppKit then calls draw(_:) + */ + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + ctx.setFillColor(NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1).cgColor) + ctx.fill(bounds) + guard let image else { + if root == nil { drawPlaceholder() } + return + } + let frame: CGRect + if let transitionFrame { + frame = transitionFrame + } else { + let origin = pictureToView(.zero) + let end = pictureToView(CGPoint(x: imageSize.width, y: imageSize.height)) + frame = CGRect(x: origin.x, y: origin.y, width: end.x - origin.x, height: end.y - origin.y) + } + ctx.saveGState() + ctx.interpolationQuality = .low + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + ctx.draw(image, in: CGRect(x: frame.minX, y: bounds.height - frame.maxY, width: frame.width, height: frame.height)) + ctx.restoreGState() + + guard transitionFrame == nil, imageScale == zoomScale, imageOffset == zoomOffset else { return } + if let hovered, hovered !== selected, let r = rect(for: hovered) { + NSColor.white.withAlphaComponent(0.55).setStroke() + let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5)) + p.lineWidth = 1 + p.stroke() + } + if let selected, let r = rect(for: selected), + !(r.minX <= 0 && r.minY <= 0 && r.maxX >= bounds.width && r.maxY >= bounds.height) { + let outer = NSBezierPath(rect: r.insetBy(dx: 1, dy: 1)) + outer.lineWidth = 2 + NSColor.white.setStroke() + outer.stroke() + let inner = NSBezierPath(rect: r.insetBy(dx: 2.5, dy: 2.5)) + inner.lineWidth = 1 + NSColor.black.withAlphaComponent(0.7).setStroke() + if r.width > 6 && r.height > 6 { inner.stroke() } + } + for node in marked { + guard let r = exactRect(for: node) else { continue } + NSColor.systemRed.withAlphaComponent(0.22).setFill() + r.fill(using: .sourceOver) + let border = NSBezierPath(rect: r.insetBy(dx: 1.25, dy: 1.25)) + border.lineWidth = 2.5 + NSColor.systemRed.setStroke() + border.stroke() + } + } + + /** + * View rect of a node that is laid out itself. + * + * Unlike `rect(for:)`, this does not fall back to an ancestor, so a node + * that is too small to draw or outside the view yields nil. + * + * @param {Node} node - The node to locate. + * @returns {NSRect?} Its rect in view points, or nil if it is not drawn. + * + * @example + * if let r = exactRect(for: markedNode) { NSBezierPath(rect: r).stroke() } + */ + private func exactRect(for node: Node) -> NSRect? { + guard let layout, let i = layout.find(node), layout.items[i].node === node else { return nil } + return viewRect(ofItem: i) + } + + /** + * Draws `placeholder` centred in the view. + * + * @example + * if root == nil { drawPlaceholder() } + */ + private func drawPlaceholder() { + let text = placeholder as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13), + .foregroundColor: NSColor.white.withAlphaComponent(0.45), + ] + let size = text.size(withAttributes: attrs) + text.draw(at: NSPoint(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2), + withAttributes: attrs) + } + + /** + * Returns the view rectangle of a laid-out item. + * + * Converts the item's pixel bounds to picture points, then through the + * current viewport. + * + * @param {Int} i - Index into the layout's items. + * @returns {NSRect?} The rectangle in view points, or nil without a layout. + * + * @example + * if let r = viewRect(ofItem: 0) { NSBezierPath(rect: r).stroke() } + */ + private func viewRect(ofItem i: Int) -> NSRect? { + guard let layout, layout.width > 0, layout.height > 0, imageSize.width > 0 else { return nil } + let it = layout.items[i] + let sx = imageSize.width / CGFloat(layout.width), sy = imageSize.height / CGFloat(layout.height) + let a = pictureToView(CGPoint(x: CGFloat(it.x0) * sx, y: CGFloat(it.y0) * sy)) + let b = pictureToView(CGPoint(x: CGFloat(it.x1) * sx, y: CGFloat(it.y1) * sy)) + return NSRect(x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y) + } + + /** + * Returns the view rectangle for a node, or for its nearest drawn ancestor. + * + * @param {Node} node - The node to locate. + * @returns {NSRect?} The rectangle in view points, or nil when the node is not in the layout. + * + * @example + * if let r = rect(for: selected) { outline(r) } + */ + private func rect(for node: Node) -> NSRect? { + guard let layout, let i = layout.find(node) else { return nil } + return viewRect(ofItem: i) + } + + /** + * Returns the node under a view point. + * + * Hit-tests the layout of the current picture; returns nil while a folder + * transition is showing, since the old picture no longer matches the root. + * + * @param {NSPoint} point - Point in view coordinates. + * @returns {Node?} The deepest laid-out node there, or nil. + * + * @example + * let n = treemap.node(at: convert(event.locationInWindow, from: nil)) + */ + func node(at point: NSPoint) -> Node? { + guard transitionFrame == nil, let layout, imageSize.width > 0, imageSize.height > 0 else { return nil } + let p = viewToPicture(point) + let x = Int(p.x * CGFloat(layout.width) / imageSize.width) + let y = Int(p.y * CGFloat(layout.height) / imageSize.height) + guard let i = layout.hitTest(x: x, y: y) else { return nil } + return layout.items[i].node + } + + // MARK: Viewport + + /** + * Keeps the zoom within 1…`maxZoom` and the view inside the zoomed layout. + * + * @example + * zoomOffset.x -= 50 + * clampViewport() + */ + private func clampViewport() { + zoomScale = min(TreemapView.maxZoom, max(1, zoomScale)) + let maxX = max(0, bounds.width * zoomScale - bounds.width) + let maxY = max(0, bounds.height * zoomScale - bounds.height) + zoomOffset.x = min(maxX, max(0, zoomOffset.x)) + zoomOffset.y = min(maxY, max(0, zoomOffset.y)) + } + + /** + * Sets the zoom factor and offset. + * + * Values are clamped. The current picture is shown transformed right away + * and a sharp render is requested. The delegate hears about it when the + * zoom factor actually changed. + * + * @param {CGFloat} scale - Zoom factor, 1 for the whole folder. + * @param {CGPoint} offset - Top-left of the view inside the zoomed layout, in points. + * + * @example + * treemap.setViewport(scale: 4, offset: CGPoint(x: 600, y: 200)) + */ + func setViewport(scale: CGFloat, offset: CGPoint) { + let oldScale = zoomScale + zoomScale = scale + zoomOffset = offset + clampViewport() + needsDisplay = true + setNeedsRender() + if zoomScale != oldScale { delegate?.treemapViewportDidChange(self) } + } + + /** + * Zooms by `factor`, keeping the layout point under `anchor` in place. + * + * Like zooming a map around the pointer. When the zoom is already at its + * limit, zooming out further counts towards moving up a folder level. + * + * @param {CGFloat} factor - Multiplier for the zoom; above 1 zooms in, below 1 zooms out. + * @param {NSPoint} anchor - Point in view coordinates that stays fixed. + * + * @example + * treemap.zoom(by: 2, at: NSPoint(x: 300, y: 120)) + */ + func zoom(by factor: CGFloat, at anchor: NSPoint) { + guard root != nil, factor > 0 else { return } + let target = min(TreemapView.maxZoom, max(1, zoomScale * factor)) + if target == zoomScale { + if factor < 1 { pushOutward(factor) } + return + } + outwardPressure = 0 + let k = target / zoomScale + setViewport(scale: target, offset: CGPoint(x: (anchor.x + zoomOffset.x) * k - anchor.x, + y: (anchor.y + zoomOffset.y) * k - anchor.y)) + } + + /** + * Returns to the whole folder at zoom 1. + * + * @example + * treemap.resetZoom() + */ + func resetZoom() { + setViewport(scale: 1, offset: .zero) + } + + /** Accumulated log zoom-out while already at zoom 1. */ + private var outwardPressure: CGFloat = 0 + /** Time of the last zoom-out attempt at zoom 1, to reset stale pressure. */ + private var lastOutward = Date.distantPast + + /** + * Collects zoom-out gestures made while the whole folder is already shown. + * + * After about 30% more outward zoom within a short burst, asks the delegate + * to go up a level, so a single stray wheel notch does not jump folders. + * Pressure older than 0.4 s is forgotten. + * + * @param {CGFloat} factor - The zoom-out factor (below 1) that could not be applied. + * + * @example + * if target == zoomScale, factor < 1 { pushOutward(factor) } + */ + private func pushOutward(_ factor: CGFloat) { + if Date().timeIntervalSince(lastOutward) > 0.4 { outwardPressure = 0 } + lastOutward = Date() + outwardPressure += log(factor) + if outwardPressure < log(0.7) { + outwardPressure = 0 + delegate?.treemapDidRequestZoomOut(self) + } + } + + /** + * Shows `node` as the whole treemap, optionally keeping `child` framed. + * + * With `child`, the view starts zoomed so that `child` still fills it, which + * makes going up a level feel continuous: further zooming out reveals its + * neighbours. Until the new render lands, the old picture keeps showing in + * the spot where that folder now sits. If `node` is already the root, only + * the viewport changes. + * + * @param {Node} node - The folder to lay out as the whole treemap. + * @param {Node?} child - A descendant to keep framed, or nil to start unzoomed. + * + * @example + * if let parent = current.parent { treemap.show(parent, framing: current) } + */ + func show(_ node: Node, framing child: Node?) { + var viewport: (scale: CGFloat, offset: CGPoint)? + var frame: CGRect? + let w = bounds.width, h = bounds.height + if let child, w > 0, h > 0, + let r = TreemapRenderer.rect(of: child, in: node, width: Double(w), height: Double(h), mode: sizeMode), + r.width > 0, r.height > 0 { + let s = min(TreemapView.maxZoom, max(1, min(w / r.width, h / r.height))) + let off = CGPoint(x: r.midX * s - w / 2, y: r.midY * s - h / 2) + viewport = (s, off) + if image != nil, zoomScale == 1 { + frame = CGRect(x: r.minX * s - off.x, y: r.minY * s - off.y, width: r.width * s, height: r.height * s) + } + } + if node === root { + if let viewport { setViewport(scale: viewport.scale, offset: viewport.offset) } + return + } + pendingViewport = viewport + root = node + transitionFrame = frame + needsDisplay = true + } + + /** + * Makes `node` the whole treemap (double-click or "Zoom Here"). + * + * When the folder is visible in the current picture, that part of the + * picture is stretched to fill the view until the new render lands, so the + * folder appears to grow into place. + * + * @param {Node} node - The folder to show on its own. + * + * @example + * treemap.showFolder(downloadsNode) + */ + func showFolder(_ node: Node) { + var frame: CGRect? + if image != nil, let i = layout?.find(node), layout?.items[i].node === node, let r = viewRect(ofItem: i), + r.width > 1, r.height > 1 { + let kx = bounds.width / r.width, ky = bounds.height / r.height + frame = CGRect(x: -r.minX * kx, y: -r.minY * ky, width: bounds.width * kx, height: bounds.height * ky) + } + root = node + transitionFrame = frame + needsDisplay = true + } + + /** + * Pans, zooming out if needed, so that `node` is on screen. + * + * Does nothing at zoom 1 (everything is visible) or when the node is + * already fully in view. If the node is larger than 90% of the view at the + * current zoom, the zoom is reduced to fit it; the node is then centred. + * + * @param {Node} node - The node chosen in the tree or file list. + * + * @example + * treemap.selected = node + * treemap.reveal(node) + */ + func reveal(_ node: Node) { + guard let root, zoomScale > 1, bounds.width > 0, bounds.height > 0, + let r = TreemapRenderer.rect(of: node, in: root, width: Double(bounds.width), + height: Double(bounds.height), mode: sizeMode) else { return } + var s = zoomScale + let visible = CGRect(x: r.minX * s - zoomOffset.x, y: r.minY * s - zoomOffset.y, + width: r.width * s, height: r.height * s) + if bounds.contains(visible) { return } + if visible.width > bounds.width * 0.9 || visible.height > bounds.height * 0.9 { + s = max(1, min(s, 0.9 * min(bounds.width / r.width, bounds.height / r.height))) + } + setViewport(scale: s, offset: CGPoint(x: r.midX * s - bounds.width / 2, y: r.midY * s - bounds.height / 2)) + } + + // MARK: Mouse + + /** + * Replaces the tracking area so hover events cover the current bounds. + * + * Hover is tracked only while the window is key. + * + * @example + * // Called by AppKit whenever the view's geometry changes. + */ + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { removeTrackingArea(trackingArea) } + let area = NSTrackingArea(rect: bounds, options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + /** Last pointer position inside the view, to refresh hover after a render. */ + private var lastMouse: NSPoint? + /** Where the current mouse press started, or nil when no press is being tracked. */ + private var dragStart: NSPoint? + /** Zoom offset when the press started, the base for panning. */ + private var dragStartOffset: CGPoint = .zero + /** Whether the current drag has turned into a pan. */ + private var isPanning = false + + /** + * Updates the hovered node as the pointer moves. + * + * @param {NSEvent} event - The mouse-moved event. + * + * @example + * // Called by AppKit through the tracking area. + */ + override func mouseMoved(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + lastMouse = p + hovered = node(at: p) + } + + /** + * Clears the hover when the pointer leaves the view. + * + * @param {NSEvent} event - The mouse-exited event. + * + * @example + * // Called by AppKit through the tracking area. + */ + override func mouseExited(with event: NSEvent) { + lastMouse = nil + hovered = nil + } + + /** + * Recomputes the hovered node at the last pointer position. + * + * Needed after a render, because the same pointer position may now lie + * over a different node. + * + * @example + * refreshHover() + */ + private func refreshHover() { + if let p = lastMouse { hovered = node(at: p) } + } + + /** + * Starts tracking a click or drag; a double-click zooms into a folder. + * + * The first click is only resolved on mouse-up, so a drag can become a pan + * instead of a selection. On the second click of a double-click, the delegate + * is asked to show the folder one level below the framed one. + * + * @param {NSEvent} event - The mouse-down event. + * + * @example + * // Called by AppKit when the user presses the mouse button over the treemap. + */ + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + let p = convert(event.locationInWindow, from: nil) + dragStart = p + dragStartOffset = zoomOffset + isPanning = false + if event.clickCount == 2 { + dragStart = nil + if let n = node(at: p), let target = zoomTarget(for: n) { delegate?.treemap(self, didZoomTo: target) } + } + } + + /** + * Pans the zoomed treemap while dragging. + * + * Only when zoomed in; the drag must move more than 3 points before it + * counts as a pan, so small jitters during a click still select. + * + * @param {NSEvent} event - The mouse-dragged event. + * + * @example + * // Called by AppKit while the user drags over the treemap. + */ + override func mouseDragged(with event: NSEvent) { + guard let start = dragStart, zoomScale > 1 else { return } + let p = convert(event.locationInWindow, from: nil) + if !isPanning && hypot(p.x - start.x, p.y - start.y) > 3 { + isPanning = true + NSCursor.closedHand.push() + } + if isPanning { + setViewport(scale: zoomScale, offset: CGPoint(x: dragStartOffset.x - (p.x - start.x), + y: dragStartOffset.y - (p.y - start.y))) + } + } + + /** + * Ends a pan, or selects the node under a plain click. + * + * The second click of a double-click is ignored here because mouse-down + * already zoomed. + * + * @param {NSEvent} event - The mouse-up event. + * + * @example + * // Called by AppKit when the user releases the mouse button. + */ + override func mouseUp(with event: NSEvent) { + defer { dragStart = nil } + if isPanning { + isPanning = false + NSCursor.pop() + return + } + guard let start = dragStart, event.clickCount == 1, let n = node(at: start) else { return } + delegate?.treemap(self, didSelect: n) + } + + /** + * Finds the folder one level below the one filling the view, along the clicked path. + * + * When a single file fills the view, counting starts from its folder. + * Returns nil when the clicked node is not below that folder or the target + * is a file or an empty folder. + * + * @param {Node} n - The node that was double-clicked. + * @returns {Node?} The folder to zoom into, or nil. + * + * @example + * if let target = zoomTarget(for: clicked) { delegate?.treemap(self, didZoomTo: target) } + */ + private func zoomTarget(for n: Node) -> Node? { + guard let base = (focusNode?.isDir == false ? focusNode?.parent : focusNode) ?? root else { return nil } + var child: Node? = n + while let c = child, c.parent !== base { + child = c.parent + } + guard let c = child, c.parent === base else { return nil } + return c.isDir && !c.children.isEmpty ? c : nil + } + + /** + * Selects the node under the pointer and returns its context menu. + * + * @param {NSEvent} event - The right-click (or control-click) event. + * @returns {NSMenu?} The delegate's menu, or nil when nothing is under the pointer. + * + * @example + * // Called by AppKit on right-click. + */ + override func menu(for event: NSEvent) -> NSMenu? { + let p = convert(event.locationInWindow, from: nil) + guard let n = node(at: p) else { return nil } + delegate?.treemap(self, didSelect: n) + return delegate?.treemap(self, menuFor: n) + } + + /** + * Sends Delete (without modifiers) to the delegate as a deletion-mark toggle. + * + * Other keys keep the default behaviour. + * + * @param {NSEvent} event - The key-down event. + * + * @example + * // Click a rectangle, then press Delete to mark it. + */ + override func keyDown(with event: NSEvent) { + if event.isPlainDeleteKey { + delegate?.treemapDidRequestToggleMark(self) + } else { + super.keyDown(with: event) + } + } + + // MARK: Wheel / pinch zoom + + /** + * Zooms around the pointer with the scroll wheel or two-finger scroll. + * + * Wheel forward (or two fingers up) zooms in, back zooms out, regardless of + * the natural-scrolling setting. Momentum events are ignored so the inertia + * after a trackpad flick does not keep zooming. Trackpads and Magic Mouse + * zoom in proportion to the scroll distance; notched wheels zoom 30% per + * notch, capped at four notches per event. + * + * @param {NSEvent} event - The scroll event. + * + * @example + * // Called by AppKit when the user scrolls over the treemap. + */ + override func scrollWheel(with event: NSEvent) { + guard root != nil else { return } + if !event.momentumPhase.isEmpty { return } + var dy = event.scrollingDeltaY + if event.isDirectionInvertedFromDevice { dy = -dy } + guard dy != 0 else { return } + let factor: CGFloat = event.hasPreciseScrollingDeltas + ? exp(dy * 0.012) + : pow(1.3, max(-4, min(4, dy))) + zoom(by: factor, at: convert(event.locationInWindow, from: nil)) + } + + /** + * Zooms around the pointer with a trackpad pinch. + * + * @param {NSEvent} event - The magnify event; its magnification is the relative change. + * + * @example + * // Called by AppKit during a pinch gesture over the treemap. + */ + override func magnify(with event: NSEvent) { + zoom(by: 1 + event.magnification, at: convert(event.locationInWindow, from: nil)) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..37b6c90 --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Builds MacTree.app into ./build (release). +# +# Signing: uses the first "Apple Development" identity in the keychain so that +# macOS keeps the Full Disk Access grant across rebuilds (an ad-hoc signature +# changes every build and the grant stops applying). Override with +# SIGN_IDENTITY="", or SIGN_IDENTITY=- for ad-hoc. +set -euo pipefail +cd "$(dirname "$0")/.." + +swift build -c release + +APP=build/MacTree.app +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp .build/release/MacTree "$APP/Contents/MacOS/MacTree" +cp Resources/Info.plist "$APP/Contents/Info.plist" +cp -R Resources/*.lproj "$APP/Contents/Resources/" + +if [ ! -f build/AppIcon.icns ] || [ scripts/make-icon.swift -nt build/AppIcon.icns ]; then + rm -rf build/AppIcon.iconset + swift scripts/make-icon.swift build/AppIcon.iconset + iconutil -c icns build/AppIcon.iconset -o build/AppIcon.icns +fi +cp build/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" + +# VERSION / BUILD_NUMBER (set by CI for tagged releases) override the Info.plist defaults. +if [ -n "${VERSION:-}" ]; then + plutil -replace CFBundleShortVersionString -string "$VERSION" "$APP/Contents/Info.plist" +fi +if [ -n "${BUILD_NUMBER:-}" ]; then + plutil -replace CFBundleVersion -string "$BUILD_NUMBER" "$APP/Contents/Info.plist" +fi + +IDENTITY="${SIGN_IDENTITY:-}" +if [ -z "$IDENTITY" ]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null | awk '/"Apple Development/ { print $2; exit }') +fi +IDENTITY="${IDENTITY:--}" +codesign --force --sign "$IDENTITY" "$APP" +if [ "$IDENTITY" = "-" ]; then + echo "Built $APP (ad-hoc signed)" +else + echo "Built $APP (signed: $(codesign -dv --verbose=2 "$APP" 2>&1 | awk -F= '/^Authority/ { print $2; exit }'))" +fi diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..7909fda --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,157 @@ +/** + * Renders the MacTree app icon into an .iconset directory. + * + * Usage: `swift scripts/make-icon.swift `, then + * `iconutil -c icns `. build-app.sh does both. + */ +import AppKit + +/** Destination .iconset directory; defaults to "AppIcon.iconset" in the current directory. */ +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset" +try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true) + +/** A coloured rectangle of the icon's treemap: its bounds and base colour before the cushion gradient. */ +struct Tile { var rect: CGRect; var color: NSColor } + +/** + * Minimal squarified treemap layout for the icon tiles. + * + * Same row-building rule as the app's renderer: keep adding items to the + * current row along the shorter side while the worst aspect ratio improves, + * then start a new row in the remaining space. + * + * @param {[Double]} weights - Tile weights, largest first. + * @param {CGRect} rect - Area to fill. + * @returns {[CGRect]} One rectangle per weight, in the same order. + * + * @example + * let rects = squarify([34, 20, 14], in: CGRect(x: 0, y: 0, width: 100, height: 100)) + */ +func squarify(_ weights: [Double], in rect: CGRect) -> [CGRect] { + var result: [CGRect] = [] + var r = rect + var items = weights + var total = items.reduce(0, +) + while !items.isEmpty { + let vertical = r.width >= r.height + let side = vertical ? r.height : r.width + let scale = (r.width * r.height) / total + var row: [Double] = [] + var worst = Double.infinity + for w in items { + let candidate = row + [w] + let sum = candidate.reduce(0, +) * scale + let t = sum / side + let maxA = candidate.max()! * scale, minA = candidate.min()! * scale + let wr = max(t * t / minA, maxA / (t * t)) + if !row.isEmpty && wr > worst { break } + row = candidate + worst = wr + } + let rowSum = row.reduce(0, +) + let t = rowSum * scale / side + var offset = 0.0 + for w in row { + let len = w * scale / t + result.append(vertical ? CGRect(x: r.minX, y: r.minY + offset, width: t, height: len) + : CGRect(x: r.minX + offset, y: r.minY, width: len, height: t)) + offset += len + } + if vertical { r = CGRect(x: r.minX + t, y: r.minY, width: r.width - t, height: r.height) } + else { r = CGRect(x: r.minX, y: r.minY + t, width: r.width, height: r.height - t) } + items.removeFirst(row.count) + total -= rowSum + } + return result +} + +/** + * Makes an sRGB colour from a `0xRRGGBB` value. + * + * @param {UInt32} v - The colour as `0xRRGGBB`. + * @returns {NSColor} The opaque colour. + * + * @example + * let blue = hex(0x3D7EFF) + */ +func hex(_ v: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((v >> 16) & 0xFF) / 255, green: CGFloat((v >> 8) & 0xFF) / 255, + blue: CGFloat(v & 0xFF) / 255, alpha: 1) +} + +/** + * Draws the icon at one pixel size. + * + * Follows the standard macOS icon grid (an 824-point rounded body with a + * 100-point margin on a 1024 canvas, scaled to `size`). It draws a drop + * shadow and a dark gradient body. Squarified tiles in the app's palette get + * a radial "cushion" gradient: bright toward the upper left, darker at the + * rim. A soft gloss covers the upper half. + * + * @param {Int} size - Width and height in pixels. + * @returns {NSBitmapImageRep} The rendered bitmap. + * + * @example + * let png = render(size: 512).representation(using: .png, properties: [:]) + */ +func render(size: Int) -> NSBitmapImageRep { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + let ctx = NSGraphicsContext(bitmapImageRep: rep)! + NSGraphicsContext.current = ctx + let cg = ctx.cgContext + let s = CGFloat(size) / 1024 + + let body = CGRect(x: 100 * s, y: 100 * s, width: 824 * s, height: 824 * s) + let shape = NSBezierPath(roundedRect: body, xRadius: 185 * s, yRadius: 185 * s) + + cg.saveGState() + cg.setShadow(offset: CGSize(width: 0, height: -10 * s), blur: 24 * s, + color: NSColor.black.withAlphaComponent(0.35).cgColor) + hex(0x15171C).setFill() + shape.fill() + cg.restoreGState() + + shape.addClip() + let bg = NSGradient(colors: [hex(0x2A2E38), hex(0x121419)])! + bg.draw(in: shape, angle: -90) + + let inner = body.insetBy(dx: 70 * s, dy: 70 * s) + let weights: [Double] = [34, 20, 14, 10, 8, 6, 4, 3, 1] + let palette: [UInt32] = [0x3D7EFF, 0xF0453A, 0x2FC25B, 0xF7C325, 0xA35CF5, 0x16C2D5, 0xFF8A1F, 0xF0509B, 0x8CCB1E] + let gap = max(1, 10 * s) + for (i, r) in squarify(weights, in: inner).enumerated() { + let tile = r.insetBy(dx: gap / 2, dy: gap / 2) + let path = NSBezierPath(roundedRect: tile, xRadius: 14 * s, yRadius: 14 * s) + let base = hex(palette[i % palette.count]) + cg.saveGState() + path.addClip() + let light = base.blended(withFraction: 0.55, of: .white)! + let dark = base.blended(withFraction: 0.45, of: .black)! + let g = NSGradient(colors: [light, base, dark], atLocations: [0, 0.55, 1], colorSpace: .sRGB)! + let center = NSPoint(x: tile.midX - tile.width * 0.18, y: tile.midY + tile.height * 0.18) + g.draw(fromCenter: center, radius: 0, toCenter: NSPoint(x: tile.midX, y: tile.midY), + radius: max(tile.width, tile.height) * 0.78, options: [.drawsAfterEndingLocation]) + cg.restoreGState() + } + + let gloss = NSGradient(colors: [NSColor.white.withAlphaComponent(0.10), NSColor.white.withAlphaComponent(0)])! + gloss.draw(in: NSRect(x: body.minX, y: body.midY, width: body.width, height: body.height / 2), angle: -90) + + NSGraphicsContext.restoreGraphicsState() + return rep +} + +/** File names and pixel sizes that iconutil expects in an .iconset. */ +let sizes: [(String, Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] +for (name, px) in sizes { + let data = render(size: px).representation(using: .png, properties: [:])! + try! data.write(to: URL(fileURLWithPath: "\(outDir)/\(name).png")) +} +print("wrote \(outDir)")