From 919e1e1134f6eca5e81bcb470b315286408adceb Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:36:14 +0200 Subject: [PATCH 1/6] feat: open archives in tabs, with a New Tab button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: every archive opened in a window of its own, so a few archives meant a few windows to arrange. Now: archives open as tabs of the front window. This is a setting, Settings ▸ General ▸ "Open archives in tabs", on by default. Turned off, archives open as before: in their own windows, unless macOS's own "Prefer tabs" setting says otherwise. - New Tab, a toolbar button and File ▸ New Tab (⌘T), opens the home screen in a new tab. It works with the setting off too. - A tab showing the home screen is named "New Tab". Once an archive is open in it, it takes the archive's name, like the window title. - The tab bar's own + button is left out on purpose: the toolbar's New Tab button already does that job. For review: the tab title is set with NSWindowTab.title; a window on its own keeps its title untouched. The new strings use the catalog's symbol keys with an English value and a comment, as since #270. --- Config/products/macpacker.json | 29 +++++++++++ MacPacker/AppDelegate.swift | 4 ++ .../ArchiveContentToolbarView.swift | 12 +++++ .../ArchiveWindow/ArchiveWindow.swift | 33 +++++++++++++ .../ArchiveWindow/ArchiveWindowManager.swift | 19 +++++++- .../Settings/GeneralSettingsView.swift | 13 +++++ MacPacker/Localizable.xcstrings | 48 +++++++++++++++++++ MacPacker/MacPackerApp.swift | 11 +++++ Modules/Sources/Core/AppStorageKeys.swift | 3 ++ .../CoreTests/OpenArchivesInTabsTests.swift | 23 +++++++++ 10 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index f7e661ab..fe3ebeac 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -98,6 +98,35 @@ "132" ] }, + { + "type": "feat", + "title": { + "en": "Open archives in tabs", + "zh-Hans": "在标签页中打开归档", + "fr": "Ouvrir les archives dans des onglets", + "de": "Archive in Tabs öffnen", + "it": "Aprire gli archivi in schede", + "ja": "アーカイブをタブで開く", + "fa": "باز کردن آرشیوها در زبانه‌ها", + "pl": "Otwieranie archiwów w kartach", + "pt-BR": "Abrir arquivos em abas", + "ru": "Открытие архивов во вкладках", + "uk": "Відкриття архівів у вкладках", + "es-MX": "Abrir archivos en pestañas", + "ko": "아카이브를 탭으로 열기", + "nl": "Archieven openen in tabbladen", + "tr": "Arşivleri sekmelerde açma", + "vi": "Mở tệp nén trong các tab", + "ar": "فتح الأرشيفات في علامات تبويب", + "ca": "Obre els arxius en pestanyes", + "el": "Άνοιγμα συμπιεσμένων αρχείων σε καρτέλες", + "nb": "Åpne arkiver i faner", + "zh-TW": "以分頁開啟壓縮檔" + }, + "issues": [ + "350" + ] + }, { "type": "fix", "highlight": true, diff --git a/MacPacker/AppDelegate.swift b/MacPacker/AppDelegate.swift index 7e177826..c66b4faa 100644 --- a/MacPacker/AppDelegate.swift +++ b/MacPacker/AppDelegate.swift @@ -443,6 +443,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { self.archiveWindowManager?.openNewArchiveWindow() } + func openNewTab() { + self.archiveWindowManager?.openNewTab() + } + func openCreateArchiveWindow() { self.archiveWindowManager?.openCreateArchiveWindow() } diff --git a/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift b/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift index d3cc4eb9..7d25bbb7 100644 --- a/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift +++ b/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift @@ -19,6 +19,7 @@ extension NSImage { struct ArchiveContentToolbarView: ToolbarContent { @EnvironmentObject private var appState: AppState @Environment(\.openQuickCompressWindow) private var openQuickCompressWindow + @Environment(\.openNewTab) private var openNewTab @Environment(\.openURL) private var openURL @Environment(\.openSettings) private var openSettings @State private var isExportingItem: Bool = false @@ -157,6 +158,17 @@ struct ArchiveContentToolbarView: ToolbarContent { } Spacer() + + Button { + openNewTab() + } label: { + Label { + Text(.commonNewTab) + } icon: { + Image(systemName: "plus.square.on.square") + } + } + .help(.archiveNewTabHint) Menu { Button { diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift index e2952a20..a6068014 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift @@ -20,6 +20,8 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { var willCloseHandler: (() -> Void)? var didBecomeMain: (() -> Void)? + /// Opens the home screen in a new tab of this window. + private let openNewTab: @MainActor @Sendable () -> Void private var cancellables = Set() @@ -30,9 +32,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { quickCompressOptions: ArchiveSaveOptions, openQuickCompressWindow: @escaping @MainActor () -> Void, openArchiveInNewWindow: @escaping @MainActor (URL) -> Void, + openNewTab: @escaping @MainActor @Sendable () -> Void, cascadeFrom: NSWindow? = nil ) { self.archiveState = archiveState + self.openNewTab = openNewTab let window = ArchiveWindow() window.isRestorable = false @@ -56,6 +60,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { window.delegate = self + // Archives open as tabs of the front window when Settings say so, and + // as macOS's own preference has it otherwise. + window.tabbingIdentifier = "MacPackerArchiveWindow" + window.tabbingMode = UserDefaults.standard.bool(forKey: Keys.openArchivesInTabs) ? .preferred : .automatic + window.toolbarStyle = .unified // show the content view @@ -65,6 +74,7 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { .environmentObject(dropCompressor) .environmentObject(quickCompressOptions) .environment(\.openArchiveInNewWindow, openArchiveInNewWindow) + .environment(\.openNewTab, openNewTab) .environment(\.openQuickCompressWindow, openQuickCompressWindow) window.contentView = NSHostingView(rootView: contentView) @@ -76,6 +86,16 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { .removeDuplicates() .sink { [weak window] edited in window?.isDocumentEdited = edited } .store(in: &cancellables) + + // As a tab, the home screen is a "New Tab"; with an archive, the tab + // reads as the window's title does. The title of a window on its own + // is not touched. + archiveState.$hasArchive + .removeDuplicates() + .sink { [weak window] hasArchive in + window?.tab.title = hasArchive ? nil : String(localized: .commonNewTab) + } + .store(in: &cancellables) } required init?(coder: NSCoder) { @@ -211,6 +231,19 @@ extension EnvironmentValues { } } +/// Opens MacPacker's home screen in a new tab of the window: the toolbar's +/// New Tab button. +private struct OpenNewTabKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = { } +} + +extension EnvironmentValues { + var openNewTab: @MainActor @Sendable () -> Void { + get { self[OpenNewTabKey.self] } + set { self[OpenNewTabKey.self] = newValue } + } +} + /// Opens the floating quick-compress window. An action, not state, so it travels /// as a closure rather than as an object a view has to hold. private struct OpenQuickCompressWindowKey: EnvironmentKey { diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift index af17d266..255cd7a3 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift @@ -46,11 +46,14 @@ class ArchiveWindowManager { } /// Creates a new archive window and loads the archive from the given url if available - /// - Parameter url: url of the archive + /// - Parameters: + /// - url: url of the archive + /// - host: When given, the window opens as a tab of that one, whatever + /// Settings say about tabs: the user asked for a tab. /// - Returns: the window's `ArchiveState`, so callers that need to drive it /// (e.g. the debug screenshot launcher) can navigate and select afterwards. @discardableResult - fileprivate func createAndShowArchiveWindow(_ url: URL?) -> ArchiveState { + fileprivate func createAndShowArchiveWindow(_ url: URL?, asTabOf host: NSWindow? = nil) -> ArchiveState { log.notice("Creating window", context: ["url": url?.lastPathComponent ?? "(empty)"]) if !hasOpenedWindow { hasOpenedWindow = true @@ -86,12 +89,18 @@ class ArchiveWindowManager { openArchiveInNewWindow: { [weak self] url in self?.openDroppedInNewWindow(url) }, + openNewTab: { [weak self] in + self?.openNewTab() + }, cascadeFrom: modelWindow ) windowControllers.append(archiveWindowController) archiveWindowController.willCloseHandler = { [weak self] in self?.windowControllers.removeAll { $0 === archiveWindowController } } + if let host, let window = archiveWindowController.window, host !== window { + host.addTabbedWindow(window, ordered: .above) + } archiveWindowController.showWindow(nil) return archiveState } @@ -129,6 +138,12 @@ class ArchiveWindowManager { createAndShowArchiveWindow(nil) } + /// Opens the home screen in a new tab of the window in use — or in a + /// window of its own when none is open. + func openNewTab() { + createAndShowArchiveWindow(nil, asTabOf: modelWindow) + } + /// A file dropped on the "open in a new window" zone of a window that is /// already busy: an archive opens, anything else starts a new archive holding /// it — the same rule an empty window applies (`ArchiveState.openDropped`). diff --git a/MacPacker/Features/Settings/GeneralSettingsView.swift b/MacPacker/Features/Settings/GeneralSettingsView.swift index f5c96a9e..93839eba 100644 --- a/MacPacker/Features/Settings/GeneralSettingsView.swift +++ b/MacPacker/Features/Settings/GeneralSettingsView.swift @@ -17,6 +17,7 @@ struct GeneralSettingsView: View { @AppStorage(Keys.showColumnPosixPermissions) var showPermissions: Bool = false @AppStorage(Keys.showParentRow) var showParentRow: Bool = false @AppStorage(Keys.quitOnLastWindowClosed) var quitOnLastWindowClosed: Bool = false + @AppStorage(Keys.openArchivesInTabs) var openArchivesInTabs: Bool = true @AppStorage(Keys.showMenuBarItem) var showMenuBarItem: Bool = false @AppStorage(Keys.rememberRecentArchives) var rememberRecentArchives: Bool = true @AppStorage(Keys.smartExtraction, store: .macPackerShared) var smartExtraction: Bool = true @@ -85,6 +86,18 @@ struct GeneralSettingsView: View { .frame(width: 240, alignment: .leading) } + HStack(alignment: .top) { + Text(.settingsOpenArchivesInTabs) + .frame(width: 200, alignment: .trailing) + + HStack { + Toggle(isOn: $openArchivesInTabs) {} + } + .padding(.leading, 8) + .frame(width: 240, alignment: .leading) + } + .help(Text(.settingsOpenArchivesInTabsHint)) + HStack(alignment: .top) { Text(.settingsShowInMenuBar) .frame(width: 200, alignment: .trailing) diff --git a/MacPacker/Localizable.xcstrings b/MacPacker/Localizable.xcstrings index 46868947..40e05c38 100644 --- a/MacPacker/Localizable.xcstrings +++ b/MacPacker/Localizable.xcstrings @@ -2771,6 +2771,18 @@ } } }, + "archiveNewTabHint": { + "comment": "Tooltip of the toolbar's New Tab button.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the home screen in a new tab" + } + } + } + }, "archivePasswordMaxLengthHint": { "comment": "Save options: the zip password is too long; the number is the limit", "extractionState": "manual", @@ -11390,6 +11402,18 @@ } } }, + "commonNewTab": { + "comment": "Toolbar button and File menu item: opens MacPacker's home screen in a new tab of the window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Tab" + } + } + } + }, "commonNewWindow": { "comment": "A label for a button that opens a new window of the app. The placeholder will add the name MacPacker to it.", "extractionState": "manual", @@ -23706,6 +23730,30 @@ } } }, + "settingsOpenArchivesInTabs": { + "comment": "General setting: archives open as tabs of the front window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open archives in tabs" + } + } + } + }, + "settingsOpenArchivesInTabsHint": { + "comment": "Tooltip of the General setting that opens archives as tabs of the front window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archives open as tabs of the front window. Off, each opens in a window of its own, unless macOS is set to prefer tabs." + } + } + } + }, "settingsOpenCacheDirectory": { "comment": "Allows the user to open the application support folder that holds the cache for temporarly extracted archive files", "extractionState": "manual", diff --git a/MacPacker/MacPackerApp.swift b/MacPacker/MacPackerApp.swift index 186a278d..33f5a5a2 100644 --- a/MacPacker/MacPackerApp.swift +++ b/MacPacker/MacPackerApp.swift @@ -76,6 +76,17 @@ struct MacPackerApp: App { } .keyboardShortcut("n", modifiers: [.command, .shift]) + Button { + appDelegate.openNewTab() + } label: { + Label { + Text(.commonNewTab) + } icon: { + Image(systemName: "plus.square.on.square") + } + } + .keyboardShortcut("t", modifiers: [.command]) + Button { appDelegate.showDropWindow() } label: { diff --git a/Modules/Sources/Core/AppStorageKeys.swift b/Modules/Sources/Core/AppStorageKeys.swift index de564b58..f06578d9 100644 --- a/Modules/Sources/Core/AppStorageKeys.swift +++ b/Modules/Sources/Core/AppStorageKeys.swift @@ -11,6 +11,8 @@ public enum Keys { // general settings public static let settingBreadcrumbPosition = "settingBreadcrumbPosition" public static let quitOnLastWindowClosed = "quitOnLastWindowClosed" + /// Whether archives open as tabs of the front window. On by default. + public static let openArchivesInTabs = "openArchivesInTabs" /// Whether opened archives are remembered for the start page's Recent list /// (and the Dock's Recent Documents menu). On by default; turning it off also /// empties what was already collected — a history you can no longer see is one @@ -79,6 +81,7 @@ public enum Keys { defaultOrderColumnAscending: true, dropWindowFloats: true, rememberRecentArchives: true, + openArchivesInTabs: true, ]) } } diff --git a/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift b/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift new file mode 100644 index 00000000..c9a6c509 --- /dev/null +++ b/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift @@ -0,0 +1,23 @@ +// +// OpenArchivesInTabsTests.swift +// Modules +// +// Archives open as tabs of the front window unless the user turns it off in +// Settings ▸ General. +// + +import Foundation +import Testing +@testable import Core + +extension AllCoreTests { + struct OpenArchivesInTabsTests { + + /// What the app registers as it starts is what a first launch reads. + @Test func archivesOpenInTabsUntilTurnedOff() { + Keys.registerDefaults() + let registered = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) + #expect(registered[Keys.openArchivesInTabs] as? Bool == true) + } + } +} From b13ddc960aa57d5186d56696c5097554102cb4e9 Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:06:22 +0200 Subject: [PATCH 2/6] fix: New Window opens a window, and Open fills the tab in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: with "Open archives in tabs" on, File ▸ New Window opened a tab of the front window instead of a window. And with two empty tabs, an archive opened with File ▸ Open went into the first of them, not the one in view, which stayed empty. Now: - New Window opens as macOS's own "Prefer tabs" setting has it, as it did before "Open archives in tabs" existed: by default, a window. Archives opened later can still become its tabs. - An archive goes into the empty tab in view, when there is one, before any other empty window. --- .../ArchiveWindow/ArchiveWindowManager.swift | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift index 255cd7a3..290b2b98 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift @@ -50,10 +50,12 @@ class ArchiveWindowManager { /// - url: url of the archive /// - host: When given, the window opens as a tab of that one, whatever /// Settings say about tabs: the user asked for a tab. + /// - asWindow: The user asked for a window (File ▸ New Window): it opens + /// as macOS's own preference has it, whatever Settings say about tabs. /// - Returns: the window's `ArchiveState`, so callers that need to drive it /// (e.g. the debug screenshot launcher) can navigate and select afterwards. @discardableResult - fileprivate func createAndShowArchiveWindow(_ url: URL?, asTabOf host: NSWindow? = nil) -> ArchiveState { + fileprivate func createAndShowArchiveWindow(_ url: URL?, asTabOf host: NSWindow? = nil, asWindow: Bool = false) -> ArchiveState { log.notice("Creating window", context: ["url": url?.lastPathComponent ?? "(empty)"]) if !hasOpenedWindow { hasOpenedWindow = true @@ -101,10 +103,23 @@ class ArchiveWindowManager { if let host, let window = archiveWindowController.window, host !== window { host.addTabbedWindow(window, ordered: .above) } + // A window the user asked for goes by macOS's preference alone. Archives + // opened later can still become its tabs: they join it by its tabbing + // identifier, whatever its own mode. + if asWindow { + archiveWindowController.window?.tabbingMode = .automatic + } archiveWindowController.showWindow(nil) return archiveState } + /// An empty window to open an archive in: the one in use, when it is + /// empty — the New Tab just opened, say — else the first one. + private var emptyWindowController: ArchiveWindowController? { + let empty = windowControllers.filter { $0.archiveState.hasArchive == false } + return empty.first { $0.window?.isMainWindow == true } ?? empty.first + } + /// The window a new one models itself on. Falls back to the newest when no /// window is main (Finder's "Add to Archive…" keeps focus). Nil when none are /// open — the new window then uses the remembered frame. @@ -135,7 +150,7 @@ class ArchiveWindowManager { /// Opens a new empty window, regardless of whether there is any other window open right now func openNewArchiveWindow() { - createAndShowArchiveWindow(nil) + createAndShowArchiveWindow(nil, asWindow: true) } /// Opens the home screen in a new tab of the window in use — or in a @@ -193,7 +208,7 @@ class ArchiveWindowManager { private func showWindow(for url: URL) -> ArchiveState { if let wc = focusWindow(holding: url) { return wc.archiveState - } else if let ewc = windowControllers.first(where: { $0.archiveState.hasArchive == false }) { + } else if let ewc = emptyWindowController { log.notice("Reusing empty window to open \(url.lastPathComponent)") ewc.archiveState.open(url: url) ewc.showWindow(nil) From 71f3db3521471b2662a2e4200b7fe4b8c50eadc2 Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:34:55 +0200 Subject: [PATCH 3/6] docs: take the changelog items out of the released 1.1.0 Before: this pull request added its changelog items to the 1.1.0 block of Config/products/macpacker.json. 1.1.0 was released on 19 October, so that block takes no new items. Now: macpacker.json is as on main. The full items are in the pull request description, for a new version block, as AGENTS.md asks. --- Config/products/macpacker.json | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/Config/products/macpacker.json b/Config/products/macpacker.json index fe3ebeac..f7e661ab 100644 --- a/Config/products/macpacker.json +++ b/Config/products/macpacker.json @@ -98,35 +98,6 @@ "132" ] }, - { - "type": "feat", - "title": { - "en": "Open archives in tabs", - "zh-Hans": "在标签页中打开归档", - "fr": "Ouvrir les archives dans des onglets", - "de": "Archive in Tabs öffnen", - "it": "Aprire gli archivi in schede", - "ja": "アーカイブをタブで開く", - "fa": "باز کردن آرشیوها در زبانه‌ها", - "pl": "Otwieranie archiwów w kartach", - "pt-BR": "Abrir arquivos em abas", - "ru": "Открытие архивов во вкладках", - "uk": "Відкриття архівів у вкладках", - "es-MX": "Abrir archivos en pestañas", - "ko": "아카이브를 탭으로 열기", - "nl": "Archieven openen in tabbladen", - "tr": "Arşivleri sekmelerde açma", - "vi": "Mở tệp nén trong các tab", - "ar": "فتح الأرشيفات في علامات تبويب", - "ca": "Obre els arxius en pestanyes", - "el": "Άνοιγμα συμπιεσμένων αρχείων σε καρτέλες", - "nb": "Åpne arkiver i faner", - "zh-TW": "以分頁開啟壓縮檔" - }, - "issues": [ - "350" - ] - }, { "type": "fix", "highlight": true, From 0c81e3ca0cb1a38049b131bf44a3a6558b0bd520 Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:40:17 +0200 Subject: [PATCH 4/6] fix: keep stale separators out of the tab bar On macOS 27 (seen on 27.0.1), adding or closing a tab in the active window left grey separators behind in the tab bar: stamped trails while the bar animated, and some that blinked afterwards. The tab bar draws them over the title bar's own background while a scroll view runs beneath it, as the home page's does in this full-size window. On macOS 26 and later the archive window now draws no title bar background, and the home page's scroll view stops a point below the bars; that point comes off the page's top padding, so the page stays where it was. At rest the window looks the same. A home page that has to scroll, in a short window, now stops at the tab bar's edge instead of sliding under the bars. Older macOS versions are unchanged. --- .../Features/ArchiveWindow/ArchiveWindow.swift | 9 +++++++++ MacPacker/Features/Home/HomeView.swift | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift index a6068014..60ac0e6f 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift @@ -66,6 +66,15 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { window.tabbingMode = UserDefaults.standard.bool(forKey: Keys.openArchivesInTabs) ? .preferred : .automatic window.toolbarStyle = .unified + // On macOS 26 and later, no title bar background of its own: the tab + // bar over that background, while a scroll view runs under it, leaves + // stale separators behind each time a tab is added or closed (seen on + // macOS 27). The content already fills the window behind the bars, and + // the home page stops short of them (HomeView), so nothing has to be + // hidden there. + if #available(macOS 26, *) { + window.titlebarAppearsTransparent = true + } // show the content view let contentView = ContentView() diff --git a/MacPacker/Features/Home/HomeView.swift b/MacPacker/Features/Home/HomeView.swift index eb7a2c47..d312495c 100644 --- a/MacPacker/Features/Home/HomeView.swift +++ b/MacPacker/Features/Home/HomeView.swift @@ -56,6 +56,18 @@ struct HomeView: View { private let maxRecents = 4 + /// On macOS 26 and later the page stops this far below the toolbar and tab + /// bar, so it never scrolls under them: the window draws no title bar + /// background there to hide it (see `ArchiveWindowController`). The page's + /// own top padding gives the point back, so nothing moves. + private static var clearanceBelowBars: CGFloat { + if #available(macOS 26, *) { + return 1 + } else { + return 0 + } + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 36) { @@ -78,10 +90,11 @@ struct HomeView: View { // room to breathe, but the whole page still has to clear a 500pt window // (less toolbar, tab bar and status bar) without scrolling .padding(.horizontal, 32) - .padding(.top, 24) + .padding(.top, 24 - Self.clearanceBelowBars) .padding(.bottom, 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .padding(.top, Self.clearanceBelowBars) .onAppear { recents = Array(RecentArchives.urls.prefix(maxRecents)) #if DEBUG From e92bc4689aa4c4d910681bc9e98187abc476613d Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:49:14 +0200 Subject: [PATCH 5/6] fix: keep the bar behind the toolbar and tab bar in every path bar position Before: with the title bar made transparent to keep stale separators out of the tab bar (macOS 26 and later), the bar behind the toolbar and tab bar was gone with the path bar at the bottom (the default) or hidden: the bars sat over the bare window. With the path bar at the top it showed, by accident and in a flat grey: the path bar's fill reached up under the bars. Now: the window's content draws that bar itself, with SwiftUI's bar material, which looks the same as the system's own title bar background in dark and light mode, in every path bar position and on the home screen. With the path bar at the top, the path bar takes the same material and reads as part of the bar. A fill drawn by the content doesn't bring the stale separators back. macOS 15 and earlier are unchanged. --- .../ArchiveContentViewer/BreadcrumbView.swift | 22 ++++++++++++++-- .../ArchiveContentViewer/ContentView.swift | 25 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift b/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift index a595fc09..7c5e1a4b 100644 --- a/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift +++ b/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift @@ -51,9 +51,12 @@ struct BreadcrumbView: View { @EnvironmentObject private var archiveState: ArchiveState private let selectedItem: ArchiveItem + /// At the top of the window, right under the toolbar and tab bar. + private let underBars: Bool - init(for selectedItem: ArchiveItem) { + init(for selectedItem: ArchiveItem, underBars: Bool = false) { self.selectedItem = selectedItem + self.underBars = underBars } private var items: [ArchiveItem] { @@ -97,6 +100,21 @@ struct BreadcrumbView: View { } .padding(.horizontal, 8) .padding(.vertical, 2) - .background(Color(NSColor.controlBackgroundColor)) + .modifier(PathBarBackground(underBars: underBars)) + } +} + +/// Right under the bars, on macOS 26 and later, the path bar is part of the band +/// behind them (see `BarsBand`): the same material, reaching up under them. +/// Anywhere else it keeps its own fill, which stays out from under the bars. +private struct PathBarBackground: ViewModifier { + let underBars: Bool + + func body(content: Content) -> some View { + if underBars, #available(macOS 26, *) { + content.background(.bar, ignoresSafeAreaEdges: .top) + } else { + content.background(Color(NSColor.controlBackgroundColor), ignoresSafeAreaEdges: BarsBand.edgesBelowIt) + } } } diff --git a/MacPacker/Features/ArchiveContentViewer/ContentView.swift b/MacPacker/Features/ArchiveContentViewer/ContentView.swift index 5e30221b..ca2b98a5 100644 --- a/MacPacker/Features/ArchiveContentViewer/ContentView.swift +++ b/MacPacker/Features/ArchiveContentViewer/ContentView.swift @@ -9,6 +9,16 @@ import AppKit import Core import SwiftUI +/// On macOS 26 and later `ContentView` draws the band behind the toolbar and +/// tab bar itself; a fill at the top of the window stays out from under the +/// bars, or it would cover that band. Before, as SwiftUI does by default, it +/// reaches up under them. +enum BarsBand { + static var edgesBelowIt: Edge.Set { + if #available(macOS 26, *) { [] } else { .all } + } +} + struct ContentView: View { // settings @AppStorage(Keys.settingBreadcrumbPosition) var breadcrumbPosition: BreadcrumbPosition = .bottom @@ -25,14 +35,25 @@ struct ContentView: View { var body: some View { VStack(spacing: 0) { + // The band behind the toolbar and tab bar, in every path bar position + // and on the home screen too. On macOS 26 and later the window draws no + // title bar background of its own (see `ArchiveWindowController`); the + // bar material drawn here looks the same as that background, and brings + // none of the stale tab separators back. + if #available(macOS 26, *) { + Color.clear + .frame(height: 0) + .background(.bar, ignoresSafeAreaEdges: .top) + } + if breadcrumbPosition == .top { if let selectedItem = archiveState.selectedItem { - BreadcrumbView(for: selectedItem) + BreadcrumbView(for: selectedItem, underBars: true) } Divider() .frame(height: 1) - .background(.quinary) + .background(.quinary, ignoresSafeAreaEdges: BarsBand.edgesBelowIt) } ArchiveView() From 2a8d66d798f0167adce353602f63bafc4d9e6a2f Mon Sep 17 00:00:00 2001 From: hertza-haeon <336921677+hertza-haeon@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:05:56 +0200 Subject: [PATCH 6/6] fix: macOS's own title bar again; the home screen clears the bars only with a tab bar Before: to keep stale separators out of the tab bar, the title bar was transparent on macOS 26 and later, with a band drawn behind the bars. That cost two things the release has: a press on the title of an inactive window didn't move it (and on an active one only now and then), and the home screen's bar no longer lit up when the pointer entered it. Now: the title bar is macOS's own again and the band is gone. The stale separators need the title bar's background behind the tab bar, which a scroll view under the bars brings out, so while a window shows its tab bar the home screen stops 1 pt below the bars, its own top padding giving the point back. A window without a tab bar behaves as in the release. Each window follows its tab group's isTabBarVisible; a new tab and its host know the bar is coming before it slides in, and the home screen goes back under the bars only once the closing animation is over. macOS 15 and earlier are unchanged. --- .../ArchiveContentViewer/BreadcrumbView.swift | 22 +------ .../ArchiveContentViewer/ContentView.swift | 25 +------ .../ArchiveWindow/ArchiveWindow.swift | 66 ++++++++++++++++--- .../ArchiveWindow/ArchiveWindowManager.swift | 3 + MacPacker/Features/Home/HomeView.swift | 31 +++++---- 5 files changed, 82 insertions(+), 65 deletions(-) diff --git a/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift b/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift index 7c5e1a4b..a595fc09 100644 --- a/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift +++ b/MacPacker/Features/ArchiveContentViewer/BreadcrumbView.swift @@ -51,12 +51,9 @@ struct BreadcrumbView: View { @EnvironmentObject private var archiveState: ArchiveState private let selectedItem: ArchiveItem - /// At the top of the window, right under the toolbar and tab bar. - private let underBars: Bool - init(for selectedItem: ArchiveItem, underBars: Bool = false) { + init(for selectedItem: ArchiveItem) { self.selectedItem = selectedItem - self.underBars = underBars } private var items: [ArchiveItem] { @@ -100,21 +97,6 @@ struct BreadcrumbView: View { } .padding(.horizontal, 8) .padding(.vertical, 2) - .modifier(PathBarBackground(underBars: underBars)) - } -} - -/// Right under the bars, on macOS 26 and later, the path bar is part of the band -/// behind them (see `BarsBand`): the same material, reaching up under them. -/// Anywhere else it keeps its own fill, which stays out from under the bars. -private struct PathBarBackground: ViewModifier { - let underBars: Bool - - func body(content: Content) -> some View { - if underBars, #available(macOS 26, *) { - content.background(.bar, ignoresSafeAreaEdges: .top) - } else { - content.background(Color(NSColor.controlBackgroundColor), ignoresSafeAreaEdges: BarsBand.edgesBelowIt) - } + .background(Color(NSColor.controlBackgroundColor)) } } diff --git a/MacPacker/Features/ArchiveContentViewer/ContentView.swift b/MacPacker/Features/ArchiveContentViewer/ContentView.swift index ca2b98a5..5e30221b 100644 --- a/MacPacker/Features/ArchiveContentViewer/ContentView.swift +++ b/MacPacker/Features/ArchiveContentViewer/ContentView.swift @@ -9,16 +9,6 @@ import AppKit import Core import SwiftUI -/// On macOS 26 and later `ContentView` draws the band behind the toolbar and -/// tab bar itself; a fill at the top of the window stays out from under the -/// bars, or it would cover that band. Before, as SwiftUI does by default, it -/// reaches up under them. -enum BarsBand { - static var edgesBelowIt: Edge.Set { - if #available(macOS 26, *) { [] } else { .all } - } -} - struct ContentView: View { // settings @AppStorage(Keys.settingBreadcrumbPosition) var breadcrumbPosition: BreadcrumbPosition = .bottom @@ -35,25 +25,14 @@ struct ContentView: View { var body: some View { VStack(spacing: 0) { - // The band behind the toolbar and tab bar, in every path bar position - // and on the home screen too. On macOS 26 and later the window draws no - // title bar background of its own (see `ArchiveWindowController`); the - // bar material drawn here looks the same as that background, and brings - // none of the stale tab separators back. - if #available(macOS 26, *) { - Color.clear - .frame(height: 0) - .background(.bar, ignoresSafeAreaEdges: .top) - } - if breadcrumbPosition == .top { if let selectedItem = archiveState.selectedItem { - BreadcrumbView(for: selectedItem, underBars: true) + BreadcrumbView(for: selectedItem) } Divider() .frame(height: 1) - .background(.quinary, ignoresSafeAreaEdges: BarsBand.edgesBelowIt) + .background(.quinary) } ArchiveView() diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift index 60ac0e6f..27faec79 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift @@ -24,6 +24,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { private let openNewTab: @MainActor @Sendable () -> Void private var cancellables = Set() + + /// Whether this window shows its tab bar (see `followTabBar`). + let tabBarPresence = TabBarPresence() + private weak var followedTabGroup: NSWindowTabGroup? + private var tabBarObservation: NSKeyValueObservation? init( archiveState: ArchiveState, @@ -66,15 +71,6 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { window.tabbingMode = UserDefaults.standard.bool(forKey: Keys.openArchivesInTabs) ? .preferred : .automatic window.toolbarStyle = .unified - // On macOS 26 and later, no title bar background of its own: the tab - // bar over that background, while a scroll view runs under it, leaves - // stale separators behind each time a tab is added or closed (seen on - // macOS 27). The content already fills the window behind the bars, and - // the home page stops short of them (HomeView), so nothing has to be - // hidden there. - if #available(macOS 26, *) { - window.titlebarAppearsTransparent = true - } // show the content view let contentView = ContentView() @@ -86,7 +82,8 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { .environment(\.openNewTab, openNewTab) .environment(\.openQuickCompressWindow, openQuickCompressWindow) - window.contentView = NSHostingView(rootView: contentView) + window.contentView = NSHostingView(rootView: TabBarAware(presence: tabBarPresence, content: contentView)) + followTabBar() // native unsaved-changes affordance: the close button shows a dot while // there are pending edits (mirrors NSDocument's isDocumentEdited) @@ -181,6 +178,55 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { archiveState.clean() willCloseHandler?() } + + func windowDidBecomeKey(_ notification: Notification) { followTabBar() } + + /// Keeps `tabBarPresence` in step with the tab bar of this window's tab + /// group. A window changes group when a tab is dragged out or windows are + /// merged, so the group is looked up again whenever the window becomes key. + private func followTabBar() { + guard let group = window?.tabGroup, group !== followedTabGroup else { return } + followedTabGroup = group + tabBarObservation = group.observe(\.isTabBarVisible, options: [.initial, .new]) { [weak self] group, _ in + let shown = group.isTabBarVisible + MainActor.assumeIsolated { self?.tabBarPresence.show(shown) } + } + } +} + +/// Whether a window shows its tab bar, for the home screen (see +/// `HomeView.clearanceBelowBars`). It shows at once, and hides only once the +/// tab bar's closing animation is over, so the page doesn't run under the bars +/// while the last separators are still moving. +@MainActor +final class TabBarPresence: ObservableObject { + @Published private(set) var isShown = false + private var pendingHide: Task? + + func show(_ shown: Bool) { + pendingHide?.cancel() + pendingHide = nil + if shown { + if !isShown { isShown = true } + return + } + guard isShown else { return } + pendingHide = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + self?.isShown = false + } + } +} + +/// Hands the window's tab bar state down to its content. +private struct TabBarAware: View { + @ObservedObject var presence: TabBarPresence + let content: Content + + var body: some View { + content.environment(\.windowShowsTabBar, presence.isShown) + } } class ArchiveWindow: NSWindow { diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift index 290b2b98..a603c160 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift @@ -101,6 +101,9 @@ class ArchiveWindowManager { self?.windowControllers.removeAll { $0 === archiveWindowController } } if let host, let window = archiveWindowController.window, host !== window { + // both know the tab bar is coming before it slides in + (host.windowController as? ArchiveWindowController)?.tabBarPresence.show(true) + archiveWindowController.tabBarPresence.show(true) host.addTabbedWindow(window, ordered: .above) } // A window the user asked for goes by macOS's preference alone. Archives diff --git a/MacPacker/Features/Home/HomeView.swift b/MacPacker/Features/Home/HomeView.swift index d312495c..0b679aec 100644 --- a/MacPacker/Features/Home/HomeView.swift +++ b/MacPacker/Features/Home/HomeView.swift @@ -31,6 +31,11 @@ enum HomeLayout { static let sectionHeaderHeight: CGFloat = 22 } +extension EnvironmentValues { + /// Whether the window shows its tab bar (see `TabBarPresence`). + @Entry var windowShowsTabBar = false +} + struct HomeView: View { @EnvironmentObject private var state: ArchiveState @EnvironmentObject private var compressor: DropCompressor @@ -56,16 +61,18 @@ struct HomeView: View { private let maxRecents = 4 - /// On macOS 26 and later the page stops this far below the toolbar and tab - /// bar, so it never scrolls under them: the window draws no title bar - /// background there to hide it (see `ArchiveWindowController`). The page's - /// own top padding gives the point back, so nothing moves. - private static var clearanceBelowBars: CGFloat { - if #available(macOS 26, *) { - return 1 - } else { - return 0 - } + @Environment(\.windowShowsTabBar) private var windowShowsTabBar + + /// While the window shows its tab bar, on macOS 26 and later, the page + /// stops this far below the toolbar and tab bar. Running under them, it + /// brings out the title bar's background, and over that background the tab + /// bar leaves stale separators behind each time a tab is added or closed + /// (seen on macOS 27). Without a tab bar it runs under them as before, and + /// the bar lights up when the pointer enters it. The page's own top padding + /// gives the point back, so nothing moves. + private var clearanceBelowBars: CGFloat { + if #available(macOS 26, *), windowShowsTabBar { return 1 } + return 0 } var body: some View { @@ -90,11 +97,11 @@ struct HomeView: View { // room to breathe, but the whole page still has to clear a 500pt window // (less toolbar, tab bar and status bar) without scrolling .padding(.horizontal, 32) - .padding(.top, 24 - Self.clearanceBelowBars) + .padding(.top, 24 - clearanceBelowBars) .padding(.bottom, 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .padding(.top, Self.clearanceBelowBars) + .padding(.top, clearanceBelowBars) .onAppear { recents = Array(RecentArchives.urls.prefix(maxRecents)) #if DEBUG