From cf97f698166b5038c55d21abe61d953f1828cfb1 Mon Sep 17 00:00:00 2001
From: jeanmonet <15112920+jeanmonet@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:00:00 +0300
Subject: [PATCH] Add persistent scratchpads and window sessions
Signed-off-by: jeanmonet <15112920+jeanmonet@users.noreply.github.com>
---
DESIGN.md | 7 +-
Docs/ARCHITECTURE.md | 16 +-
Docs/RELEASE_NOTES.md | 13 +-
README.md | 2 +
Resources/Info.plist | 2 +
Sources/MarginApp/AppDelegate.swift | 353 ++++++++++++++++--
Sources/MarginApp/AppMenu.swift | 9 +
Sources/MarginApp/EditorViewController.swift | 52 ++-
Sources/MarginApp/ScratchpadStore.swift | 89 +++++
Sources/MarginApp/WorkspaceSessionStore.swift | 158 +++++++-
.../MarginApp/WorkspaceWindowController.swift | 236 +++++++++++-
.../WorkspaceBehaviorTests.swift | 263 ++++++++++++-
12 files changed, 1122 insertions(+), 78 deletions(-)
create mode 100644 Sources/MarginApp/ScratchpadStore.swift
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..7266c26 100644
--- a/Docs/RELEASE_NOTES.md
+++ b/Docs/RELEASE_NOTES.md
@@ -2,7 +2,18 @@
## Unreleased
-No changes yet.
+- 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/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..818530e 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -32,6 +32,8 @@
Copyright © 2026 OpenProse, Inc.
NSPrincipalClass
NSApplication
+ NSQuitAlwaysKeepsWindows
+
CFBundleDocumentTypes
diff --git a/Sources/MarginApp/AppDelegate.swift b/Sources/MarginApp/AppDelegate.swift
index 09ec11b..28404f5 100644
--- a/Sources/MarginApp/AppDelegate.swift
+++ b/Sources/MarginApp/AppDelegate.swift
@@ -1,6 +1,51 @@
import AppKit
+import ColorSync
import MarginCore
+enum WorkspaceRestorationIdentifier {
+ private static let prefix = "ink.margin.workspace."
+
+ static func make(_ value: String) -> NSUserInterfaceItemIdentifier {
+ NSUserInterfaceItemIdentifier(prefix + value)
+ }
+
+ static func parse(_ identifier: NSUserInterfaceItemIdentifier) -> String? {
+ guard identifier.rawValue.hasPrefix(prefix) else { return nil }
+ let value = String(identifier.rawValue.dropFirst(prefix.count))
+ return value.isEmpty ? nil : 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
+ }
+}
+
+@MainActor
+final class WorkspaceWindowRestorationHandler: NSObject, NSWindowRestoration {
+ static func restoreWindow(
+ withIdentifier identifier: NSUserInterfaceItemIdentifier,
+ state: NSCoder,
+ completionHandler: @escaping (NSWindow?, Error?) -> Void
+ ) {
+ guard let delegate = NSApplication.shared.delegate as? AppDelegate else {
+ completionHandler(nil, nil)
+ return
+ }
+ completionHandler(delegate.restoreWorkspaceWindow(withIdentifier: identifier), nil)
+ }
+}
+
protocol WorkspacePathRenameParticipating: AnyObject {
var documentURLForPathRename: URL? { get }
func prepareForPathRename(from sourceURL: URL) -> Bool
@@ -138,15 +183,36 @@ 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 sessionPersistenceQueue = DispatchQueue(
label: "ink.margin.session-persistence",
qos: .utility
)
private var isRestoringSession = false
private var isSessionPersistScheduled = false
+ private var terminationSession: WorkspaceSession?
+ private var nativeRestorationSession: WorkspaceSession?
+ private var nativeRestoredControllers: [String: WorkspaceWindowController] = [:]
+
+ override init() {
+ sessionStore = WorkspaceSessionStore()
+ scratchpadStore = ScratchpadStore()
+ super.init()
+ }
+
+ init(sessionStore: WorkspaceSessionStore, scratchpadStore: ScratchpadStore) {
+ self.sessionStore = sessionStore
+ self.scratchpadStore = scratchpadStore
+ super.init()
+ }
func applicationWillFinishLaunching(_ notification: Notification) {
+ // Margin owns durable document continuity and always reopens its
+ // workspace. Register the matching AppKit preference as well so a
+ // normal Quit preserves native window state even when the system-wide
+ // “close windows when quitting” setting is enabled.
+ UserDefaults.standard.register(defaults: ["NSQuitAlwaysKeepsWindows": true])
WorkspacePaneFactory.makeEditor = { EditorViewController() }
WorkspacePaneFactory.makeComments = { CommentsViewController() }
AppMenu.install(for: NSApplication.shared, delegate: self)
@@ -157,8 +223,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
let startupURLs = pendingURLs.isEmpty ? commandLineURLs() : pendingURLs
pendingURLs.removeAll()
+ let didRestoreNatively = !nativeRestoredControllers.isEmpty
- if startupURLs.isEmpty, restoreLastSession() {
+ if startupURLs.isEmpty, didRestoreNatively {
+ // AppKit has recreated and reconfigured every registered window,
+ // including its native tab-group placement.
+ } else 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.
@@ -167,6 +237,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
} else {
open(startupURLs)
}
+ if didRestoreNatively {
+ isRestoringSession = false
+ nativeRestoredControllers.removeAll()
+ nativeRestorationSession = nil
+ }
NSApplication.shared.activate(ignoringOtherApps: true)
}
@@ -197,8 +272,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
persistSession()
}
- func applicationWillTerminate(_ notification: Notification) {
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard workspaceWindows.allSatisfy({ $0.prepareForApplicationTermination() }) else {
+ terminationSession = nil
+ return .terminateCancel
+ }
+ terminationSession = captureWorkspaceSession()
persistSession(synchronously: true)
+ workspaceWindows.forEach {
+ $0.invalidateRestorableState()
+ $0.window?.invalidateRestorableState()
+ }
+ sender.invalidateRestorableState()
+ return .terminateNow
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ if let terminationSession {
+ sessionPersistenceQueue.sync { [sessionStore] in
+ sessionStore.save(terminationSession)
+ }
+ } else {
+ persistSession(synchronously: true)
+ }
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
@@ -221,11 +317,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 +534,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,
@@ -598,22 +700,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
NSApplication.shared.activate(ignoringOtherApps: true)
}
+ 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,
+ 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 +781,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
}
@@ -887,38 +1018,118 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
}
+ func restoreWorkspaceWindow(
+ withIdentifier identifier: NSUserInterfaceItemIdentifier
+ ) -> NSWindow? {
+ guard commandLineURLs().isEmpty,
+ let restorationIdentifier = WorkspaceRestorationIdentifier.parse(identifier)
+ else { return nil }
+ if let existing = nativeRestoredControllers[restorationIdentifier] {
+ return existing.window
+ }
+
+ let session: WorkspaceSession
+ if let nativeRestorationSession {
+ session = nativeRestorationSession
+ } else {
+ guard let loaded = sessionStore.load() else { return nil }
+ nativeRestorationSession = loaded
+ session = loaded
+ }
+ guard let state = session.windows
+ .flatMap(\.tabs)
+ .first(where: { $0.restorationIdentifier == restorationIdentifier }),
+ isRestorable(state) else { return nil }
+
+ isRestoringSession = true
+ let controller = makeWorkspaceWindow(
+ for: nil,
+ restorationState: state,
+ restorationIdentifier: restorationIdentifier,
+ ordersFront: false
+ )
+ nativeRestoredControllers[restorationIdentifier] = controller
+ return controller.window
+ }
+
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
+ ))
+ }
+ 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
)
+ 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
+ )
+ }
+ 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
)
- controllers.append(controller)
- tabAnchor = controller.window
+ if let selectedWindow = controllers[selectedIndex].window {
+ selectedWindow.tabGroup?.selectedWindow = selectedWindow
+ }
+ restoredWindows.append(controllers)
}
- let selected = requestedSelectedState.flatMap { tabs.firstIndex(of: $0) }
- ?? min(max(session.selectedIndex, 0), controllers.count - 1)
- self.focus(controllers[selected])
+
+ let selectedWindowIndex = min(
+ max(restoredSelectedWindowIndex, 0),
+ restoredWindows.count - 1
+ )
+ let selectedWindowState = windows[selectedWindowIndex]
+ let selectedTabIndex = min(
+ max(selectedWindowState.selectedIndex, 0),
+ restoredWindows[selectedWindowIndex].count - 1
+ )
+ self.focus(restoredWindows[selectedWindowIndex][selectedTabIndex])
self.isRestoringSession = false
self.persistSession()
}
@@ -927,14 +1138,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
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)
+ let session = captureWorkspaceSession()
let save = { [sessionStore] in sessionStore.save(session) }
if synchronously {
sessionPersistenceQueue.sync(execute: save)
@@ -943,8 +1147,77 @@ 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
+ }
+ windowSessions.append(WorkspaceWindowSession(
+ tabs: tabs,
+ selectedIndex: selectedIndex,
+ frame: WorkspaceWindowFrame(
+ selectedWindow.frame,
+ displayIdentifier: WorkspaceScreenIdentity.identifier(
+ for: selectedWindow.screen
+ ),
+ displayFrame: selectedWindow.screen?.visibleFrame
+ )
+ ))
+ }
+
+ 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/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/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..fb84615 100644
--- a/Sources/MarginApp/WorkspaceSessionStore.swift
+++ b/Sources/MarginApp/WorkspaceSessionStore.swift
@@ -26,12 +26,142 @@ 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?
}
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 +179,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..04acb9b 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,7 +1045,9 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
window.titlebarSeparatorStyle = .none
window.toolbarStyle = .unified
window.backgroundColor = MarginTheme.documentBackground
- window.isRestorable = false
+ window.identifier = WorkspaceRestorationIdentifier.make(restorationIdentifier)
+ window.restorationClass = WorkspaceWindowRestorationHandler.self
+ window.isRestorable = true
// Let AppKit own live resize and full-screen sizing. Explicit frame
// maxima are ignored by Auto Layout and can interact poorly with
// split-view fitting sizes; a content minimum is the native contract.
@@ -891,7 +1068,7 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
}
private func configureInitialWindowFrame(_ window: NSWindow) {
- let frameName = "MarginWorkspaceWindow.v3"
+ let frameName = "MarginWorkspaceWindow.v4.\(restorationIdentifier)"
let restored = window.setFrameUsingName(frameName)
let restoredFrame = window.frame
let restoredIsUsable = restored
@@ -1099,6 +1276,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 +1310,10 @@ final class WorkspaceWindowController: NSWindowController, NSWindowDelegate, NST
openStandaloneFile(rootURL)
}
+ restorePresentationState(state)
+ }
+
+ private func restorePresentationState(_ state: WorkspaceTabSession) {
if canShowNavigator {
navigatorItem.isCollapsed = !state.navigatorVisible
}
@@ -1221,6 +1416,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 +1890,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 +1939,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/Tests/MarginAppTests/WorkspaceBehaviorTests.swift b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
index 449b33f..558598e 100644
--- a/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
+++ b/Tests/MarginAppTests/WorkspaceBehaviorTests.swift
@@ -177,6 +177,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 +257,33 @@ 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)
)
)
],
- selectedIndex: 0
+ selectedWindowIndex: 0
)
store.save(value)
@@ -277,6 +292,222 @@ 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)
+ }
+
+ 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)
+ )
+ XCTAssertTrue(controller.window?.isRestorable == true)
+ XCTAssertTrue(
+ controller.window?.restorationClass === WorkspaceWindowRestorationHandler.self
+ )
+
+ 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) }
@@ -395,4 +626,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
+ }
}