Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MacPacker/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
self.archiveWindowManager?.openNewArchiveWindow()
}

func openNewTab() {
self.archiveWindowManager?.openNewTab()
}

func openCreateArchiveWindow() {
self.archiveWindowManager?.openCreateArchiveWindow()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
90 changes: 89 additions & 1 deletion MacPacker/Features/ArchiveWindow/ArchiveWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ 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<AnyCancellable>()

/// Whether this window shows its tab bar (see `followTabBar`).
let tabBarPresence = TabBarPresence()
private weak var followedTabGroup: NSWindowTabGroup?
private var tabBarObservation: NSKeyValueObservation?

init(
archiveState: ArchiveState,
Expand All @@ -30,9 +37,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
Expand All @@ -56,6 +65,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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

window.toolbarStyle = .unified

// show the content view
Expand All @@ -65,9 +79,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate {
.environmentObject(dropCompressor)
.environmentObject(quickCompressOptions)
.environment(\.openArchiveInNewWindow, openArchiveInNewWindow)
.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)
Expand All @@ -76,6 +92,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) {
Expand Down Expand Up @@ -152,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<Void, Never>?

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<Content: View>: View {
@ObservedObject var presence: TabBarPresence
let content: Content

var body: some View {
content.environment(\.windowShowsTabBar, presence.isShown)
}
}

class ArchiveWindow: NSWindow {
Expand Down Expand Up @@ -211,6 +286,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 {
Expand Down
41 changes: 37 additions & 4 deletions MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ 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.
/// - 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?) -> 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
Expand Down Expand Up @@ -86,16 +91,38 @@ 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 {
// 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
// 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.
Expand Down Expand Up @@ -126,7 +153,13 @@ 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
/// window of its own when none is open.
func openNewTab() {
createAndShowArchiveWindow(nil, asTabOf: modelWindow)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// A file dropped on the "open in a new window" zone of a window that is
Expand Down Expand Up @@ -178,7 +211,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)
Expand Down
22 changes: 21 additions & 1 deletion MacPacker/Features/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,6 +61,20 @@ struct HomeView: View {

private let maxRecents = 4

@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 {
ScrollView {
VStack(alignment: .leading, spacing: 36) {
Expand All @@ -78,10 +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)
.padding(.top, 24 - clearanceBelowBars)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.padding(.top, clearanceBelowBars)
.onAppear {
recents = Array(RecentArchives.urls.prefix(maxRecents))
#if DEBUG
Expand Down
13 changes: 13 additions & 0 deletions MacPacker/Features/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading