diff --git a/Benchmarks/performance/LaunchBenchmark.swift b/Benchmarks/performance/LaunchBenchmark.swift
index 8b2daf9..81edb81 100644
--- a/Benchmarks/performance/LaunchBenchmark.swift
+++ b/Benchmarks/performance/LaunchBenchmark.swift
@@ -221,10 +221,20 @@ private func measureOnce(options: Options) throws -> Sample {
let process = Process()
process.executableURL = executable
process.arguments = [options.documentPath]
+ let preferencesSuite = "ink.margin.editor.benchmark.\(UUID().uuidString)"
let readyURL = FileManager.default.temporaryDirectory
.appendingPathComponent("margin-ready-\(UUID().uuidString)", isDirectory: false)
+ let preferencesRoot = FileManager.default.temporaryDirectory
+ .appendingPathComponent("margin-benchmark-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(
+ at: preferencesRoot.appendingPathComponent("Library/Preferences", isDirectory: true),
+ withIntermediateDirectories: true
+ )
process.environment = ProcessInfo.processInfo.environment.merging([
+ "CFFIXED_USER_HOME": preferencesRoot.path,
+ "CFPREFERENCES_AVOID_DAEMON": "1",
"MARGIN_BENCHMARK_READY_FILE": readyURL.path,
+ "MARGIN_PREFERENCES_SUITE": preferencesSuite,
]) { _, benchmarkValue in benchmarkValue }
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
@@ -237,7 +247,17 @@ private func measureOnce(options: Options) throws -> Sample {
}
defer {
stop(process)
+ if let benchmarkDefaults = UserDefaults(suiteName: preferencesSuite) {
+ benchmarkDefaults.removePersistentDomain(forName: preferencesSuite)
+ benchmarkDefaults.synchronize()
+ }
+ let preferencesFile = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Preferences", isDirectory: true)
+ .appendingPathComponent(preferencesSuite)
+ .appendingPathExtension("plist")
+ try? FileManager.default.removeItem(at: preferencesFile)
try? FileManager.default.removeItem(at: readyURL)
+ try? FileManager.default.removeItem(at: preferencesRoot)
}
let timeout = UInt64(options.timeoutMilliseconds) * 1_000_000
diff --git a/DESIGN.md b/DESIGN.md
index fefc20b..eeaea61 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -18,11 +18,12 @@ All panes use standard AppKit split-view behavior, system materials, native focu
- A new or comment-free document begins with the source alone. The comment inspector opens automatically only when a document already contains review threads or the user starts a comment.
- Standalone files hide the navigator; directory workspaces reveal it. Closing either sidebar gives its space to the document without changing the outer window frame.
-- The initial window uses a generous 1180 × 780 working size when the screen permits. Later launches restore only a usable, on-screen frame, and the document pane remains the elastic region during live resize and full screen.
-- Opening several files—through the Open panel, Finder, or the CLI—places them in native tabs. An empty tab is reused, duplicate paths focus their existing tab, and separate windows remain available with `⌘N`.
+- The initial window uses a generous 1180 × 780 working size when the screen permits. Later launches restore each window to its prior physical display when available, with a usable on-screen fallback when a display is disconnected; the document pane remains the elastic region during live resize and full screen.
+- Opening several files—through the Open panel, Finder, or the CLI—places them in native tabs. An empty start tab is reused, duplicate paths focus their existing tab, and separate windows remain available.
+- `⌘T` creates an editable, automatically saved untitled Markdown scratchpad in the current tab group. `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to an ordinary named Markdown file without changing its tab position.
- Tabs follow browser conventions: `⌘T`, `⌘W`, `⌃Tab`, `⌃⇧Tab`, and `⌘1`…`⌘9`. Pane focus uses `⌃1`…`⌃3` so it never competes with tab selection.
- Reader presentation and file-provider change watching begin only after they are requested, away from the main interaction path. Stale reader work is discarded when the user switches files or modes.
-- A normal relaunch restores tabs and lightweight view state, but an explicit CLI target always wins. Persistence is deferred to a utility queue and never joins the first-window path.
+- A normal relaunch restores independent windows, their physical displays, native tab groups and order, the selected window and tab, usable window frames, scratchpad contents, and lightweight editor state. An explicit CLI target always wins. Session persistence is deferred to a utility queue, and scratchpads remain ordinary private Markdown files rather than database records.
- New comments or replies arriving from another process never steal focus or open the inspector. They add a restrained numeric tab/toolbar signal and a temporary **New** filter until the affected thread is deliberately visited.
## Typography and color
diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md
index bd93d93..187ae15 100644
--- a/Docs/ARCHITECTURE.md
+++ b/Docs/ARCHITECTURE.md
@@ -102,10 +102,18 @@ directory indexing, filesystem watching, comparison loading, collaboration
overviews, and stage presentation are created only when requested so they do
not join the initial window path.
-The application stores only lightweight local presentation state such as open
-tabs, pane visibility, selection, scroll position, and the active thread. An
-explicit file or directory supplied by the user takes precedence over session
-restoration.
+The application stores only lightweight local presentation state such as the
+independent-window and native-tab hierarchy, pane visibility, selection, scroll
+position, display affinity, and the active thread. Stable native restoration
+identifiers let AppKit preserve system-managed window state; custom placement
+metadata provides physical-display continuity and an on-screen fallback when a
+display is unavailable. Untitled scratchpads are ordinary Markdown
+files below the user's private Application Support directory; the session holds
+only their stable identity and presentation state. Scratch contents use the
+same atomic autosave path as named documents, are flushed synchronously during
+a normal quit, and move to the selected destination only after Save As
+succeeds. An explicit file or directory supplied by the user takes precedence
+over session restoration.
## CLI contract
diff --git a/Docs/RELEASE_NOTES.md b/Docs/RELEASE_NOTES.md
index 3c7ba1c..b7fb0b9 100644
--- a/Docs/RELEASE_NOTES.md
+++ b/Docs/RELEASE_NOTES.md
@@ -2,7 +2,22 @@
## Unreleased
-No changes yet.
+- On current macOS, independent windows now return to their previous Spaces
+ without pulling the user away from the active Space during cold launch. Once
+ running, ordinary Space changes perform no window creation or activation;
+ deliberate Dock or Finder activation follows native macOS app switching.
+- Added durable untitled Markdown scratchpads. `⌘T` creates one in the current
+ tab group, `⌘N` creates one in a separate window, and `⌘⇧S` promotes it to a
+ named file. Scratch contents are private file-backed Markdown, autosave while
+ editing, flush synchronously on quit, and require an explicit Save As or
+ discard decision before a nonempty scratchpad tab closes.
+- Expanded session restoration from one flattened tab list to the complete
+ independent-window and native-tab hierarchy, including tab order, selected
+ tabs, usable window frames, cursor and scroll continuity, and open
+ scratchpads. Existing version-1 sessions migrate to a single restored window.
+- Integrated the durable session with native AppKit window restoration.
+ Physical-display identity and relative placement are retained, with an
+ on-screen fallback when a monitor is disconnected or its layout changes.
## 0.5.1 — native reading and review fixes
diff --git a/NOTICE b/NOTICE
index 8733567..7c1eb9e 100644
--- a/NOTICE
+++ b/NOTICE
@@ -6,3 +6,27 @@ This product includes software developed by OpenProse, Inc.
The Apache License, Version 2.0 does not grant permission to use the Margin or
OpenProse trade names, trademarks, service marks, or product names except as
required for reasonable and customary use in describing the origin of the work.
+
+This product includes software adapted from yabai:
+
+The MIT License (MIT)
+
+Copyright (c) 2019 Åsmund Vikane
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.swift b/Package.swift
index 8f7976c..11a46fe 100644
--- a/Package.swift
+++ b/Package.swift
@@ -31,10 +31,20 @@ var targets: [Target] = [
#if os(macOS)
products.append(.executable(name: "MarginAppBinary", targets: ["MarginApp"]))
+targets.append(
+ .target(
+ name: "MarginPrivateSupport",
+ path: "Sources/MarginPrivateSupport",
+ publicHeadersPath: "include",
+ linkerSettings: [
+ .linkedFramework("CoreFoundation"),
+ ]
+ )
+)
targets.append(
.executableTarget(
name: "MarginApp",
- dependencies: ["MarginCore"],
+ dependencies: ["MarginCore", "MarginPrivateSupport"],
path: "Sources/MarginApp"
)
)
diff --git a/README.md b/README.md
index f5ce029..b84f42b 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,8 @@ Open a file for quick editing or a directory for a native, lazily loaded navigat
In the app you can:
+- create an automatically saved untitled Markdown scratchpad with `⌘T`, or a scratchpad in a separate window with `⌘N`, then promote it to an ordinary file with `⌘⇧S`;
+- return after a normal quit to the same independent windows and displays, tab groups, selected tabs, cursor positions, and scroll positions;
- edit literal Markdown with restrained syntax cues, delimiter pairing, list continuation, native undo, find, spellcheck, and accessibility support;
- switch to a bounded, typography-first reader view with `⌘⇧R`;
- select a passage and comment from the inline affordance, context menu, or `⌘⌥M`;
diff --git a/Resources/Info.plist b/Resources/Info.plist
index c601b9b..070bc7f 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -32,6 +32,8 @@
Copyright © 2026 OpenProse, Inc.
NSPrincipalClass
NSApplication
+ LSUIElement
+
CFBundleDocumentTypes
diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift
index 09ec11b..9752056 100644
--- a/Sources/MarginApp/AppDelegate.swift
+++ b/Sources/MarginApp/AppDelegate.swift
@@ -1,6 +1,63 @@
import AppKit
+import ColorSync
import MarginCore
+enum MarginRuntimeEnvironment {
+ static var isPerformanceBenchmark: Bool {
+ ProcessInfo.processInfo.environment["MARGIN_BENCHMARK_READY_FILE"] != nil
+ }
+}
+
+enum WorkspaceRestorationIdentifier {
+ private static let prefix = "ink.margin.workspace."
+
+ static func make(_ value: String) -> NSUserInterfaceItemIdentifier {
+ NSUserInterfaceItemIdentifier(prefix + value)
+ }
+}
+
+enum WorkspaceScreenIdentity {
+ static func identifier(for screen: NSScreen?) -> String? {
+ guard let number = screen?.deviceDescription[
+ NSDeviceDescriptionKey("NSScreenNumber")
+ ] as? NSNumber else { return nil }
+ let displayID = CGDirectDisplayID(number.uint32Value)
+ guard let displayUUID = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue(),
+ let identifier = CFUUIDCreateString(nil, displayUUID)
+ else {
+ return "display-\(displayID)"
+ }
+ return identifier as String
+ }
+}
+
+enum WorkspaceLaunchAction: Equatable {
+ case focusRestoredWindow(Int)
+ case createEmptyWindow
+}
+
+enum WorkspaceLaunchPolicy {
+ static func action(
+ previouslySelectedIndex: Int,
+ restoredWindowsOnActiveSpace: [Bool]
+ ) -> WorkspaceLaunchAction {
+ if restoredWindowsOnActiveSpace.indices.contains(previouslySelectedIndex),
+ restoredWindowsOnActiveSpace[previouslySelectedIndex] {
+ return .focusRestoredWindow(previouslySelectedIndex)
+ }
+ if let localIndex = restoredWindowsOnActiveSpace.firstIndex(of: true) {
+ return .focusRestoredWindow(localIndex)
+ }
+ return .createEmptyWindow
+ }
+}
+
+private struct WorkspaceBackgroundReveal {
+ let windows: [NSWindow]
+ let selectedWindow: NSWindow
+ let assignment: WorkspaceSpaceAssignment?
+}
+
protocol WorkspacePathRenameParticipating: AnyObject {
var documentURLForPathRename: URL? { get }
func prepareForPathRename(from sourceURL: URL) -> Bool
@@ -138,18 +195,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private var comparisonPickerController: NavigationPaletteController?
private var pendingURLs: [URL] = []
private var didFinishLaunching = false
- private let sessionStore = WorkspaceSessionStore()
+ private let sessionStore: WorkspaceSessionStore
+ private let scratchpadStore: ScratchpadStore
+ private let recentWorkspaceStore: RecentWorkspaceStore
+ private let shouldPersistSession: Bool
private let sessionPersistenceQueue = DispatchQueue(
label: "ink.margin.session-persistence",
qos: .utility
)
private var isRestoringSession = false
private var isSessionPersistScheduled = false
+ private var terminationSession: WorkspaceSession?
+ private lazy var spaceRestorationBackend = WorkspaceSpaceRestorationBackendFactory.make()
+
+ override init() {
+ let defaults = Self.runtimeDefaults()
+ let shouldPersistSession = !MarginRuntimeEnvironment.isPerformanceBenchmark
+ sessionStore = WorkspaceSessionStore(defaults: defaults)
+ scratchpadStore = ScratchpadStore(defaults: defaults)
+ recentWorkspaceStore = RecentWorkspaceStore(
+ defaults: defaults,
+ persistenceEnabled: shouldPersistSession
+ )
+ self.shouldPersistSession = shouldPersistSession
+ super.init()
+ }
+
+ init(
+ sessionStore: WorkspaceSessionStore,
+ scratchpadStore: ScratchpadStore,
+ recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore(),
+ shouldPersistSession: Bool = true
+ ) {
+ self.sessionStore = sessionStore
+ self.scratchpadStore = scratchpadStore
+ self.recentWorkspaceStore = recentWorkspaceStore
+ self.shouldPersistSession = shouldPersistSession
+ super.init()
+ }
+
+ private static func runtimeDefaults() -> UserDefaults {
+ guard let suiteName = ProcessInfo.processInfo.environment[
+ "MARGIN_PREFERENCES_SUITE"
+ ],
+ !suiteName.isEmpty,
+ let defaults = UserDefaults(suiteName: suiteName) else {
+ return .standard
+ }
+ return defaults
+ }
func applicationWillFinishLaunching(_ notification: Notification) {
WorkspacePaneFactory.makeEditor = { EditorViewController() }
WorkspacePaneFactory.makeComments = { CommentsViewController() }
AppMenu.install(for: NSApplication.shared, delegate: self)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowScreenDidChange(_:)),
+ name: NSWindow.didChangeScreenNotification,
+ object: nil
+ )
}
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -157,18 +262,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
let startupURLs = pendingURLs.isEmpty ? commandLineURLs() : pendingURLs
pendingURLs.removeAll()
+ var defersActivationUntilRestorationCompletes = false
if startupURLs.isEmpty, restoreLastSession() {
- // The first restored tab is visible immediately. Remaining tabs
- // attach on the next run-loop turn so session continuity never
- // delays the first usable window.
+ // Rebuild the complete native tab graph before revealing it so
+ // each independent window can return to its saved Space cleanly.
+ defersActivationUntilRestorationCompletes = true
} else if startupURLs.isEmpty {
makeWorkspaceWindow(for: nil)
} else {
open(startupURLs)
}
- NSApplication.shared.activate(ignoringOtherApps: true)
+ if !defersActivationUntilRestorationCompletes {
+ activatePreparedApplication()
+ }
+ }
+
+ func applicationShouldRestoreApplicationState(_ sender: NSApplication) -> Bool {
+ false
+ }
+
+ func applicationShouldSaveApplicationState(_ sender: NSApplication) -> Bool {
+ false
}
func application(_ application: NSApplication, open urls: [URL]) {
@@ -193,12 +309,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
true
}
+ func applicationShouldHandleReopen(
+ _ _: NSApplication,
+ hasVisibleWindows _: Bool
+ ) -> Bool {
+ // Reopen is the only running-app event that may surface a window.
+ // In particular, never pre-create or key a window from an active-Space
+ // notification: doing so can steal focus from the app the user is
+ // switching to, and makes ordinary Space navigation observable work.
+ if let localWindow = activeSpaceDocumentWindow {
+ localWindow.makeKeyAndOrderFront(nil)
+ localWindow.makeMain()
+ } else {
+ let controller = makeWorkspaceWindow(for: nil, ordersFront: false)
+ focus(controller)
+ controller.window?.makeMain()
+ }
+ // The reopen event already carries activation intent. Explicitly
+ // activating here can make AppKit choose a previously key window on a
+ // different Space even after the local window has been made key.
+ return false
+ }
+
func applicationDidResignActive(_ notification: Notification) {
persistSession()
}
- func applicationWillTerminate(_ notification: Notification) {
+ func applicationDidBecomeActive(_ notification: Notification) {
+ schedulePersistSession()
+ }
+
+ @objc private func windowScreenDidChange(_ notification: Notification) {
+ guard let window = notification.object as? NSWindow,
+ window.windowController is WorkspaceWindowController else { return }
+ schedulePersistSession()
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard workspaceWindows.allSatisfy({ $0.prepareForApplicationTermination() }) else {
+ terminationSession = nil
+ return .terminateCancel
+ }
+ guard shouldPersistSession else { return .terminateNow }
+ terminationSession = captureWorkspaceSession()
persistSession(synchronously: true)
+ workspaceWindows.forEach {
+ $0.invalidateRestorableState()
+ $0.window?.invalidateRestorableState()
+ }
+ sender.invalidateRestorableState()
+ return .terminateNow
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ guard shouldPersistSession else { return }
+ if let terminationSession {
+ sessionPersistenceQueue.sync { [sessionStore] in
+ sessionStore.save(terminationSession)
+ }
+ } else {
+ persistSession(synchronously: true)
+ }
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
@@ -221,11 +392,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
@objc func newWindow(_ sender: Any?) {
- makeWorkspaceWindow(for: nil)
+ makeScratchpadWindow(tabbedTo: nil)
}
@objc func newWindowForTab(_ sender: Any?) {
- makeWorkspaceWindow(for: nil, tabbedTo: activeTabAnchorWindow)
+ makeScratchpadWindow(tabbedTo: activeTabAnchorWindow)
+ }
+
+ @objc func saveDocumentAs(_ sender: Any?) {
+ activeWorkspaceWindow?.saveDocumentAs(sender)
}
@objc func selectTab(_ sender: NSMenuItem) {
@@ -434,6 +609,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
case #selector(openDocument(_:)), #selector(newWindow(_:)),
#selector(newWindowForTab(_:)), #selector(compareFiles(_:)):
return true
+ case #selector(saveDocumentAs(_:)):
+ return activeWorkspaceWindow?.canSaveDocumentAs == true
case #selector(compareActiveTab(_:)):
return OpenTabComparisonPickerModel.isAvailable(
active: activeWorkspaceWindow,
@@ -555,6 +732,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
return workspaceWindows.last(where: { $0.window?.isVisible == true })?.window
}
+ private var activeSpaceDocumentWindow: NSWindow? {
+ NSApplication.shared.windows.first { window in
+ guard windowIsOnActiveSpace(window),
+ (window.windowController is WorkspaceWindowController
+ || window.windowController is ComparisonWindowController) else { return false }
+ return window.tabGroup?.selectedWindow == nil
+ || window.tabGroup?.selectedWindow === window
+ }
+ }
+
+ private func windowIsOnActiveSpace(_ window: NSWindow) -> Bool {
+ if window.windowNumber > 0,
+ let result = spaceRestorationBackend.windowsAreOnActiveSpace([
+ CGWindowID(window.windowNumber)
+ ]) {
+ return result
+ }
+ return window.isOnActiveSpace
+ }
+
private func open(_ urls: [URL]) {
var seen = Set()
var tabAnchor = activeTabAnchorWindow
@@ -595,25 +792,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
}
- NSApplication.shared.activate(ignoringOtherApps: true)
+ activatePreparedApplication()
+ }
+
+ private func makeScratchpadWindow(tabbedTo parentWindow: NSWindow?) {
+ do {
+ let scratchpad = try scratchpadStore.create()
+ makeWorkspaceWindow(
+ for: nil,
+ tabbedTo: parentWindow,
+ scratchpad: scratchpad
+ )
+ } catch {
+ let alert = NSAlert(error: error)
+ alert.messageText = "Could Not Create Scratchpad"
+ alert.runModal()
+ }
}
@discardableResult
private func makeWorkspaceWindow(
for url: URL?,
tabbedTo parentWindow: NSWindow? = nil,
- restorationState: WorkspaceTabSession? = nil
+ restorationState: WorkspaceTabSession? = nil,
+ scratchpad: ScratchpadDescriptor? = nil,
+ restorationIdentifier: String? = nil,
+ ordersFront: Bool = true
) -> WorkspaceWindowController {
let controller = WorkspaceWindowController(
workspaceURL: url,
- restorationState: restorationState
+ scratchpad: scratchpad,
+ restorationState: restorationState,
+ recentWorkspaceStore: recentWorkspaceStore,
+ scratchpadStore: scratchpadStore,
+ restorationIdentifier: restorationIdentifier
)
controller.onClose = { [weak self, weak controller] in
guard let self, let controller else { return }
self.workspaceWindows.removeAll { $0 === controller }
- self.persistSession()
+ if self.terminationSession == nil {
+ self.schedulePersistSession()
+ }
+ }
+ controller.onSessionStateChange = { [weak self, weak controller] in
+ controller?.invalidateRestorableState()
+ controller?.window?.invalidateRestorableState()
+ self?.schedulePersistSession()
}
- controller.onSessionStateChange = { [weak self] in self?.schedulePersistSession() }
controller.onRequestPathRename = { [weak self] sourceURL, proposedName in
guard let self else {
return .failure(NavigatorRenameError.coordinationUnavailable)
@@ -652,9 +877,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
controller.refreshTabPresentation()
}
- controller.showWindow(nil)
- focus(controller)
- schedulePersistSession()
+ if ordersFront {
+ controller.showWindow(nil)
+ focus(controller)
+ schedulePersistSession()
+ }
return controller
}
@@ -873,6 +1100,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
window.makeKeyAndOrderFront(nil)
}
+ private func activatePreparedApplication() {
+ if NSApplication.shared.activationPolicy() != .regular {
+ NSApplication.shared.setActivationPolicy(.regular)
+ }
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+
private func commandLineURLs() -> [URL] {
let workingDirectory = URL(
fileURLWithPath: FileManager.default.currentDirectoryPath,
@@ -889,52 +1123,244 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private func restoreLastSession() -> Bool {
guard let session = sessionStore.load() else { return false }
- let requestedSelectedState = session.tabs.indices.contains(session.selectedIndex)
- ? session.tabs[session.selectedIndex]
- : session.tabs.first
- let tabs = session.tabs.filter { state in
- FileManager.default.fileExists(atPath: state.workspacePath)
+ let indexedWindows = session.windows.enumerated().compactMap {
+ originalIndex, window -> (Int, WorkspaceWindowSession)? in
+ let tabs = window.tabs.filter(isRestorable)
+ guard !tabs.isEmpty else { return nil }
+ let requestedTab = window.tabs.indices.contains(window.selectedIndex)
+ ? window.tabs[window.selectedIndex]
+ : nil
+ let selectedIndex = requestedTab.flatMap { tabs.firstIndex(of: $0) }
+ ?? min(max(window.selectedIndex, 0), tabs.count - 1)
+ return (originalIndex, WorkspaceWindowSession(
+ tabs: tabs,
+ selectedIndex: selectedIndex,
+ frame: window.frame,
+ space: window.space
+ ))
+ }
+ let windows = indexedWindows.map(\.1)
+ guard let firstWindow = windows.first, let firstTab = firstWindow.tabs.first else {
+ return false
}
- guard let first = tabs.first else { return false }
+ let restoredSelectedWindowIndex = indexedWindows.firstIndex {
+ $0.0 == session.selectedWindowIndex
+ } ?? min(max(session.selectedWindowIndex, 0), windows.count - 1)
isRestoringSession = true
let firstController = makeWorkspaceWindow(
- for: URL(fileURLWithPath: first.workspacePath),
- restorationState: first
+ for: nil,
+ restorationState: firstTab,
+ ordersFront: false
)
+ firstWindow.frame.map(firstController.applyRestoredWindowFrame)
DispatchQueue.main.async { [weak self, weak firstController] in
guard let self, let firstController else { return }
- var controllers = [firstController]
- var tabAnchor = firstController.window
- for state in tabs.dropFirst() {
- let controller = self.makeWorkspaceWindow(
- for: URL(fileURLWithPath: state.workspacePath),
- tabbedTo: tabAnchor,
- restorationState: state
+ var restoredWindows: [[WorkspaceWindowController]] = []
+
+ for (windowIndex, windowState) in windows.enumerated() {
+ var controllers: [WorkspaceWindowController] = []
+ var tabAnchor: NSWindow?
+
+ for (tabIndex, state) in windowState.tabs.enumerated() {
+ let controller: WorkspaceWindowController
+ if windowIndex == 0, tabIndex == 0 {
+ controller = firstController
+ } else {
+ controller = self.makeWorkspaceWindow(
+ for: nil,
+ tabbedTo: tabAnchor,
+ restorationState: state,
+ ordersFront: false
+ )
+ }
+ controllers.append(controller)
+ tabAnchor = controller.window
+ }
+
+ if let frame = windowState.frame {
+ controllers.first?.applyRestoredWindowFrame(frame)
+ }
+ let selectedIndex = min(
+ max(windowState.selectedIndex, 0),
+ controllers.count - 1
+ )
+ if let selectedWindow = controllers[selectedIndex].window {
+ selectedWindow.tabGroup?.selectedWindow = selectedWindow
+ }
+ restoredWindows.append(controllers)
+ }
+
+ let selectedWindowIndex = min(
+ max(restoredSelectedWindowIndex, 0),
+ restoredWindows.count - 1
+ )
+ let groups = Array(zip(windows, restoredWindows))
+ self.restoreSpaceAssignments(for: groups) { [weak self] in
+ self?.finishSessionRestoration(
+ groups: groups,
+ selectedWindowIndex: selectedWindowIndex
+ )
+ }
+ }
+ return true
+ }
+
+ private func restoreSpaceAssignments(
+ for groups: [(WorkspaceWindowSession, [WorkspaceWindowController])],
+ completion: @escaping () -> Void
+ ) {
+ guard spaceRestorationBackend.isAvailable else {
+ completion()
+ return
+ }
+ let requests = groups.compactMap {
+ state, controllers -> ([CGWindowID], WorkspaceSpaceAssignment)? in
+ guard let assignment = state.space else { return nil }
+ let windowIDs = controllers.compactMap(\.window).compactMap { window in
+ window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil
+ }
+ return windowIDs.isEmpty ? nil : (windowIDs, assignment)
+ }
+ guard !requests.isEmpty else {
+ completion()
+ return
+ }
+
+ var remaining = requests.count
+ for (windowIDs, assignment) in requests {
+ spaceRestorationBackend.restore(
+ windowIDs: windowIDs,
+ assignment: assignment
+ ) { _ in
+ remaining -= 1
+ if remaining == 0 { completion() }
+ }
+ }
+ }
+
+ private func revealBackgroundWindows(
+ _ reveals: [WorkspaceBackgroundReveal],
+ at index: Int = 0,
+ completion: @escaping () -> Void
+ ) {
+ guard reveals.indices.contains(index) else {
+ completion()
+ return
+ }
+ let reveal = reveals[index]
+ let insertedMoveToActiveSpace = !reveal.selectedWindow.collectionBehavior.contains(
+ .moveToActiveSpace
+ )
+ reveal.selectedWindow.collectionBehavior.insert(.moveToActiveSpace)
+ reveal.selectedWindow.orderFrontRegardless()
+ if insertedMoveToActiveSpace {
+ reveal.selectedWindow.collectionBehavior.remove(.moveToActiveSpace)
+ }
+
+ let finishReveal = { [weak self] in
+ reveal.windows.forEach { $0.alphaValue = 1 }
+ DispatchQueue.main.async {
+ self?.revealBackgroundWindows(
+ reveals,
+ at: index + 1,
+ completion: completion
)
- controllers.append(controller)
- tabAnchor = controller.window
}
- let selected = requestedSelectedState.flatMap { tabs.firstIndex(of: $0) }
- ?? min(max(session.selectedIndex, 0), controllers.count - 1)
- self.focus(controllers[selected])
+ }
+ guard let assignment = reveal.assignment else {
+ finishReveal()
+ return
+ }
+ let windowIDs = reveal.windows.compactMap { window in
+ window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil
+ }
+ guard !windowIDs.isEmpty else {
+ finishReveal()
+ return
+ }
+ spaceRestorationBackend.restore(
+ windowIDs: windowIDs,
+ assignment: assignment
+ ) { _ in
+ finishReveal()
+ }
+ }
+
+ private func finishSessionRestoration(
+ groups: [(WorkspaceWindowSession, [WorkspaceWindowController])],
+ selectedWindowIndex: Int
+ ) {
+ var restoredWindowsOnActiveSpace: [Bool] = []
+ var selectedControllers: [WorkspaceWindowController?] = []
+ for (state, controllers) in groups {
+ guard !controllers.isEmpty else {
+ restoredWindowsOnActiveSpace.append(false)
+ selectedControllers.append(nil)
+ continue
+ }
+ let selectedIndex = min(max(state.selectedIndex, 0), controllers.count - 1)
+ let selectedController = controllers[selectedIndex]
+ let selectedWindow = selectedController.window
+ // Capture locality before ordering any restored window. Ordering a
+ // remote window while launch activation is in flight can switch
+ // Spaces, which would make subsequent isOnActiveSpace results refer
+ // to the wrong (newly activated) Space.
+ restoredWindowsOnActiveSpace.append(
+ selectedWindow.map(windowIsOnActiveSpace) == true
+ )
+ selectedControllers.append(selectedController)
+ }
+
+ let launchAction = WorkspaceLaunchPolicy.action(
+ previouslySelectedIndex: selectedWindowIndex,
+ restoredWindowsOnActiveSpace: restoredWindowsOnActiveSpace
+ )
+ let launchController: WorkspaceWindowController
+ let launchRestoredIndex: Int?
+ switch launchAction {
+ case .focusRestoredWindow(let index):
+ launchController = selectedControllers.indices.contains(index)
+ ? selectedControllers[index] ?? makeWorkspaceWindow(for: nil, ordersFront: false)
+ : makeWorkspaceWindow(for: nil, ordersFront: false)
+ launchRestoredIndex = selectedControllers.indices.contains(index)
+ && selectedControllers[index] != nil ? index : nil
+ case .createEmptyWindow:
+ launchController = makeWorkspaceWindow(for: nil, ordersFront: false)
+ launchRestoredIndex = nil
+ }
+
+ var backgroundReveals: [WorkspaceBackgroundReveal] = []
+ for (index, (state, controllers)) in groups.enumerated() {
+ let windows = controllers.compactMap(\.window)
+ if index == launchRestoredIndex {
+ windows.forEach { $0.alphaValue = 1 }
+ } else if let selectedWindow = selectedControllers[index]?.window {
+ windows.forEach { $0.alphaValue = 0 }
+ backgroundReveals.append(WorkspaceBackgroundReveal(
+ windows: windows,
+ selectedWindow: selectedWindow,
+ assignment: state.space
+ ))
+ }
+ }
+
+ // Complete activation with the current-Space target as the only newly
+ // ordered window. Once AppKit has committed that activation, reveal
+ // every other restored window without making it key or changing Space.
+ focus(launchController)
+ activatePreparedApplication()
+ revealBackgroundWindows(backgroundReveals) { [weak self] in
+ guard let self else { return }
self.isRestoringSession = false
self.persistSession()
}
- return true
}
private func persistSession(synchronously: Bool = false) {
- guard didFinishLaunching, !isRestoringSession else { return }
- let tabs = workspaceWindows.compactMap(\.sessionState)
- let selectedController = activeWorkspaceWindow
- let selectedIndex = selectedController.flatMap { selected in
- workspaceWindows.firstIndex { $0 === selected }
- } ?? 0
- let session = tabs.isEmpty
- ? nil
- : WorkspaceSession(tabs: tabs, selectedIndex: selectedIndex)
+ guard shouldPersistSession, didFinishLaunching, !isRestoringSession else { return }
+ let session = captureWorkspaceSession()
let save = { [sessionStore] in sessionStore.save(session) }
if synchronously {
sessionPersistenceQueue.sync(execute: save)
@@ -943,8 +1369,83 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
}
+ private func isRestorable(_ state: WorkspaceTabSession) -> Bool {
+ if let id = state.scratchpadID {
+ return scratchpadStore.exists(ScratchpadDescriptor(
+ id: id,
+ title: state.scratchpadTitle ?? "Untitled"
+ ))
+ }
+ return FileManager.default.fileExists(atPath: state.workspacePath)
+ }
+
+ private func captureWorkspaceSession() -> WorkspaceSession? {
+ var visitedWindows = Set()
+ var windowSessions: [WorkspaceWindowSession] = []
+ var controllerWindowIndices: [ObjectIdentifier: Int] = [:]
+
+ for controller in workspaceWindows {
+ guard let window = controller.window,
+ !visitedWindows.contains(ObjectIdentifier(window)) else { continue }
+
+ let tabWindows = window.tabGroup?.windows ?? [window]
+ let controllers = tabWindows.compactMap {
+ $0.windowController as? WorkspaceWindowController
+ }
+ controllers.compactMap(\.window).forEach {
+ visitedWindows.insert(ObjectIdentifier($0))
+ }
+ let tabs = controllers.compactMap(\.sessionState)
+ guard !tabs.isEmpty else { continue }
+
+ let selectedWindow = window.tabGroup?.selectedWindow ?? window
+ let selectedIndex = controllers.firstIndex {
+ $0.window === selectedWindow
+ } ?? 0
+ let windowIndex = windowSessions.count
+ controllers.forEach { controller in
+ controllerWindowIndices[ObjectIdentifier(controller)] = windowIndex
+ }
+ let windowIDs = controllers.compactMap(\.window).compactMap { window in
+ window.windowNumber > 0 ? CGWindowID(window.windowNumber) : nil
+ }
+ windowSessions.append(WorkspaceWindowSession(
+ tabs: tabs,
+ selectedIndex: selectedIndex,
+ frame: WorkspaceWindowFrame(
+ selectedWindow.frame,
+ displayIdentifier: WorkspaceScreenIdentity.identifier(
+ for: selectedWindow.screen
+ ),
+ displayFrame: selectedWindow.screen?.visibleFrame
+ ),
+ space: spaceRestorationBackend.captureAssignment(
+ forWindowIDs: windowIDs
+ )
+ ))
+ }
+
+ guard !windowSessions.isEmpty else { return nil }
+ let selectedWindowIndex = activeWorkspaceWindow.flatMap {
+ controllerWindowIndices[ObjectIdentifier($0)]
+ } ?? 0
+ return WorkspaceSession(
+ windows: windowSessions,
+ selectedWindowIndex: selectedWindowIndex
+ )
+ }
+
+ var workspaceSessionForTesting: WorkspaceSession? {
+ captureWorkspaceSession()
+ }
+
+ var isTerminatingApplication: Bool {
+ terminationSession != nil
+ }
+
private func schedulePersistSession() {
guard didFinishLaunching, !isRestoringSession, !isSessionPersistScheduled else { return }
+ NSApplication.shared.invalidateRestorableState()
isSessionPersistScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
diff --git a/Sources/MarginApp/AppMenu.swift b/Sources/MarginApp/AppMenu.swift
index b4039a7..7193710 100644
--- a/Sources/MarginApp/AppMenu.swift
+++ b/Sources/MarginApp/AppMenu.swift
@@ -149,6 +149,15 @@ enum AppMenu {
fileMenu.addItem(
item("Save", action: #selector(WorkspaceDocumentSaving.saveDocument(_:)), key: "s")
)
+ fileMenu.addItem(
+ item(
+ "Save Scratchpad As…",
+ action: #selector(AppDelegate.saveDocumentAs(_:)),
+ key: "s",
+ modifiers: [.command, .shift],
+ target: delegate
+ )
+ )
fileMenu.addItem(.separator())
fileMenu.addItem(item("Close Tab", action: #selector(NSWindow.performClose(_:)), key: "w"))
mainMenu.addItem(menuItem(title: "File", submenu: fileMenu))
diff --git a/Sources/MarginApp/ComparisonWindowController.swift b/Sources/MarginApp/ComparisonWindowController.swift
index d6b8e48..2b8ed16 100644
--- a/Sources/MarginApp/ComparisonWindowController.swift
+++ b/Sources/MarginApp/ComparisonWindowController.swift
@@ -222,7 +222,9 @@ final class ComparisonWindowController: NSWindowController, NSWindowDelegate {
window.tabbingMode = .disallowed
window.minSize = NSSize(width: 620, height: 460)
window.collectionBehavior.insert(.fullScreenPrimary)
- window.setFrameAutosaveName("MarginComparisonWindow")
+ if !MarginRuntimeEnvironment.isPerformanceBenchmark {
+ window.setFrameAutosaveName("MarginComparisonWindow")
+ }
if let visible = NSScreen.main?.visibleFrame {
let width = min(1180, max(620, visible.width - 96))
diff --git a/Sources/MarginApp/EditorViewController.swift b/Sources/MarginApp/EditorViewController.swift
index 4c18427..c434d6b 100644
--- a/Sources/MarginApp/EditorViewController.swift
+++ b/Sources/MarginApp/EditorViewController.swift
@@ -5,6 +5,7 @@ final class EditorViewController: NSViewController,
WorkspaceDocumentPresenting,
WorkspaceReaderModeToggling,
WorkspaceDocumentSaving,
+ WorkspaceDocumentSavingAs,
WorkspaceDocumentPathRelocating,
WorkspaceComparisonSourceProviding,
WorkspaceComparisonApplying,
@@ -63,6 +64,7 @@ final class EditorViewController: NSViewController,
private var highlighter: MarkdownHighlighter?
private weak var commentsViewController: CommentsViewController?
private var documentURL: URL?
+ private var documentDisplayName: String?
private var lastSavedBodyData = Data()
private var isApplyingDocument = false
private var isDirty = false
@@ -271,6 +273,14 @@ final class EditorViewController: NSViewController,
}
func presentDocument(at url: URL) {
+ presentDocument(at: url, displayName: nil)
+ }
+
+ func presentScratchpad(at url: URL, title: String) {
+ presentDocument(at: url, displayName: title)
+ }
+
+ private func presentDocument(at url: URL, displayName: String?) {
_ = view
guard prepareToClose() else { return }
saveWorkItem?.cancel()
@@ -278,6 +288,7 @@ final class EditorViewController: NSViewController,
fileWatcher = nil
documentURL = url.standardizedFileURL
+ documentDisplayName = displayName
isDocumentLoaded = false
pendingContinuityState = nil
selectedThreadID = nil
@@ -340,6 +351,7 @@ final class EditorViewController: NSViewController,
fileWatcher?.stop()
fileWatcher = nil
documentURL = nil
+ documentDisplayName = nil
isDocumentLoaded = false
pendingContinuityState = nil
selectedThreadID = nil
@@ -493,6 +505,43 @@ final class EditorViewController: NSViewController,
}
}
+ func saveDocument(to destinationURL: URL) -> Bool {
+ let destinationURL = destinationURL.standardizedFileURL
+ guard let sourceURL = documentURL else { return false }
+ guard prepareToClose() else { return false }
+ if sourceURL == destinationURL { return true }
+
+ do {
+ let data = try Data(contentsOf: sourceURL)
+ try data.write(to: destinationURL, options: .atomic)
+ fileWatcher?.stop()
+ fileWatcher = nil
+ documentURL = destinationURL
+ documentDisplayName = nil
+ watchDocument(destinationURL)
+ textView.isEditable = FileManager.default.isWritableFile(atPath: destinationURL.path)
+ textView.setAccessibilityHelp(
+ "Editing \(destinationURL.lastPathComponent) as literal Markdown. Formatting marks remain visible."
+ )
+ if isReaderMode, let reader = readerViewController {
+ let selection = reader.selectedSourceRange
+ reader.renderAsync(
+ markdown: textView.string,
+ baseURL: destinationURL.deletingLastPathComponent(),
+ preferredSourceSelection: selection
+ ) { [weak self] applied in
+ guard let self, applied, self.isReaderMode else { return }
+ self.updateReaderHighlights()
+ }
+ }
+ updateStatus(savedMessage: true)
+ return true
+ } catch {
+ showBanner("Could not save as \(destinationURL.lastPathComponent): \(error.localizedDescription)")
+ return false
+ }
+ }
+
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
if menuItem.action == #selector(saveDocument(_:)) {
return documentURL != nil
@@ -530,6 +579,7 @@ final class EditorViewController: NSViewController,
fileWatcher?.stop()
fileWatcher = nil
self.documentURL = relocatedURL
+ documentDisplayName = nil
watchDocument(relocatedURL)
textView.setAccessibilityHelp(
"Editing \(relocatedURL.lastPathComponent) as literal Markdown. Formatting marks remain visible."
@@ -834,7 +884,7 @@ final class EditorViewController: NSViewController,
textView.setSelectedRange(NSRange(location: 0, length: 0))
isApplyingDocument = false
isDocumentLoaded = true
- let name = documentURL?.lastPathComponent ?? "Markdown document"
+ let name = documentDisplayName ?? documentURL?.lastPathComponent ?? "Markdown document"
textView.setAccessibilityHelp("Editing \(name) as literal Markdown. Formatting marks remain visible.")
lastSavedBodyData = bodyData
setDirty(false)
diff --git a/Sources/MarginApp/NativeSpaceRestoration.swift b/Sources/MarginApp/NativeSpaceRestoration.swift
new file mode 100644
index 0000000..3a81a18
--- /dev/null
+++ b/Sources/MarginApp/NativeSpaceRestoration.swift
@@ -0,0 +1,263 @@
+import AppKit
+import Foundation
+import MarginPrivateSupport
+
+struct WorkspaceSpaceDescriptor: Equatable {
+ var managedSpaceID: UInt64
+ var spaceUUID: String?
+ var displayIdentifier: String
+ var desktopOrdinal: Int?
+ var spaceType: Int
+ var isActive: Bool
+}
+
+enum WorkspaceSpaceResolver {
+ static func resolve(
+ _ assignment: WorkspaceSpaceAssignment,
+ in catalog: [WorkspaceSpaceDescriptor]
+ ) -> WorkspaceSpaceDescriptor? {
+ let desktops = catalog.filter { $0.spaceType == 0 }
+ if let uuid = assignment.spaceUUID,
+ let exact = desktops.first(where: { $0.spaceUUID == uuid }) {
+ return exact
+ }
+ if assignment.spaceUUID == nil,
+ let id = assignment.managedSpaceID,
+ let hinted = desktops.first(where: {
+ $0.managedSpaceID == id
+ && displayMatches(assignment.displayIdentifier, $0.displayIdentifier)
+ }) {
+ return hinted
+ }
+ if let display = assignment.displayIdentifier,
+ let ordinal = assignment.desktopOrdinal,
+ let ordinalMatch = desktops.first(where: {
+ $0.displayIdentifier == display && $0.desktopOrdinal == ordinal
+ }) {
+ return ordinalMatch
+ }
+ if let display = assignment.displayIdentifier,
+ let active = desktops.first(where: {
+ $0.displayIdentifier == display && $0.isActive
+ }) {
+ return active
+ }
+ return nil
+ }
+
+ private static func displayMatches(_ saved: String?, _ candidate: String) -> Bool {
+ saved == nil || saved == candidate
+ }
+}
+
+protocol WorkspaceSpaceRestorationBackend: AnyObject {
+ var isAvailable: Bool { get }
+ func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool?
+ func captureAssignment(
+ forWindowIDs windowIDs: [CGWindowID]
+ ) -> WorkspaceSpaceAssignment?
+ func restore(
+ windowIDs: [CGWindowID],
+ assignment: WorkspaceSpaceAssignment,
+ completion: @escaping (Bool) -> Void
+ )
+}
+
+final class AppKitSpaceRestorationBackend: WorkspaceSpaceRestorationBackend {
+ let isAvailable = false
+
+ func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool? {
+ nil
+ }
+
+ func captureAssignment(
+ forWindowIDs windowIDs: [CGWindowID]
+ ) -> WorkspaceSpaceAssignment? {
+ nil
+ }
+
+ func restore(
+ windowIDs: [CGWindowID],
+ assignment: WorkspaceSpaceAssignment,
+ completion: @escaping (Bool) -> Void
+ ) {
+ completion(false)
+ }
+}
+
+final class TahoeNativeSpaceRestorationBackend: WorkspaceSpaceRestorationBackend {
+ private static let supportedMajorVersion = 26
+ private static let maximumVerificationAttempts = 20
+ private static let verificationInterval: TimeInterval = 0.05
+
+ let isAvailable: Bool
+
+ init(processInfo: ProcessInfo = .processInfo) {
+ isAvailable = processInfo.operatingSystemVersion.majorVersion
+ == Self.supportedMajorVersion
+ && MarginNativeSpacesAvailable()
+ }
+
+ func windowsAreOnActiveSpace(_ windowIDs: [CGWindowID]) -> Bool? {
+ guard isAvailable, !windowIDs.isEmpty else { return nil }
+ let activeSpaceIDs = Set(
+ spaceCatalog().filter(\.isActive).map(\.managedSpaceID)
+ )
+ guard !activeSpaceIDs.isEmpty else { return nil }
+ return !activeSpaceIDs.isDisjoint(with: spaceIDs(for: windowIDs))
+ }
+
+ func captureAssignment(
+ forWindowIDs windowIDs: [CGWindowID]
+ ) -> WorkspaceSpaceAssignment? {
+ guard isAvailable, !windowIDs.isEmpty else { return nil }
+ let catalog = spaceCatalog()
+ let windowSpaces = Set(spaceIDs(for: windowIDs))
+ let matchingDesktops = catalog.filter {
+ $0.spaceType == 0 && windowSpaces.contains($0.managedSpaceID)
+ }
+ guard matchingDesktops.count == 1, let space = matchingDesktops.first else {
+ return nil
+ }
+ return WorkspaceSpaceAssignment(
+ spaceUUID: space.spaceUUID,
+ managedSpaceID: space.managedSpaceID,
+ displayIdentifier: space.displayIdentifier,
+ desktopOrdinal: space.desktopOrdinal,
+ spaceType: space.spaceType
+ )
+ }
+
+ func restore(
+ windowIDs: [CGWindowID],
+ assignment: WorkspaceSpaceAssignment,
+ completion: @escaping (Bool) -> Void
+ ) {
+ guard isAvailable,
+ assignment.spaceType == 0,
+ !windowIDs.isEmpty,
+ let destination = WorkspaceSpaceResolver.resolve(
+ assignment,
+ in: spaceCatalog()
+ ) else {
+ completion(false)
+ return
+ }
+ if allWindows(windowIDs, occupy: destination.managedSpaceID) {
+ completion(true)
+ return
+ }
+ let invoked = windowIDs.withUnsafeBufferPointer { buffer in
+ MarginNativeSpacesMoveWindows(
+ buffer.baseAddress,
+ buffer.count,
+ destination.managedSpaceID
+ )
+ }
+ guard invoked else {
+ completion(false)
+ return
+ }
+ verify(
+ windowIDs: windowIDs,
+ destinationSpaceID: destination.managedSpaceID,
+ remainingAttempts: Self.maximumVerificationAttempts,
+ completion: completion
+ )
+ }
+
+ private func verify(
+ windowIDs: [CGWindowID],
+ destinationSpaceID: UInt64,
+ remainingAttempts: Int,
+ completion: @escaping (Bool) -> Void
+ ) {
+ if allWindows(windowIDs, occupy: destinationSpaceID) {
+ completion(true)
+ return
+ }
+ guard remainingAttempts > 1 else {
+ completion(false)
+ return
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.verificationInterval) {
+ [weak self] in
+ guard let self else {
+ completion(false)
+ return
+ }
+ self.verify(
+ windowIDs: windowIDs,
+ destinationSpaceID: destinationSpaceID,
+ remainingAttempts: remainingAttempts - 1,
+ completion: completion
+ )
+ }
+ }
+
+ private func allWindows(_ windowIDs: [CGWindowID], occupy spaceID: UInt64) -> Bool {
+ windowIDs.allSatisfy { spaceIDs(for: [$0]).contains(spaceID) }
+ }
+
+ private func spaceIDs(for windowIDs: [CGWindowID]) -> [UInt64] {
+ guard !windowIDs.isEmpty else { return [] }
+ let spaces = windowIDs.withUnsafeBufferPointer { buffer in
+ MarginNativeSpacesCopySpacesForWindows(buffer.baseAddress, buffer.count)
+ }
+ return (spaces as? [NSNumber])?.map(\.uint64Value) ?? []
+ }
+
+ private func spaceCatalog() -> [WorkspaceSpaceDescriptor] {
+ guard let managedDisplays = MarginNativeSpacesCopyManagedDisplaySpaces()
+ as? [[String: Any]] else { return [] }
+ var result: [WorkspaceSpaceDescriptor] = []
+ for managedDisplay in managedDisplays {
+ guard let rawDisplayIdentifier = managedDisplay["Display Identifier"] as? String,
+ let spaces = managedDisplay["Spaces"] as? [[String: Any]] else { continue }
+ let displayIdentifier = normalizedDisplayIdentifier(rawDisplayIdentifier)
+ let activeID = spaceID(in: managedDisplay["Current Space"])
+ var desktopOrdinal = 0
+ for space in spaces {
+ guard let managedSpaceID = spaceID(in: space) else { continue }
+ let type = (space["type"] as? NSNumber)?.intValue
+ ?? Int(MarginNativeSpacesGetType(managedSpaceID))
+ let ordinal: Int?
+ if type == 0 {
+ ordinal = desktopOrdinal
+ desktopOrdinal += 1
+ } else {
+ ordinal = nil
+ }
+ let copiedName = MarginNativeSpacesCopyName(managedSpaceID) as String?
+ let dictionaryUUID = space["uuid"] as? String
+ result.append(WorkspaceSpaceDescriptor(
+ managedSpaceID: managedSpaceID,
+ spaceUUID: copiedName ?? dictionaryUUID,
+ displayIdentifier: displayIdentifier,
+ desktopOrdinal: ordinal,
+ spaceType: type,
+ isActive: activeID == managedSpaceID
+ ))
+ }
+ }
+ return result
+ }
+
+ private func spaceID(in value: Any?) -> UInt64? {
+ guard let dictionary = value as? [String: Any] else { return nil }
+ return (dictionary["id64"] as? NSNumber)?.uint64Value
+ ?? (dictionary["ManagedSpaceID"] as? NSNumber)?.uint64Value
+ }
+
+ private func normalizedDisplayIdentifier(_ identifier: String) -> String {
+ guard identifier == "Main" else { return identifier }
+ return WorkspaceScreenIdentity.identifier(for: NSScreen.main) ?? identifier
+ }
+}
+
+enum WorkspaceSpaceRestorationBackendFactory {
+ static func make() -> WorkspaceSpaceRestorationBackend {
+ let native = TahoeNativeSpaceRestorationBackend()
+ return native.isAvailable ? native : AppKitSpaceRestorationBackend()
+ }
+}
diff --git a/Sources/MarginApp/RecentWorkspaceStore.swift b/Sources/MarginApp/RecentWorkspaceStore.swift
index b1e6df8..b26b901 100644
--- a/Sources/MarginApp/RecentWorkspaceStore.swift
+++ b/Sources/MarginApp/RecentWorkspaceStore.swift
@@ -21,13 +21,16 @@ final class RecentWorkspaceStore {
private let defaults: UserDefaults
private let key: String
+ private let persistenceEnabled: Bool
init(
defaults: UserDefaults = .standard,
- key: String = "MarginRecentWorkspaces.v1"
+ key: String = "MarginRecentWorkspaces.v1",
+ persistenceEnabled: Bool = true
) {
self.defaults = defaults
self.key = key
+ self.persistenceEnabled = persistenceEnabled
}
func workspaces(limit: Int = 10) -> [RecentWorkspace] {
@@ -67,6 +70,7 @@ final class RecentWorkspaceStore {
}
func record(_ url: URL, at date: Date = Date(), limit: Int = 12) {
+ guard persistenceEnabled else { return }
let standardizedURL = url.standardizedFileURL
var entries = workspaces(limit: max(limit, 12)).map {
PersistedWorkspace(
diff --git a/Sources/MarginApp/ScratchpadStore.swift b/Sources/MarginApp/ScratchpadStore.swift
new file mode 100644
index 0000000..5cf1578
--- /dev/null
+++ b/Sources/MarginApp/ScratchpadStore.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+struct ScratchpadDescriptor: Codable, Equatable {
+ let id: UUID
+ let title: String
+}
+
+enum ScratchpadStoreError: LocalizedError {
+ case couldNotCreateStorage
+
+ var errorDescription: String? {
+ switch self {
+ case .couldNotCreateStorage:
+ return "Margin could not create its private scratchpad storage."
+ }
+ }
+}
+
+/// Gives untitled documents durable, file-backed storage without exposing an
+/// implementation path as the document's identity in the UI. Scratchpads use
+/// ordinary Markdown bytes so recovery never depends on a database or daemon.
+final class ScratchpadStore {
+ private let fileManager: FileManager
+ private let rootURL: URL
+ private let defaults: UserDefaults
+ private let counterKey: String
+
+ init(
+ fileManager: FileManager = .default,
+ rootURL: URL? = nil,
+ defaults: UserDefaults = .standard,
+ counterKey: String = "MarginScratchpadCounter.v1"
+ ) {
+ self.fileManager = fileManager
+ self.defaults = defaults
+ self.counterKey = counterKey
+ if let rootURL {
+ self.rootURL = rootURL.standardizedFileURL
+ } else {
+ let applicationSupport = fileManager.urls(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask
+ ).first ?? fileManager.homeDirectoryForCurrentUser
+ self.rootURL = applicationSupport
+ .appendingPathComponent("Margin", isDirectory: true)
+ .appendingPathComponent("Scratchpads", isDirectory: true)
+ }
+ }
+
+ func create() throws -> ScratchpadDescriptor {
+ try fileManager.createDirectory(
+ at: rootURL,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let descriptor = ScratchpadDescriptor(id: UUID(), title: nextTitle())
+ let destination = url(for: descriptor.id)
+ guard fileManager.createFile(
+ atPath: destination.path,
+ contents: Data(),
+ attributes: [.posixPermissions: 0o600]
+ ) else {
+ throw ScratchpadStoreError.couldNotCreateStorage
+ }
+ return descriptor
+ }
+
+ func url(for id: UUID) -> URL {
+ rootURL.appendingPathComponent(id.uuidString.lowercased())
+ .appendingPathExtension("md")
+ }
+
+ func exists(_ descriptor: ScratchpadDescriptor) -> Bool {
+ fileManager.fileExists(atPath: url(for: descriptor.id).path)
+ }
+
+ func remove(_ descriptor: ScratchpadDescriptor) throws {
+ let target = url(for: descriptor.id)
+ guard fileManager.fileExists(atPath: target.path) else { return }
+ try fileManager.removeItem(at: target)
+ }
+
+ private func nextTitle() -> String {
+ let previous = defaults.integer(forKey: counterKey)
+ let next = previous == Int.max ? 1 : previous + 1
+ defaults.set(next, forKey: counterKey)
+ return "Untitled \(next)"
+ }
+}
diff --git a/Sources/MarginApp/WorkspaceSessionStore.swift b/Sources/MarginApp/WorkspaceSessionStore.swift
index 7a92b7e..6b20ee0 100644
--- a/Sources/MarginApp/WorkspaceSessionStore.swift
+++ b/Sources/MarginApp/WorkspaceSessionStore.swift
@@ -26,12 +26,151 @@ struct WorkspaceTabSession: Codable, Equatable {
var navigatorVisible: Bool
var commentsVisible: Bool
var editor: EditorContinuityState
+ var scratchpadID: UUID? = nil
+ var scratchpadTitle: String? = nil
+ var restorationIdentifier: String? = nil
+}
+
+struct WorkspaceWindowFrame: Codable, Equatable {
+ var x: Double
+ var y: Double
+ var width: Double
+ var height: Double
+ var displayIdentifier: String? = nil
+ var displayX: Double? = nil
+ var displayY: Double? = nil
+ var displayWidth: Double? = nil
+ var displayHeight: Double? = nil
+
+ init(
+ _ frame: CGRect,
+ displayIdentifier: String? = nil,
+ displayFrame: CGRect? = nil
+ ) {
+ x = frame.origin.x
+ y = frame.origin.y
+ width = frame.size.width
+ height = frame.size.height
+ self.displayIdentifier = displayIdentifier
+ displayX = displayFrame.map { Double($0.origin.x) }
+ displayY = displayFrame.map { Double($0.origin.y) }
+ displayWidth = displayFrame.map { Double($0.size.width) }
+ displayHeight = displayFrame.map { Double($0.size.height) }
+ }
+
+ var rect: CGRect {
+ CGRect(x: x, y: y, width: width, height: height)
+ }
+
+ var savedDisplayFrame: CGRect? {
+ guard let displayX,
+ let displayY,
+ let displayWidth,
+ let displayHeight,
+ displayWidth > 0,
+ displayHeight > 0 else { return nil }
+ return CGRect(
+ x: displayX,
+ y: displayY,
+ width: displayWidth,
+ height: displayHeight
+ )
+ }
+}
+
+struct WorkspaceDisplayGeometry: Equatable {
+ var identifier: String?
+ var visibleFrame: CGRect
+}
+
+enum WorkspaceWindowPlacementResolver {
+ static func resolve(
+ _ state: WorkspaceWindowFrame,
+ displays: [WorkspaceDisplayGeometry]
+ ) -> CGRect? {
+ guard state.width >= 720,
+ state.height >= 480,
+ !displays.isEmpty else { return nil }
+
+ let savedFrame = state.rect
+ let targetDisplay = state.displayIdentifier.flatMap { identifier in
+ displays.first { $0.identifier == identifier }
+ } ?? bestDisplay(for: state, displays: displays)
+ guard let targetDisplay else { return nil }
+
+ var restoredFrame = savedFrame
+ if let savedDisplayFrame = state.savedDisplayFrame {
+ restoredFrame.origin.x = targetDisplay.visibleFrame.origin.x
+ + savedFrame.origin.x - savedDisplayFrame.origin.x
+ restoredFrame.origin.y = targetDisplay.visibleFrame.origin.y
+ + savedFrame.origin.y - savedDisplayFrame.origin.y
+ }
+ return constrain(restoredFrame, to: targetDisplay.visibleFrame)
+ }
+
+ private static func bestDisplay(
+ for state: WorkspaceWindowFrame,
+ displays: [WorkspaceDisplayGeometry]
+ ) -> WorkspaceDisplayGeometry? {
+ let referenceFrame = state.savedDisplayFrame ?? state.rect
+ let intersecting = displays.max { left, right in
+ intersectionArea(referenceFrame, left.visibleFrame)
+ < intersectionArea(referenceFrame, right.visibleFrame)
+ }
+ if let intersecting,
+ intersectionArea(referenceFrame, intersecting.visibleFrame) > 0 {
+ return intersecting
+ }
+ return displays.first
+ }
+
+ private static func intersectionArea(_ left: CGRect, _ right: CGRect) -> Double {
+ let intersection = left.intersection(right)
+ guard !intersection.isNull else { return 0 }
+ return intersection.width * intersection.height
+ }
+
+ private static func constrain(_ frame: CGRect, to visibleFrame: CGRect) -> CGRect {
+ var result = frame
+ result.size.width = min(result.width, visibleFrame.width)
+ result.size.height = min(result.height, visibleFrame.height)
+ result.origin.x = min(
+ max(result.origin.x, visibleFrame.minX),
+ visibleFrame.maxX - result.width
+ )
+ result.origin.y = min(
+ max(result.origin.y, visibleFrame.minY),
+ visibleFrame.maxY - result.height
+ )
+ return result
+ }
+}
+
+struct WorkspaceWindowSession: Codable, Equatable {
+ var tabs: [WorkspaceTabSession]
+ var selectedIndex: Int
+ var frame: WorkspaceWindowFrame?
+ var space: WorkspaceSpaceAssignment? = nil
+}
+
+struct WorkspaceSpaceAssignment: Codable, Equatable {
+ var spaceUUID: String?
+ var managedSpaceID: UInt64?
+ var displayIdentifier: String?
+ var desktopOrdinal: Int?
+ var spaceType: Int
}
struct WorkspaceSession: Codable, Equatable {
- static let currentVersion = 1
+ static let currentVersion = 2
var version: Int = currentVersion
+ var windows: [WorkspaceWindowSession]
+ var selectedWindowIndex: Int
+}
+
+private struct LegacyWorkspaceSession: Codable {
+ var version: Int
var tabs: [WorkspaceTabSession]
var selectedIndex: Int
}
@@ -49,15 +188,29 @@ final class WorkspaceSessionStore {
}
func load() -> WorkspaceSession? {
- guard let data = defaults.data(forKey: key),
- let session = try? JSONDecoder().decode(WorkspaceSession.self, from: data),
- session.version == WorkspaceSession.currentVersion,
- !session.tabs.isEmpty else { return nil }
- return session
+ guard let data = defaults.data(forKey: key) else { return nil }
+ if let session = try? JSONDecoder().decode(WorkspaceSession.self, from: data),
+ session.version == WorkspaceSession.currentVersion,
+ !session.windows.isEmpty,
+ session.windows.contains(where: { !$0.tabs.isEmpty }) {
+ return session
+ }
+ guard let legacy = try? JSONDecoder().decode(LegacyWorkspaceSession.self, from: data),
+ legacy.version == 1,
+ !legacy.tabs.isEmpty else { return nil }
+ return WorkspaceSession(
+ windows: [WorkspaceWindowSession(
+ tabs: legacy.tabs,
+ selectedIndex: legacy.selectedIndex,
+ frame: nil
+ )],
+ selectedWindowIndex: 0
+ )
}
func save(_ session: WorkspaceSession?) {
- guard let session, !session.tabs.isEmpty,
+ guard let session, !session.windows.isEmpty,
+ session.windows.contains(where: { !$0.tabs.isEmpty }),
let data = try? JSONEncoder().encode(session) else {
defaults.removeObject(forKey: key)
return
diff --git a/Sources/MarginApp/WorkspaceWindowController.swift b/Sources/MarginApp/WorkspaceWindowController.swift
index e486ceb..1941379 100644
--- a/Sources/MarginApp/WorkspaceWindowController.swift
+++ b/Sources/MarginApp/WorkspaceWindowController.swift
@@ -3,10 +3,15 @@ import MarginCore
protocol WorkspaceDocumentPresenting: AnyObject {
func presentDocument(at url: URL)
+ func presentScratchpad(at url: URL, title: String)
func clearDocument()
}
extension WorkspaceDocumentPresenting {
+ func presentScratchpad(at url: URL, title: String) {
+ presentDocument(at: url)
+ }
+
func clearDocument() {}
}
@@ -23,6 +28,10 @@ protocol WorkspaceReaderModeToggling: AnyObject {
func saveDocument(_ sender: Any?)
}
+protocol WorkspaceDocumentSavingAs: AnyObject {
+ func saveDocument(to url: URL) -> Bool
+}
+
protocol WorkspaceDocumentPathRelocating: AnyObject {
func prepareForPathRename() -> Bool
func applyDocumentPathRename(from sourceURL: URL, to destinationURL: URL)
@@ -54,6 +63,7 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
case empty
case file(URL)
case directory(URL)
+ case scratchpad(ScratchpadDescriptor)
}
var onClose: (() -> Void)?
@@ -72,6 +82,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
private let editorItem: NSSplitViewItem
private let commentsItem: NSSplitViewItem
private let recentWorkspaceStore: RecentWorkspaceStore
+ private let scratchpadStore: ScratchpadStore
+ let restorationIdentifier: String
private var navigationPaletteController: NavigationPaletteController?
private var recentWorkspaceStartViewController: RecentWorkspaceStartViewController?
private var recentWorkspaceLoadGeneration = UUID()
@@ -90,6 +102,11 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
return false
}
+ var isScratchpad: Bool {
+ if case .scratchpad = workspaceKind { return true }
+ return false
+ }
+
var canShowNavigator: Bool {
if case .directory = workspaceKind { return true }
return false
@@ -117,6 +134,10 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
documentURL != nil && editorViewController is WorkspaceDocumentSaving
}
+ var canSaveDocumentAs: Bool {
+ isScratchpad && editorViewController is WorkspaceDocumentSavingAs
+ }
+
var canQuickOpen: Bool { quickOpenDirectoryURL != nil }
var canNavigateFiles: Bool {
@@ -167,13 +188,22 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
guard let workspaceURL else { return nil }
let editorState = (editorViewController as? WorkspaceContinuityProviding)?
.captureContinuityState() ?? .beginning
+ let scratchpad: ScratchpadDescriptor?
+ if case .scratchpad(let descriptor) = workspaceKind {
+ scratchpad = descriptor
+ } else {
+ scratchpad = nil
+ }
return WorkspaceTabSession(
workspacePath: workspaceURL.path,
documentPath: documentURL?.path,
readerMode: isReaderModeActive,
navigatorVisible: isNavigatorVisible,
commentsVisible: isCommentsVisible,
- editor: editorState
+ editor: editorState,
+ scratchpadID: scratchpad?.id,
+ scratchpadTitle: scratchpad?.title,
+ restorationIdentifier: restorationIdentifier
)
}
@@ -181,16 +211,23 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
switch workspaceKind {
case .directory(let url): return url
case .file(let url): return url.deletingLastPathComponent()
- case .empty: return nil
+ case .empty, .scratchpad: return nil
}
}
init(
workspaceURL: URL?,
+ scratchpad: ScratchpadDescriptor? = nil,
restorationState: WorkspaceTabSession? = nil,
- recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore()
+ recentWorkspaceStore: RecentWorkspaceStore = RecentWorkspaceStore(),
+ scratchpadStore: ScratchpadStore = ScratchpadStore(),
+ restorationIdentifier: String? = nil
) {
self.recentWorkspaceStore = recentWorkspaceStore
+ self.scratchpadStore = scratchpadStore
+ self.restorationIdentifier = restorationIdentifier
+ ?? restorationState?.restorationIdentifier
+ ?? UUID().uuidString
editorViewController = WorkspacePaneFactory.makeEditor()
commentsViewController = WorkspacePaneFactory.makeComments()
@@ -216,6 +253,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
if let restorationState {
restore(restorationState)
+ } else if let scratchpad {
+ openScratchpad(scratchpad)
} else if let workspaceURL {
open(workspaceURL)
} else {
@@ -250,14 +289,40 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
scheduleRecentWorkspaceStartScreenIfNeeded()
}
+ override func encodeRestorableState(with coder: NSCoder) {
+ super.encodeRestorableState(with: coder)
+ coder.encode(restorationIdentifier, forKey: "MarginRestorationIdentifier")
+ }
+
func prepareForTabAttachment() {
isExplicitlyTabbed = true
pendingInitialWindowFrame = nil
}
+ func applyRestoredWindowFrame(_ state: WorkspaceWindowFrame) {
+ guard let window else { return }
+ let displays = NSScreen.screens.map {
+ WorkspaceDisplayGeometry(
+ identifier: WorkspaceScreenIdentity.identifier(for: $0),
+ visibleFrame: $0.visibleFrame
+ )
+ }
+ guard let frame = WorkspaceWindowPlacementResolver.resolve(
+ state,
+ displays: displays
+ ) else { return }
+ pendingInitialWindowFrame = nil
+ window.setFrame(frame, display: true, animate: false)
+ }
+
func refreshTabPresentation() {
guard isExplicitlyTabbed, let window else { return }
- window.tab.toolTip = (window.representedURL?.path).flatMap { $0.isEmpty ? nil : $0 } ?? "Margin"
+ if case .scratchpad(let descriptor) = workspaceKind {
+ window.tab.toolTip = "\(descriptor.title) · Scratchpad"
+ } else {
+ window.tab.toolTip = (window.representedURL?.path)
+ .flatMap { $0.isEmpty ? nil : $0 } ?? "Margin"
+ }
}
func open(_ url: URL) {
@@ -282,7 +347,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
recentWorkspaceStore.recordAfterLaunch(recentDirectoryURL)
}
- var documentURLForPathRename: URL? { documentURL }
+ var documentURLForPathRename: URL? {
+ isScratchpad ? nil : documentURL
+ }
func prepareForPathRename(from sourceURL: URL) -> Bool {
guard let documentURL,
@@ -317,6 +384,8 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
workspaceKind = .directory(
WorkspacePathRelocation.relocatedURL(url, from: sourceURL, to: destinationURL) ?? url
)
+ case .scratchpad:
+ break
}
indexedFileURLs = indexedFileURLs.map {
@@ -383,6 +452,51 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
_ = NSApp.sendAction(NSSelectorFromString("beginComment:"), to: editorViewController, from: sender)
}
+ @objc func saveDocumentAs(_ sender: Any?) {
+ guard canSaveDocumentAs, let window else { return }
+ let panel = scratchpadSavePanel()
+ panel.beginSheetModal(for: window) { [weak self] response in
+ guard response == .OK, let destination = panel.url else { return }
+ _ = self?.saveScratchpad(to: destination)
+ }
+ }
+
+ @discardableResult
+ func saveScratchpad(to destinationURL: URL) -> Bool {
+ let destinationURL = destinationURL.standardizedFileURL
+ guard case .scratchpad(let descriptor) = workspaceKind,
+ destinationURL != scratchpadStore.url(for: descriptor.id).standardizedFileURL,
+ let editor = editorViewController as? WorkspaceDocumentSavingAs,
+ editor.saveDocument(to: destinationURL) else { return false }
+
+ try? scratchpadStore.remove(descriptor)
+ workspaceKind = .file(destinationURL)
+ workspaceURL = destinationURL
+ documentURL = destinationURL
+ fileTreeViewController.trackActiveDocument(nil)
+ (commentsViewController as? WorkspaceCommentsPresenting)?
+ .presentComments(for: destinationURL)
+ updateWindowTitle(documentURL: destinationURL, workspaceURL: destinationURL)
+ window?.toolbar?.validateVisibleItems()
+ recentWorkspaceStore.recordAfterLaunch(destinationURL.deletingLastPathComponent())
+ onSessionStateChange?()
+ return true
+ }
+
+ private var suggestedScratchpadFilename: String {
+ guard case .scratchpad(let descriptor) = workspaceKind else { return "Untitled.md" }
+ return descriptor.title + ".md"
+ }
+
+ private func scratchpadSavePanel() -> NSSavePanel {
+ let panel = NSSavePanel()
+ panel.title = "Save Scratchpad"
+ panel.prompt = "Save"
+ panel.nameFieldStringValue = suggestedScratchpadFilename
+ panel.canCreateDirectories = true
+ return panel
+ }
+
@objc func quickOpen(_ sender: Any?) {
guard let directoryURL = quickOpenDirectoryURL, let window else { return }
navigationPaletteController?.close()
@@ -458,6 +572,16 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
) { [weak self] in self?.toggleReaderMode(nil) }
)
}
+ if canSaveDocumentAs {
+ items.append(
+ NavigationPaletteItem(
+ title: "Save Scratchpad As",
+ subtitle: "⌘⇧S",
+ symbolName: "square.and.arrow.down",
+ searchText: "save export name scratchpad markdown file"
+ ) { [weak self] in self?.saveDocumentAs(nil) }
+ )
+ }
if canShowComments {
var commentItems = [
NavigationPaletteItem(
@@ -845,10 +969,53 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
+ guard let editor = editorViewController as? EditorViewController else { return true }
+ guard editor.prepareToClose() else { return false }
+ guard case .scratchpad(let descriptor) = workspaceKind,
+ (NSApp.delegate as? AppDelegate)?.isTerminatingApplication != true else {
+ return true
+ }
+ return confirmScratchpadClose(descriptor)
+ }
+
+ func prepareForApplicationTermination() -> Bool {
guard let editor = editorViewController as? EditorViewController else { return true }
return editor.prepareToClose()
}
+ private func confirmScratchpadClose(_ descriptor: ScratchpadDescriptor) -> Bool {
+ let scratchURL = scratchpadStore.url(for: descriptor.id)
+ if (try? Data(contentsOf: scratchURL).isEmpty) == true {
+ try? scratchpadStore.remove(descriptor)
+ return true
+ }
+
+ let alert = NSAlert()
+ alert.messageText = "Save \(descriptor.title) as a Markdown file?"
+ alert.informativeText = "The scratchpad is safely stored inside Margin. Save it as a normal file, or explicitly discard it before closing this tab."
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: "Save As…")
+ alert.addButton(withTitle: "Cancel")
+ alert.addButton(withTitle: "Discard Scratchpad")
+
+ switch alert.runModal() {
+ case .alertFirstButtonReturn:
+ let panel = scratchpadSavePanel()
+ guard panel.runModal() == .OK, let destination = panel.url else { return false }
+ return saveScratchpad(to: destination)
+ case .alertThirdButtonReturn:
+ do {
+ try scratchpadStore.remove(descriptor)
+ return true
+ } catch {
+ NSAlert(error: error).runModal()
+ return false
+ }
+ default:
+ return false
+ }
+ }
+
func windowWillClose(_ notification: Notification) {
onClose?()
}
@@ -857,6 +1024,14 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
onSessionStateChange?()
}
+ func windowDidMove(_ notification: Notification) {
+ onSessionStateChange?()
+ }
+
+ func windowDidEndLiveResize(_ notification: Notification) {
+ onSessionStateChange?()
+ }
+
func windowWillUseStandardFrame(_ window: NSWindow, defaultFrame newFrame: NSRect) -> NSRect {
window.screen?.visibleFrame ?? newFrame
}
@@ -870,6 +1045,11 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
window.titlebarSeparatorStyle = .none
window.toolbarStyle = .unified
window.backgroundColor = MarginTheme.documentBackground
+ window.identifier = WorkspaceRestorationIdentifier.make(restorationIdentifier)
+ // Margin's Space-aware session is the single restoration authority.
+ // AppKit restoration can activate a remote saved window before Margin
+ // has selected the launch Space, so these windows must not participate
+ // in the competing system snapshot.
window.isRestorable = false
// Let AppKit own live resize and full-screen sizing. Explicit frame
// maxima are ignored by Auto Layout and can interact poorly with
@@ -891,8 +1071,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
}
private func configureInitialWindowFrame(_ window: NSWindow) {
- let frameName = "MarginWorkspaceWindow.v3"
- let restored = window.setFrameUsingName(frameName)
+ let frameName = "MarginWorkspaceWindow.v4.\(restorationIdentifier)"
+ let usesAutosavedLayout = !MarginRuntimeEnvironment.isPerformanceBenchmark
+ let restored = usesAutosavedLayout && window.setFrameUsingName(frameName)
let restoredFrame = window.frame
let restoredIsUsable = restored
&& restoredFrame.width >= 720
@@ -913,7 +1094,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
// Version the frame key when the window layout changes materially so
// an old, cramped frame does not become the new product default.
- window.setFrameAutosaveName(frameName)
+ if usesAutosavedLayout {
+ window.setFrameAutosaveName(frameName)
+ }
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
@@ -1008,7 +1191,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
private func configureSplitView() {
splitViewController.splitView.isVertical = true
splitViewController.splitView.dividerStyle = .thin
- splitViewController.splitView.autosaveName = "MarginWorkspaceSplitView"
+ if !MarginRuntimeEnvironment.isPerformanceBenchmark {
+ splitViewController.splitView.autosaveName = "MarginWorkspaceSplitView"
+ }
navigatorItem.minimumThickness = 190
navigatorItem.maximumThickness = 350
@@ -1099,6 +1284,20 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
}
private func restore(_ state: WorkspaceTabSession) {
+ if let id = state.scratchpadID {
+ let descriptor = ScratchpadDescriptor(
+ id: id,
+ title: state.scratchpadTitle ?? "Untitled"
+ )
+ guard scratchpadStore.exists(descriptor) else {
+ showEmptyState()
+ return
+ }
+ openScratchpad(descriptor)
+ restorePresentationState(state)
+ return
+ }
+
let rootURL = URL(fileURLWithPath: state.workspacePath).standardizedFileURL
var isDirectory = ObjCBool(false)
let exists = FileManager.default.fileExists(atPath: rootURL.path, isDirectory: &isDirectory)
@@ -1119,6 +1318,10 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
openStandaloneFile(rootURL)
}
+ restorePresentationState(state)
+ }
+
+ private func restorePresentationState(_ state: WorkspaceTabSession) {
if canShowNavigator {
navigatorItem.isCollapsed = !state.navigatorVisible
}
@@ -1221,6 +1424,17 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
presentDocument(fileURL)
}
+ private func openScratchpad(_ descriptor: ScratchpadDescriptor) {
+ dismissRecentWorkspaceStartScreen()
+ let fileURL = scratchpadStore.url(for: descriptor.id)
+ workspaceKind = .scratchpad(descriptor)
+ workspaceURL = fileURL
+ documentURL = fileURL
+ resetUnreadComments()
+ navigatorItem.isCollapsed = true
+ presentDocument(fileURL, scratchpadTitle: descriptor.title)
+ }
+
private func openDirectory(
_ directoryURL: URL,
preferredDocumentURL: URL? = nil
@@ -1684,11 +1898,16 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
}
}
- private func presentDocument(_ fileURL: URL) {
+ private func presentDocument(_ fileURL: URL, scratchpadTitle: String? = nil) {
commentsVisibilityChoice = .automatic
resetUnreadComments()
commentsItem.isCollapsed = true
- (editorViewController as? WorkspaceDocumentPresenting)?.presentDocument(at: fileURL)
+ if let scratchpadTitle {
+ (editorViewController as? WorkspaceDocumentPresenting)?
+ .presentScratchpad(at: fileURL, title: scratchpadTitle)
+ } else {
+ (editorViewController as? WorkspaceDocumentPresenting)?.presentDocument(at: fileURL)
+ }
if case .directory = workspaceKind {
fileTreeViewController.trackActiveDocument(fileURL)
}
@@ -1728,6 +1947,13 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
private func updateWindowTitle(documentURL: URL?, workspaceURL: URL?) {
guard let window else { return }
+ if case .scratchpad(let descriptor) = workspaceKind {
+ window.representedURL = nil
+ window.title = descriptor.title
+ window.subtitle = "Scratchpad · Saved automatically"
+ refreshTabPresentation()
+ return
+ }
let representedURL = documentURL ?? workspaceURL
window.representedURL = representedURL
refreshTabPresentation()
diff --git a/Sources/MarginApp/main.swift b/Sources/MarginApp/main.swift
index 231009e..be7ce8c 100644
--- a/Sources/MarginApp/main.swift
+++ b/Sources/MarginApp/main.swift
@@ -3,6 +3,9 @@ import AppKit
let application = NSApplication.shared
let applicationDelegate = AppDelegate()
-application.setActivationPolicy(.regular)
+// Start without Launch Services activating a remembered remote Space. The
+// delegate promotes Margin to a regular Dock app after it has prepared the
+// correct current-Space window.
+application.setActivationPolicy(.accessory)
application.delegate = applicationDelegate
application.run()
diff --git a/Sources/MarginPrivateSupport/MarginPrivateSupport.c b/Sources/MarginPrivateSupport/MarginPrivateSupport.c
new file mode 100644
index 0000000..97c0be1
--- /dev/null
+++ b/Sources/MarginPrivateSupport/MarginPrivateSupport.c
@@ -0,0 +1,212 @@
+#include "MarginPrivateSupport.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+typedef int (*SLSMainConnectionIDFunction)(void);
+typedef CFArrayRef (*SLSCopyManagedDisplaySpacesFunction)(int);
+typedef CFArrayRef (*SLSCopySpacesForWindowsFunction)(int, int, CFArrayRef);
+typedef CFStringRef (*SLSSpaceCopyNameFunction)(int, uint64_t);
+typedef int (*SLSSpaceGetTypeFunction)(int, uint64_t);
+typedef int64_t (*SLSPerformBridgedOperationFunction)(void *);
+
+static SLSMainConnectionIDFunction main_connection_id;
+static SLSCopyManagedDisplaySpacesFunction copy_managed_display_spaces;
+static SLSCopySpacesForWindowsFunction copy_spaces_for_windows;
+static SLSSpaceCopyNameFunction copy_space_name;
+static SLSSpaceGetTypeFunction get_space_type;
+static SLSPerformBridgedOperationFunction perform_bridged_operation;
+static bool support_available;
+
+// SkyLight keeps the Tahoe bridge entry point local rather than exporting it
+// through dlsym. Resolve that one address from the loaded Mach-O image while
+// looking up every other private API dynamically. This fail-closed technique
+// is adapted from yabai's Tahoe implementation; see NOTICE for attribution.
+static void *find_local_symbol(
+ const struct mach_header_64 *header,
+ intptr_t slide,
+ const char *target
+) {
+ const struct segment_command_64 *linkedit = NULL;
+ const struct symtab_command *symtab = NULL;
+ const uint8_t *cursor = (const uint8_t *)header + sizeof(*header);
+
+ for (uint32_t index = 0; index < header->ncmds; index++) {
+ const struct load_command *command = (const struct load_command *)cursor;
+ if (command->cmd == LC_SEGMENT_64) {
+ const struct segment_command_64 *segment =
+ (const struct segment_command_64 *)command;
+ if (strncmp(segment->segname, SEG_LINKEDIT, sizeof(segment->segname)) == 0) {
+ linkedit = segment;
+ }
+ } else if (command->cmd == LC_SYMTAB) {
+ symtab = (const struct symtab_command *)command;
+ }
+ cursor += command->cmdsize;
+ }
+
+ if (linkedit == NULL || symtab == NULL) return NULL;
+ uintptr_t linkedit_base = (uintptr_t)slide + linkedit->vmaddr - linkedit->fileoff;
+ const struct nlist_64 *symbols =
+ (const struct nlist_64 *)(linkedit_base + symtab->symoff);
+ const char *strings = (const char *)(linkedit_base + symtab->stroff);
+
+ for (uint32_t index = 0; index < symtab->nsyms; index++) {
+ uint32_t string_index = symbols[index].n_un.n_strx;
+ if (string_index == 0 || string_index >= symtab->strsize) continue;
+ if (strcmp(strings + string_index, target) == 0) {
+ return (void *)((uintptr_t)slide + symbols[index].n_value);
+ }
+ }
+ return NULL;
+}
+
+static void load_support(void) {
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ const char *path =
+ "/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight";
+ void *handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL);
+ if (handle == NULL) return;
+
+ main_connection_id = (SLSMainConnectionIDFunction)dlsym(
+ handle, "SLSMainConnectionID"
+ );
+ copy_managed_display_spaces = (SLSCopyManagedDisplaySpacesFunction)dlsym(
+ handle, "SLSCopyManagedDisplaySpaces"
+ );
+ copy_spaces_for_windows = (SLSCopySpacesForWindowsFunction)dlsym(
+ handle, "SLSCopySpacesForWindows"
+ );
+ copy_space_name = (SLSSpaceCopyNameFunction)dlsym(handle, "SLSSpaceCopyName");
+ get_space_type = (SLSSpaceGetTypeFunction)dlsym(handle, "SLSSpaceGetType");
+
+ for (uint32_t index = 0; index < _dyld_image_count(); index++) {
+ const char *image_name = _dyld_get_image_name(index);
+ if (image_name == NULL || strcmp(image_name, path) != 0) continue;
+ const struct mach_header *candidate = _dyld_get_image_header(index);
+ if (candidate == NULL || candidate->magic != MH_MAGIC_64) break;
+ perform_bridged_operation = (SLSPerformBridgedOperationFunction)find_local_symbol(
+ (const struct mach_header_64 *)candidate,
+ _dyld_get_image_vmaddr_slide(index),
+ "__ZL54SLSPerformAsynchronousBridgedWindowManagementOperationP47SLSAsynchronousBridgedWindowManagementOperation"
+ );
+ break;
+ }
+
+ Class operation_class = objc_getClass(
+ "SLSBridgedMoveWindowsToManagedSpaceOperation"
+ );
+ SEL initializer = sel_registerName("initWithWindows:spaceID:");
+ support_available = main_connection_id != NULL
+ && copy_managed_display_spaces != NULL
+ && copy_spaces_for_windows != NULL
+ && copy_space_name != NULL
+ && get_space_type != NULL
+ && perform_bridged_operation != NULL
+ && operation_class != Nil
+ && class_getInstanceMethod(operation_class, initializer) != NULL;
+ });
+}
+
+static CFArrayRef copy_window_id_array(const uint32_t *window_ids, size_t count) {
+ if (window_ids == NULL || count == 0) return NULL;
+ CFMutableArrayRef result = CFArrayCreateMutable(
+ kCFAllocatorDefault,
+ (CFIndex)count,
+ &kCFTypeArrayCallBacks
+ );
+ if (result == NULL) return NULL;
+
+ for (size_t index = 0; index < count; index++) {
+ uint32_t value = window_ids[index];
+ CFNumberRef number = CFNumberCreate(
+ kCFAllocatorDefault,
+ kCFNumberSInt32Type,
+ &value
+ );
+ if (number == NULL) {
+ CFRelease(result);
+ return NULL;
+ }
+ CFArrayAppendValue(result, number);
+ CFRelease(number);
+ }
+ return result;
+}
+
+bool MarginNativeSpacesAvailable(void) {
+ load_support();
+ return support_available;
+}
+
+CFArrayRef MarginNativeSpacesCopyManagedDisplaySpaces(void) {
+ load_support();
+ if (!support_available) return NULL;
+ return copy_managed_display_spaces(main_connection_id());
+}
+
+CFArrayRef MarginNativeSpacesCopySpacesForWindows(
+ const uint32_t *window_ids,
+ size_t window_count
+) {
+ load_support();
+ if (!support_available) return NULL;
+ CFArrayRef windows = copy_window_id_array(window_ids, window_count);
+ if (windows == NULL) return NULL;
+ CFArrayRef spaces = copy_spaces_for_windows(main_connection_id(), 0x7, windows);
+ CFRelease(windows);
+ return spaces;
+}
+
+CFStringRef MarginNativeSpacesCopyName(uint64_t space_id) {
+ load_support();
+ if (!support_available) return NULL;
+ return copy_space_name(main_connection_id(), space_id);
+}
+
+int MarginNativeSpacesGetType(uint64_t space_id) {
+ load_support();
+ if (!support_available) return -1;
+ return get_space_type(main_connection_id(), space_id);
+}
+
+bool MarginNativeSpacesMoveWindows(
+ const uint32_t *window_ids,
+ size_t window_count,
+ uint64_t space_id
+) {
+ load_support();
+ if (!support_available || window_ids == NULL || window_count == 0) return false;
+ CFArrayRef windows = copy_window_id_array(window_ids, window_count);
+ if (windows == NULL) return false;
+
+ Class operation_class = objc_getClass(
+ "SLSBridgedMoveWindowsToManagedSpaceOperation"
+ );
+ SEL allocate = sel_registerName("alloc");
+ SEL initialize = sel_registerName("initWithWindows:spaceID:");
+ SEL release = sel_registerName("release");
+ id operation = ((id (*)(id, SEL))objc_msgSend)((id)operation_class, allocate);
+ operation = ((id (*)(id, SEL, id, uint64_t))objc_msgSend)(
+ operation,
+ initialize,
+ (id)windows,
+ space_id
+ );
+ if (operation == nil) {
+ CFRelease(windows);
+ return false;
+ }
+
+ perform_bridged_operation((void *)operation);
+ ((void (*)(id, SEL))objc_msgSend)(operation, release);
+ CFRelease(windows);
+ return true;
+}
diff --git a/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h b/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h
new file mode 100644
index 0000000..2e48b2e
--- /dev/null
+++ b/Sources/MarginPrivateSupport/include/MarginPrivateSupport.h
@@ -0,0 +1,31 @@
+#ifndef MARGIN_PRIVATE_SUPPORT_H
+#define MARGIN_PRIVATE_SUPPORT_H
+
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+bool MarginNativeSpacesAvailable(void);
+CFArrayRef MarginNativeSpacesCopyManagedDisplaySpaces(void) CF_RETURNS_RETAINED;
+CFArrayRef MarginNativeSpacesCopySpacesForWindows(
+ const uint32_t *window_ids,
+ size_t window_count
+) CF_RETURNS_RETAINED;
+CFStringRef MarginNativeSpacesCopyName(uint64_t space_id) CF_RETURNS_RETAINED;
+int MarginNativeSpacesGetType(uint64_t space_id);
+bool MarginNativeSpacesMoveWindows(
+ const uint32_t *window_ids,
+ size_t window_count,
+ uint64_t space_id
+);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
index 449b33f..501cde7 100644
--- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
+++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
@@ -34,6 +34,7 @@ final class WorkspaceBehaviorTests: XCTestCase {
XCTAssertFalse(controller.isNavigatorVisible)
XCTAssertFalse(controller.isCommentsVisible)
XCTAssertFalse(controller.canShowComments)
+ XCTAssertNil(controller.sessionState)
let initialWidth = window.frame.width
let untitled = FileManager.default.temporaryDirectory
@@ -177,6 +178,10 @@ final class WorkspaceBehaviorTests: XCTestCase {
XCTAssertEqual(newTab?.keyEquivalent, "t")
XCTAssertEqual(newTab?.keyEquivalentModifierMask, [.command])
+ let saveScratchpadAs = menuItem(named: "Save Scratchpad As…")
+ XCTAssertEqual(saveScratchpadAs?.keyEquivalent, "s")
+ XCTAssertEqual(saveScratchpadAs?.keyEquivalentModifierMask, [.command, .shift])
+
let focusEditor = menuItem(named: "Focus Editor")
XCTAssertEqual(focusEditor?.keyEquivalent, "2")
XCTAssertEqual(focusEditor?.keyEquivalentModifierMask, [.control])
@@ -253,22 +258,40 @@ final class WorkspaceBehaviorTests: XCTestCase {
defer { defaults.removePersistentDomain(forName: suite) }
let store = WorkspaceSessionStore(defaults: defaults, key: "session")
let value = WorkspaceSession(
- tabs: [
- WorkspaceTabSession(
- workspacePath: "/tmp/workspace",
- documentPath: "/tmp/workspace/note.md",
- readerMode: true,
- navigatorVisible: true,
- commentsVisible: false,
- editor: EditorContinuityState(
- selectionLocation: 42,
- selectionLength: 7,
- scrollFraction: 0.5,
- selectedThreadID: "urn:uuid:test"
+ windows: [
+ WorkspaceWindowSession(
+ tabs: [
+ WorkspaceTabSession(
+ workspacePath: "/tmp/workspace",
+ documentPath: "/tmp/workspace/note.md",
+ readerMode: true,
+ navigatorVisible: true,
+ commentsVisible: false,
+ editor: EditorContinuityState(
+ selectionLocation: 42,
+ selectionLength: 7,
+ scrollFraction: 0.5,
+ selectedThreadID: "urn:uuid:test"
+ ),
+ restorationIdentifier: "window-restoration-test"
+ )
+ ],
+ selectedIndex: 0,
+ frame: WorkspaceWindowFrame(
+ CGRect(x: 40, y: 80, width: 1180, height: 780),
+ displayIdentifier: "display-test",
+ displayFrame: CGRect(x: 0, y: 0, width: 1440, height: 900)
+ ),
+ space: WorkspaceSpaceAssignment(
+ spaceUUID: "space-test",
+ managedSpaceID: 42,
+ displayIdentifier: "display-test",
+ desktopOrdinal: 1,
+ spaceType: 0
)
)
],
- selectedIndex: 0
+ selectedWindowIndex: 0
)
store.save(value)
@@ -277,6 +300,335 @@ final class WorkspaceBehaviorTests: XCTestCase {
XCTAssertNil(store.load())
}
+ func testVersionTwoSessionWithoutPlacementMetadataStillLoads() throws {
+ let suite = "margin-session-compatibility-tests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suite) else {
+ return XCTFail("Expected isolated defaults")
+ }
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let data = Data("""
+ {
+ "version": 2,
+ "windows": [{
+ "selectedIndex": 0,
+ "frame": {"x": 40, "y": 80, "width": 1180, "height": 780},
+ "tabs": [{
+ "workspacePath": "/tmp/legacy-v2.md",
+ "documentPath": "/tmp/legacy-v2.md",
+ "readerMode": false,
+ "navigatorVisible": false,
+ "commentsVisible": false,
+ "editor": {
+ "selectionLocation": 0,
+ "selectionLength": 0,
+ "scrollFraction": 0
+ }
+ }]
+ }],
+ "selectedWindowIndex": 0
+ }
+ """.utf8)
+ defaults.set(data, forKey: "session")
+
+ let session = try XCTUnwrap(
+ WorkspaceSessionStore(defaults: defaults, key: "session").load()
+ )
+ XCTAssertNil(session.windows[0].tabs[0].restorationIdentifier)
+ XCTAssertNil(session.windows[0].frame?.displayIdentifier)
+ XCTAssertNil(session.windows[0].space)
+ }
+
+ func testSpaceResolverPrefersStableUUIDOverTransientSpaceID() throws {
+ let assignment = WorkspaceSpaceAssignment(
+ spaceUUID: "target-space",
+ managedSpaceID: 12,
+ displayIdentifier: "display-a",
+ desktopOrdinal: 0,
+ spaceType: 0
+ )
+ let resolved = try XCTUnwrap(WorkspaceSpaceResolver.resolve(
+ assignment,
+ in: [
+ spaceDescriptor(id: 12, uuid: "recycled-space", ordinal: 0),
+ spaceDescriptor(id: 99, uuid: "target-space", ordinal: 3),
+ ]
+ ))
+
+ XCTAssertEqual(resolved.managedSpaceID, 99)
+ }
+
+ func testSpaceResolverFallsBackToDesktopOrdinalThenActiveDesktop() throws {
+ let ordinalAssignment = WorkspaceSpaceAssignment(
+ spaceUUID: "deleted-space",
+ managedSpaceID: 12,
+ displayIdentifier: "display-a",
+ desktopOrdinal: 1,
+ spaceType: 0
+ )
+ let catalog = [
+ spaceDescriptor(id: 20, uuid: "first", ordinal: 0),
+ spaceDescriptor(id: 21, uuid: "second", ordinal: 1),
+ spaceDescriptor(id: 22, uuid: "active", ordinal: 2, isActive: true),
+ ]
+ XCTAssertEqual(
+ WorkspaceSpaceResolver.resolve(ordinalAssignment, in: catalog)?.managedSpaceID,
+ 21
+ )
+
+ var activeAssignment = ordinalAssignment
+ activeAssignment.desktopOrdinal = 8
+ XCTAssertEqual(
+ WorkspaceSpaceResolver.resolve(activeAssignment, in: catalog)?.managedSpaceID,
+ 22
+ )
+ }
+
+ func testSpaceResolverNeverTargetsFullscreenOrAnotherDisplay() {
+ let assignment = WorkspaceSpaceAssignment(
+ spaceUUID: "missing",
+ managedSpaceID: 12,
+ displayIdentifier: "display-a",
+ desktopOrdinal: 1,
+ spaceType: 0
+ )
+ let catalog = [
+ spaceDescriptor(
+ id: 20,
+ uuid: "fullscreen",
+ display: "display-a",
+ ordinal: nil,
+ type: 4,
+ isActive: true
+ ),
+ spaceDescriptor(
+ id: 21,
+ uuid: "other-display",
+ display: "display-b",
+ ordinal: 1,
+ isActive: true
+ ),
+ ]
+
+ XCTAssertNil(WorkspaceSpaceResolver.resolve(assignment, in: catalog))
+ }
+
+ func testLaunchPolicyKeepsThePreviouslySelectedWindowWhenItIsLocal() {
+ XCTAssertEqual(
+ WorkspaceLaunchPolicy.action(
+ previouslySelectedIndex: 1,
+ restoredWindowsOnActiveSpace: [true, true, false]
+ ),
+ .focusRestoredWindow(1)
+ )
+ }
+
+ func testLaunchPolicyUsesAnotherLocalWindowInsteadOfSwitchingSpaces() {
+ XCTAssertEqual(
+ WorkspaceLaunchPolicy.action(
+ previouslySelectedIndex: 2,
+ restoredWindowsOnActiveSpace: [false, true, false]
+ ),
+ .focusRestoredWindow(1)
+ )
+ }
+
+ func testLaunchPolicyCreatesAnEmptyWindowWhenOnlySavedWindowIsRemote() {
+ XCTAssertEqual(
+ WorkspaceLaunchPolicy.action(
+ previouslySelectedIndex: 0,
+ restoredWindowsOnActiveSpace: [false]
+ ),
+ .createEmptyWindow
+ )
+ }
+
+ func testLaunchPolicyCreatesAnEmptyWindowWhenEverySavedWindowIsRemote() {
+ XCTAssertEqual(
+ WorkspaceLaunchPolicy.action(
+ previouslySelectedIndex: 1,
+ restoredWindowsOnActiveSpace: [false, false, false]
+ ),
+ .createEmptyWindow
+ )
+ }
+
+ func testWindowPlacementFollowsTheSameDisplayWhenItsLayoutChanges() throws {
+ let state = WorkspaceWindowFrame(
+ CGRect(x: 1560, y: 80, width: 900, height: 650),
+ displayIdentifier: "external",
+ displayFrame: CGRect(x: 1440, y: 0, width: 1920, height: 1080)
+ )
+ let restored = try XCTUnwrap(WorkspaceWindowPlacementResolver.resolve(
+ state,
+ displays: [
+ WorkspaceDisplayGeometry(
+ identifier: "built-in",
+ visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900)
+ ),
+ WorkspaceDisplayGeometry(
+ identifier: "external",
+ visibleFrame: CGRect(x: -1920, y: 0, width: 1920, height: 1080)
+ ),
+ ]
+ ))
+
+ XCTAssertEqual(restored.origin.x, -1800, accuracy: 0.5)
+ XCTAssertEqual(restored.origin.y, 80, accuracy: 0.5)
+ XCTAssertEqual(restored.size, state.rect.size)
+ }
+
+ func testWindowPlacementFallsBackOnscreenWhenDisplayIsDisconnected() throws {
+ let state = WorkspaceWindowFrame(
+ CGRect(x: 1600, y: 100, width: 900, height: 650),
+ displayIdentifier: "disconnected",
+ displayFrame: CGRect(x: 1440, y: 0, width: 1920, height: 1080)
+ )
+ let restored = try XCTUnwrap(WorkspaceWindowPlacementResolver.resolve(
+ state,
+ displays: [WorkspaceDisplayGeometry(
+ identifier: "built-in",
+ visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900)
+ )]
+ ))
+
+ XCTAssertEqual(restored.origin.x, 160, accuracy: 0.5)
+ XCTAssertEqual(restored.origin.y, 100, accuracy: 0.5)
+ XCTAssertTrue(CGRect(x: 0, y: 0, width: 1440, height: 900).contains(restored))
+ }
+
+ func testScratchpadIsPrivateEditableAndCanBecomeANamedMarkdownFile() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("margin-scratch-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let suite = "margin-scratch-defaults-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suite) else {
+ return XCTFail("Expected isolated defaults")
+ }
+ defer { defaults.removePersistentDomain(forName: suite) }
+
+ let scratchStore = ScratchpadStore(
+ rootURL: directory.appendingPathComponent("private", isDirectory: true),
+ defaults: defaults,
+ counterKey: "counter"
+ )
+ let descriptor = try scratchStore.create()
+ let controller = WorkspaceWindowController(
+ workspaceURL: nil,
+ scratchpad: descriptor,
+ scratchpadStore: scratchStore
+ )
+ defer { controller.close() }
+ controller.showWindow(nil)
+
+ XCTAssertTrue(controller.isScratchpad)
+ XCTAssertEqual(controller.window?.title, "Untitled 1")
+ XCTAssertNil(controller.window?.representedURL)
+ XCTAssertEqual(controller.sessionState?.scratchpadID, descriptor.id)
+ XCTAssertEqual(
+ controller.sessionState?.restorationIdentifier,
+ controller.restorationIdentifier
+ )
+ XCTAssertEqual(
+ controller.window?.identifier,
+ WorkspaceRestorationIdentifier.make(controller.restorationIdentifier)
+ )
+ XCTAssertFalse(controller.window?.isRestorable == true)
+ XCTAssertNil(controller.window?.restorationClass)
+
+ let textView = try XCTUnwrap(descendantTextView(in: controller.window?.contentView))
+ waitUntil { textView.isEditable }
+ textView.insertText("# Durable scratch\n", replacementRange: NSRange(location: 0, length: 0))
+ let scratchURL = scratchStore.url(for: descriptor.id)
+ XCTAssertTrue(controller.prepareForApplicationTermination())
+ XCTAssertEqual(
+ try String(contentsOf: scratchURL, encoding: .utf8),
+ "# Durable scratch\n"
+ )
+
+ let destination = directory.appendingPathComponent("named.md")
+ XCTAssertTrue(controller.saveScratchpad(to: destination))
+ XCTAssertFalse(controller.isScratchpad)
+ XCTAssertFalse(scratchStore.exists(descriptor))
+ XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "# Durable scratch\n")
+ XCTAssertEqual(controller.window?.representedURL, destination.standardizedFileURL)
+ }
+
+ func testLegacyFlatSessionMigratesToOneWindow() throws {
+ let suite = "margin-legacy-session-tests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suite) else {
+ return XCTFail("Expected isolated defaults")
+ }
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let tab = WorkspaceTabSession(
+ workspacePath: "/tmp/legacy.md",
+ documentPath: "/tmp/legacy.md",
+ readerMode: false,
+ navigatorVisible: false,
+ commentsVisible: false,
+ editor: .beginning
+ )
+ let tabJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(tab))
+ let data = try JSONSerialization.data(withJSONObject: [
+ "version": 1,
+ "tabs": [tabJSON],
+ "selectedIndex": 0,
+ ])
+ defaults.set(data, forKey: "session")
+
+ let migrated = try XCTUnwrap(
+ WorkspaceSessionStore(defaults: defaults, key: "session").load()
+ )
+ XCTAssertEqual(migrated.windows.count, 1)
+ XCTAssertEqual(migrated.windows[0].tabs, [tab])
+ XCTAssertEqual(migrated.windows[0].selectedIndex, 0)
+ }
+
+ func testSessionCapturePreservesIndependentWindowsAndTheirTabGroups() throws {
+ let originalWindows = Set(NSApplication.shared.windows.map(ObjectIdentifier.init))
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("margin-window-session-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
+ defer {
+ NSApplication.shared.windows
+ .filter { !originalWindows.contains(ObjectIdentifier($0)) }
+ .forEach { $0.close() }
+ try? FileManager.default.removeItem(at: directory)
+ }
+ let suite = "margin-window-session-defaults-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suite) else {
+ return XCTFail("Expected isolated defaults")
+ }
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let scratchStore = ScratchpadStore(
+ rootURL: directory.appendingPathComponent("scratch", isDirectory: true),
+ defaults: defaults,
+ counterKey: "counter"
+ )
+ let delegate = AppDelegate(
+ sessionStore: WorkspaceSessionStore(defaults: defaults, key: "session"),
+ scratchpadStore: scratchStore
+ )
+
+ delegate.newWindow(nil)
+ delegate.newWindowForTab(nil)
+ var captured = try XCTUnwrap(delegate.workspaceSessionForTesting)
+ XCTAssertEqual(captured.windows.count, 1)
+ XCTAssertEqual(captured.windows[0].tabs.count, 2)
+
+ delegate.newWindow(nil)
+ captured = try XCTUnwrap(delegate.workspaceSessionForTesting)
+ XCTAssertEqual(captured.windows.count, 2)
+ XCTAssertEqual(captured.windows.map { $0.tabs.count }.sorted(), [1, 2])
+ XCTAssertEqual(captured.windows.flatMap(\.tabs).compactMap(\.scratchpadID).count, 3)
+ let restorationIdentifiers = captured.windows
+ .flatMap(\.tabs)
+ .compactMap(\.restorationIdentifier)
+ XCTAssertEqual(restorationIdentifiers.count, 3)
+ XCTAssertEqual(Set(restorationIdentifiers).count, 3)
+ XCTAssertNotNil(captured.windows[0].frame?.displayIdentifier)
+ }
+
func testUnreadCommentBadgeIsLazyAndPersistsUntilActivityIsRead() throws {
let fixture = try makeDocument("# Review\n\nA passage.\n")
defer { try? FileManager.default.removeItem(at: fixture.directory) }
@@ -316,6 +668,24 @@ final class WorkspaceBehaviorTests: XCTestCase {
XCTAssertEqual(store.urls(limit: 10), [first, second])
}
+ func testRecentWorkspaceStoreCanDisablePersistenceForBenchmarks() {
+ let suite = "margin-recents-disabled-tests-\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suite) else {
+ return XCTFail("Expected isolated defaults")
+ }
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let store = RecentWorkspaceStore(
+ defaults: defaults,
+ key: "recents",
+ persistenceEnabled: false
+ )
+
+ store.record(URL(fileURLWithPath: "/tmp/benchmark.md"))
+
+ XCTAssertNil(defaults.object(forKey: "recents"))
+ XCTAssertTrue(store.urls().isEmpty)
+ }
+
func testCollaborationOverviewIsLazyAndLoadsDurableActorActivityOnDemand() throws {
let fixture = try makeDocument("# Collaboration\n\nA shared boundary.\n")
defer { try? FileManager.default.removeItem(at: fixture.directory) }
@@ -369,6 +739,24 @@ final class WorkspaceBehaviorTests: XCTestCase {
wait(for: [expectation], timeout: timeout + 0.2)
}
+ private func spaceDescriptor(
+ id: UInt64,
+ uuid: String,
+ display: String = "display-a",
+ ordinal: Int?,
+ type: Int = 0,
+ isActive: Bool = false
+ ) -> WorkspaceSpaceDescriptor {
+ WorkspaceSpaceDescriptor(
+ managedSpaceID: id,
+ spaceUUID: uuid,
+ displayIdentifier: display,
+ desktopOrdinal: ordinal,
+ spaceType: type,
+ isActive: isActive
+ )
+ }
+
private func makeDocument(_ source: String) throws -> (directory: URL, file: URL) {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("margin-workspace-tests-\(UUID().uuidString)", isDirectory: true)
@@ -395,4 +783,10 @@ final class WorkspaceBehaviorTests: XCTestCase {
let own = (view as? NSTextField).map { [$0.stringValue] } ?? []
return own + view.subviews.flatMap { descendantText(in: $0) }
}
+
+ private func descendantTextView(in view: NSView?) -> NSTextView? {
+ guard let view else { return nil }
+ if let textView = view as? NSTextView { return textView }
+ return view.subviews.lazy.compactMap { descendantTextView(in: $0) }.first
+ }
}