Business Logic Model: Unit 2 — Document Management
Pipeline Overview
App Launch
|
v
[1] ShoechooApp Scene Setup
| DocumentGroup scene with MarkdownDocument
| Tabbed window support via .windowStyle
v
[2] Document Lifecycle (Create / Open / Save)
| NSDocument read/write pipeline
| EditorViewModel binding
v
[3] Auto-Save & Versions
| NSDocument autosaving-in-place
| macOS Versions integration
v
[4] EditorView Composition
| Toolbar + WYSIWYGTextView layout
| EditorSettings observation
v
[5] EditorSettings Persistence
| UserDefaults read/write
| Preferences window
v
[6] Recent Files
| NSDocumentController.shared.recentDocumentURLs
| File > Open Recent menu
[1] ShoechooApp Scene Setup
Trigger: App launch via @main entry point
Logic:
- Declare
DocumentGroupscene withMarkdownDocumentas the document type - Register
.mdUTType (conforming topublic.plain-text) - Set
windowStyle(.automatic)for native macOS tabbed window support - Register menu commands (File, Edit, Format, View)
- Apply
EditorSettings.shared.appearanceOverrideto the window’s appearance - On first launch with no restored windows: create one blank document
@main
struct ShoechooApp: App {
var body: some Scene {
DocumentGroup(newDocument: { MarkdownDocument() }) { config in
EditorView(document: config.document)
}
.commands {
FileCommands()
FormatCommands()
ViewCommands()
}
Settings {
PreferencesView()
}
}
}
[2] Document Lifecycle
Create New Document
Trigger: App launch (blank document) or Cmd+N
Logic:
NSDocumentController.shared.newDocument(nil)creates a newMarkdownDocumentMarkdownDocument.init()setssourceText = ""(blank)makeWindowControllers()creates the window andEditorViewModelEditorViewModelinitializes with empty source, applies defaults fromEditorSettings.shared:isFocusModeEnabled = EditorSettings.shared.defaultFocusModeisTypewriterScrollEnabled = EditorSettings.shared.defaultTypewriterScroll
- Document state is
.blank(untitled, never saved) defaultDraftName()returns localized “Untitled” string
Open Existing Document
Trigger: Cmd+O, File > Open, File > Open Recent, double-click .md file, drag onto Dock icon
Logic:
NSDocumentController.shared.openDocument(withContentsOf:display:completionHandler:)MarkdownDocument.read(from:ofType:)is called:- Decode
Dataas UTF-8 string - If decoding fails, try other encodings (Shift_JIS, EUC-JP) as fallback
- Store decoded string in
sourceText
- Decode
makeWindowControllers()creates window +EditorViewModelEditorViewModel.sourceTextis set, triggering Unit 1 parse pipeline- Document state is
.saved(url:) - URL is added to recent documents via
NSDocumentController.noteNewRecentDocumentURL()
Save Document (Cmd+S)
Trigger: Cmd+S or File > Save
Logic:
- If document is untitled (never saved): redirect to Save As (NSSavePanel)
MarkdownDocument.data(ofType:)is called:- Encode
sourceTextas UTF-8Data - Return data for NSDocument to write
- Encode
- NSDocument writes to the file URL
- Document state transitions to
.saved(url:) isDocumentEditedis set tofalse
Save As (Cmd+Shift+S)
Trigger: Cmd+Shift+S or File > Save As
Logic:
- NSDocument presents
NSSavePanelwith.mdfile extension filter - User selects destination URL
data(ofType:)encodes source text- NSDocument writes to the new URL
- Document’s
fileURLis updated to the new location - Previous file is NOT deleted (standard Save As behavior)
[3] Auto-Save & Versions
Trigger: Periodic auto-save by NSDocument (macOS default interval)
Logic:
MarkdownDocumentdeclaresoverride class var autosavesInPlace: Bool { true }- macOS calls
data(ofType:)automatically at save intervals - NSDocument manages version snapshots via macOS Versions:
- File > Revert To > Browse All Versions
- Time Machine integration
- During auto-save, the parse pipeline is NOT paused (auto-save reads
sourceTextwhich is always current) - If auto-save fails (disk full, permissions), NSDocument presents system error alert
Concurrency Note
data(ofType:)runs on the main thread (NSDocument requirement)sourceTextis always up-to-date becausetextDidChange()syncs immediately- No locking needed: single-writer (main thread) for sourceText
[4] EditorView Composition
Input: MarkdownDocument from DocumentGroup config
Logic:
EditorViewreceives theMarkdownDocumentbinding- Creates or reuses
EditorViewModel(owned by the document) - Layout structure:
EditorView (SwiftUI) +-------------------------------------------+ | Toolbar | | [H1] [H2] [B] [I] [K] [Code] [Link] .. | +-------------------------------------------+ | WYSIWYGTextView (NSViewRepresentable) | | | | (Full editor area from Unit 1) | | | +-------------------------------------------+ - Toolbar buttons invoke
EditorViewModelmethods (toggleBold, setHeading, etc.) EditorSettings.sharedis observed — font/spacing changes trigger re-render via Unit 1 pipeline- Appearance override applied via
.preferredColorScheme()orNSApp.appearance
[5] EditorSettings Persistence
Load (App Launch)
Logic:
EditorSettings.init()reads fromUserDefaults.standard- For each key in
EditorSettingsKey:- If value exists in UserDefaults: use it
- If missing: use factory default (see domain-entities.md)
- Settings object is immediately available as
EditorSettings.shared
Save (User Changes)
Logic:
- User modifies setting in Preferences window
@Observableproperty setter firessave()writes all values toUserDefaults.standard- All open
EditorViewinstances observe the change and re-render:- Font change: invalidate all render caches, full re-render
- Line spacing change: invalidate layout, re-render
- Appearance change: invalidate render caches (colors change), update window appearance
Reset
Logic:
- User clicks “Reset to Defaults” in Preferences
reset()sets all properties to factory defaultssave()persists factory defaults to UserDefaults- All open editors re-render
[6] Recent Files
Logic:
- macOS manages recent files automatically via
NSDocumentController - Opening a document calls
noteNewRecentDocumentURL()internally - File > Open Recent menu is populated by
NSDocumentController.shared.recentDocumentURLs - “Clear Menu” clears the recent list via
clearRecentDocuments(nil) - Maximum recent files count is managed by macOS (default: 10)
- If a recent file no longer exists on disk, selecting it shows a system “file not found” alert
Tabbed Windows
Logic:
- macOS native tab support is enabled via
DocumentGroupscene NSWindow.allowsAutomaticWindowTabbing = true(default for document-based apps)- Window > Merge All Windows merges open documents into tabs
- Window > Move Tab to New Window separates a tab
- Cmd+N opens a new tab in the current window (if tab bar is visible) or a new window
- Tab title shows the document’s
displayName(filename or “Untitled”) - Tab close button triggers save confirmation if document has unsaved changes
- Drag-and-drop tab reordering is handled by macOS natively
ViewModel-Document Binding
MarkdownDocument EditorViewModel
+------------------+ +------------------+
| sourceText | <--- sync --> | sourceText |
| fileURL | | document (weak) |
+------------------+ +------------------+
| |
v v
NSDocument auto-save Unit 1 Parse Pipeline
reads sourceText reads sourceText
via data(ofType:) via textDidChange()
Sync Strategy:
- On document open:
MarkdownDocument.sourceText→EditorViewModel.sourceText(one-time push) - On every text edit:
EditorViewModel.textDidChange()updates bothEditorViewModel.sourceTextandMarkdownDocument.sourceText - On auto-save:
MarkdownDocument.data(ofType:)reads its ownsourceText(already current) - No bidirectional binding needed — edits always flow from the text view through the ViewModel to the Document