Sources/Orgstar/OrgstarApp.swift
172 lines · 6700 bytes
1import AppKit
2import OrgApp
3import OrgCore
4import OrgEditorAppKit
5import SwiftUI
6
7/// Window-level requests the menu bar makes.
8@MainActor
9@Observable
10final class AppCommands {
11 var showingQuickOpen = false
12 var showingFolderPicker = false
13 var focusSearch = false
14 var showingConflict = false
15 var showingPalette = false
16 /// The editor showing the open file, for menus and the palette.
17 var editor: OrgEditor?
18 /// The pending key prefix, and what can follow it.
19 var pendingKeys: String?
20 var keyHints: [KeyHint] = []
21 /// A question a command asked, answered in the echo area.
22 private(set) var prompt: PromptRequest?
23 /// The last message from a command or the keymap, cleared after a few seconds.
24 private(set) var message: String?
25 private var messageID = 0
26
27 func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
28 self.prompt = PromptRequest(prompt: prompt, reply: reply)
29 }
30
31 /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
32 func answer(_ text: String?) {
33 guard let request = prompt else { return }
34 prompt = nil
35 if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
36 request.reply(text)
37 }
38
39 func show(_ text: String) {
40 messageID += 1
41 let id = messageID
42 message = text
43 Task {
44 try? await Task.sleep(for: .seconds(4))
45 if messageID == id { message = nil }
46 }
47 }
48}
49
50struct PromptRequest: Identifiable {
51 let id = UUID()
52 let prompt: Prompt
53 let reply: (String?) -> Void
54}
55
56@MainActor
57final class AppDelegate: NSObject, NSApplicationDelegate {
58 var session: DocumentSession?
59
60 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
61
62 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
63 guard let session else { return .terminateNow }
64 return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel
65 }
66}
67
68/// Unsaved edits when leaving a file or quitting.
69@MainActor
70enum UnsavedChanges {
71 /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
72 /// explicit mode. A save that fails or conflicts keeps the file open.
73 static func resolve(_ session: DocumentSession) -> Bool {
74 guard session.isDirty else { return true }
75 if session.saveMode == .automatic { return session.save() }
76 let alert = NSAlert()
77 alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
78 alert.informativeText = "Your changes will be lost if you don't save them."
79 alert.addButton(withTitle: "Save")
80 alert.addButton(withTitle: "Don't Save")
81 alert.addButton(withTitle: "Cancel")
82 switch alert.runModal() {
83 case .alertFirstButtonReturn: return session.save()
84 case .alertSecondButtonReturn: return true
85 default: return false
86 }
87 }
88}
89
90@main
91struct OrgstarApp: App {
92 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
93 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
94 @State private var commands = AppCommands()
95 @State private var session = DocumentSession()
96
97 var body: some Scene {
98 Window("Orgstar", id: "main") {
99 switch workspace {
100 case .success(let model):
101 ContentView(workspace: model, session: session, commands: commands)
102 .onAppear { delegate.session = session }
103 case .failure(let error):
104 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
105 }
106 }
107 .defaultSize(width: 1280, height: 860)
108 .commands {
109 CommandGroup(after: .newItem) {
110 Button("Add Folder…") { commands.showingFolderPicker = true }
111 .keyboardShortcut("o", modifiers: [.command, .shift])
112 Button("Quick Open…") { commands.showingQuickOpen = true }
113 .keyboardShortcut("p")
114 Button("Command Palette…") { commands.showingPalette = true }
115 .keyboardShortcut("p", modifiers: [.command, .shift])
116 }
117 CommandGroup(replacing: .saveItem) {
118 Button("Save") {
119 if !session.save(), session.conflict != nil { commands.showingConflict = true }
120 }
121 .keyboardShortcut("s")
122 .disabled(session.path == nil || session.isReadOnly)
123 }
124 CommandGroup(after: .textEditing) {
125 Button("Search Notes") { commands.focusSearch = true }
126 .keyboardShortcut("f", modifiers: [.command, .shift])
127 }
128 }
129 .commands {
130 CommandMenu("Org") {
131 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
132 Button(command.title) { commands.editor?.run(command.id) }
133 .disabled(commands.editor == nil)
134 }
135 }
136 }
137 Settings {
138 SettingsView()
139 }
140 }
141}
142
143struct SettingsView: View {
144 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
145 @AppStorage("keymap") private var preset = KeymapPreset.emacs
146 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
147
148 var body: some View {
149 Form {
150 Picker("Save files:", selection: $saveMode) {
151 Text("Automatically, when typing stops").tag(SaveMode.automatic)
152 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
153 }
154 .pickerStyle(.radioGroup)
155 Picker("Keys:", selection: $preset) {
156 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
157 }
158 Picker("Option as Meta:", selection: $optionAsMeta) {
159 Text("Left Option").tag(OptionAsMeta.left.rawValue)
160 Text("Right Option").tag(OptionAsMeta.right.rawValue)
161 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
162 Text("Neither").tag(0)
163 }
164 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
165 .font(.caption)
166 .foregroundStyle(.secondary)
167 .textSelection(.enabled)
168 }
169 .padding(20)
170 .frame(width: 420)
171 }
172}