Commit 87e24faa39
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +13
| @@ -115,6 +115,19 @@ public final class DocumentSession { | |||
| 115 | } | 115 | } |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | /// Closes the open file without saving, as when it was deleted. | ||
| 119 | public func close() { | ||
| 120 | autosave?.cancel() | ||
| 121 | outlineUpdate?.cancel() | ||
| 122 | path = nil | ||
| 123 | buffer = nil | ||
| 124 | document = nil | ||
| 125 | outline = [] | ||
| 126 | isDirty = false | ||
| 127 | conflict = nil | ||
| 128 | revealRequest = nil | ||
| 129 | } | ||
| 130 | |||
| 118 | /// Makes `buffer`, built from `document`, the live state of the open file. | 131 | /// Makes `buffer`, built from `document`, the live state of the open file. |
| 119 | public func attach(_ buffer: DocumentBuffer) { | 132 | public func attach(_ buffer: DocumentBuffer) { |
| 120 | self.buffer = buffer | 133 | self.buffer = buffer |
Sources/OrgApp/WorkspaceModel.swift +60
| @@ -185,6 +185,66 @@ public final class WorkspaceModel { | |||
| 185 | return url | 185 | return url |
| 186 | } | 186 | } |
| 187 | 187 | ||
| 188 | // MARK: - Files | ||
| 189 | |||
| 190 | public struct FileError: LocalizedError { | ||
| 191 | public let message: String | ||
| 192 | public var errorDescription: String? { message } | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Creates an empty file at `name` (a name or a relative path; `.org` is added when it has | ||
| 196 | /// no extension) inside `folder`, which must be in a root. Returns its path. | ||
| 197 | public func createFile(named name: String, in folder: String) async throws -> String { | ||
| 198 | let trimmed = name.trimmingCharacters(in: .whitespaces) | ||
| 199 | guard !trimmed.isEmpty, !trimmed.hasPrefix("/"), !trimmed.split(separator: "/").contains(where: { $0 == ".." }) else { | ||
| 200 | throw FileError(message: "Not a file name: \(name)") | ||
| 201 | } | ||
| 202 | var url = URL(fileURLWithPath: folder).appendingPathComponent(trimmed) | ||
| 203 | if url.pathExtension.isEmpty { url.appendPathExtension("org") } | ||
| 204 | guard rootURL(containing: url.path) != nil else { throw FileError(message: "\(folder) isn't in a folder you added.") } | ||
| 205 | guard !FileManager.default.fileExists(atPath: url.path) else { throw FileError(message: "\(url.lastPathComponent) already exists.") } | ||
| 206 | try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 207 | try Data().write(to: url, options: .withoutOverwriting) | ||
| 208 | await rescan(containing: url.path) | ||
| 209 | return url.standardizedFileURL.path | ||
| 210 | } | ||
| 211 | |||
| 212 | /// Renames a file or folder in place. Returns the new path. | ||
| 213 | public func rename(_ path: String, to name: String) async throws -> String { | ||
| 214 | let trimmed = name.trimmingCharacters(in: .whitespaces) | ||
| 215 | guard !trimmed.isEmpty, !trimmed.contains("/"), trimmed != ".", trimmed != ".." else { | ||
| 216 | throw FileError(message: "Not a file name: \(name)") | ||
| 217 | } | ||
| 218 | let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(trimmed) | ||
| 219 | guard target.path != path else { return path } | ||
| 220 | guard !FileManager.default.fileExists(atPath: target.path) else { throw FileError(message: "\(trimmed) already exists.") } | ||
| 221 | try FileManager.default.moveItem(atPath: path, toPath: target.path) | ||
| 222 | await rescan(containing: path) | ||
| 223 | return target.standardizedFileURL.path | ||
| 224 | } | ||
| 225 | |||
| 226 | /// How `trash` disposes of a file; tests replace it so nothing reaches the real Trash. | ||
| 227 | @ObservationIgnored public var trasher: @Sendable (URL) throws -> Void = { url in | ||
| 228 | try FileManager.default.trashItem(at: url, resultingItemURL: nil) | ||
| 229 | } | ||
| 230 | |||
| 231 | /// Moves a file or folder to the Trash. | ||
| 232 | public func trash(_ path: String) async throws { | ||
| 233 | try trasher(URL(fileURLWithPath: path)) | ||
| 234 | await rescan(containing: path) | ||
| 235 | } | ||
| 236 | |||
| 237 | private func rootURL(containing path: String) -> URL? { | ||
| 238 | resolvedRoots.first { path == $0.key || path.hasPrefix($0.key + "/") }?.value | ||
| 239 | } | ||
| 240 | |||
| 241 | /// Brings the index up to date for the root holding `path` now, rather than when the | ||
| 242 | /// file events arrive. | ||
| 243 | private func rescan(containing path: String) async { | ||
| 244 | guard let root = rootURL(containing: path) else { return } | ||
| 245 | await filesChanged([], rescan: [root]) | ||
| 246 | } | ||
| 247 | |||
| 188 | // MARK: - Queries | 248 | // MARK: - Queries |
| 189 | 249 | ||
| 190 | public func tree(forRoot path: String) -> FileTreeNode { | 250 | public func tree(forRoot path: String) -> FileTreeNode { |
Sources/Orgstar/ContentView.swift +10 −1
| @@ -115,7 +115,7 @@ struct ContentView: View { | |||
| 115 | 115 | ||
| 116 | private var main: some View { | 116 | private var main: some View { |
| 117 | NavigationSplitView { | 117 | NavigationSplitView { |
| 118 | Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true } | 118 | Sidebar(workspace: workspace, session: session, selection: $selectedFile) { commands.showingFolderPicker = true } |
| 119 | .navigationSplitViewColumnWidth(min: 200, ideal: 260) | 119 | .navigationSplitViewColumnWidth(min: 200, ideal: 260) |
| 120 | } detail: { | 120 | } detail: { |
| 121 | HSplitView { | 121 | HSplitView { |
| @@ -427,11 +427,16 @@ struct ContentView: View { | |||
| 427 | 427 | ||
| 428 | struct Sidebar: View { | 428 | struct Sidebar: View { |
| 429 | let workspace: WorkspaceModel | 429 | let workspace: WorkspaceModel |
| 430 | let session: DocumentSession | ||
| 430 | @Binding var selection: String? | 431 | @Binding var selection: String? |
| 431 | var addFolder: () -> Void = {} | 432 | var addFolder: () -> Void = {} |
| 432 | /// Folders shown collapsed, by path, one per line. | 433 | /// Folders shown collapsed, by path, one per line. |
| 433 | @AppStorage("collapsedFolders") private var collapsedText = "" | 434 | @AppStorage("collapsedFolders") private var collapsedText = "" |
| 434 | @State private var removing: String? | 435 | @State private var removing: String? |
| 436 | /// A file operation waiting for a name or a confirmation. | ||
| 437 | @State var pending: FileOperation? | ||
| 438 | @State var name = "" | ||
| 439 | @State var failure: String? | ||
| 435 | 440 | ||
| 436 | private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) } | 441 | private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) } |
| 437 | 442 | ||
| @@ -453,6 +458,7 @@ struct Sidebar: View { | |||
| 453 | Label(node.name, systemImage: icon(node)) | 458 | Label(node.name, systemImage: icon(node)) |
| 454 | .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary) | 459 | .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary) |
| 455 | .tag(node.file?.path) | 460 | .tag(node.file?.path) |
| 461 | .contextMenu { fileMenu(node) } | ||
| 456 | } | 462 | } |
| 457 | } header: { | 463 | } header: { |
| 458 | Text((root as NSString).lastPathComponent) | 464 | Text((root as NSString).lastPathComponent) |
| @@ -496,10 +502,13 @@ struct Sidebar: View { | |||
| 496 | } message: { | 502 | } message: { |
| 497 | Text("The folder and its files stay on disk.") | 503 | Text("The folder and its files stay on disk.") |
| 498 | } | 504 | } |
| 505 | .fileOperations(self) | ||
| 499 | } | 506 | } |
| 500 | 507 | ||
| 501 | @ViewBuilder | 508 | @ViewBuilder |
| 502 | private func folderMenu(_ root: String) -> some View { | 509 | private func folderMenu(_ root: String) -> some View { |
| 510 | Button("New File…") { ask(.create(folder: root)) } | ||
| 511 | Divider() | ||
| 503 | Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() } | 512 | Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() } |
| 504 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) } | 513 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) } |
| 505 | Divider() | 514 | Divider() |
Sources/Orgstar/SidebarFiles.swift added +113
| @@ -0,0 +1,113 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgApp | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Creating, renaming and trashing files from the sidebar. | ||
| 6 | enum FileOperation: Equatable { | ||
| 7 | case create(folder: String) | ||
| 8 | case rename(path: String) | ||
| 9 | case trash(path: String) | ||
| 10 | } | ||
| 11 | |||
| 12 | extension Sidebar { | ||
| 13 | @ViewBuilder | ||
| 14 | func fileMenu(_ node: FileTreeNode) -> some View { | ||
| 15 | let folder = node.file == nil ? node.path : (node.path as NSString).deletingLastPathComponent | ||
| 16 | Button("New File…") { ask(.create(folder: folder)) } | ||
| 17 | Divider() | ||
| 18 | Button("Rename…") { ask(.rename(path: node.path)) } | ||
| 19 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: node.path)]) } | ||
| 20 | Divider() | ||
| 21 | Button("Move to Trash…") { ask(.trash(path: node.path)) } | ||
| 22 | } | ||
| 23 | |||
| 24 | func ask(_ operation: FileOperation) { | ||
| 25 | switch operation { | ||
| 26 | case .create: name = "" | ||
| 27 | case .rename(let path): name = (path as NSString).lastPathComponent | ||
| 28 | case .trash: break | ||
| 29 | } | ||
| 30 | pending = operation | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Whether `path` is the open file or a folder holding it. | ||
| 34 | func holdsOpenFile(_ path: String) -> Bool { | ||
| 35 | guard let open = session.path else { return false } | ||
| 36 | return open == path || open.hasPrefix(path + "/") | ||
| 37 | } | ||
| 38 | |||
| 39 | func finish() { | ||
| 40 | guard let operation = pending else { return } | ||
| 41 | pending = nil | ||
| 42 | Task { | ||
| 43 | do { | ||
| 44 | switch operation { | ||
| 45 | case .create(let folder): | ||
| 46 | selection = try await workspace.createFile(named: name, in: folder) | ||
| 47 | case .rename(let path): | ||
| 48 | let affected = holdsOpenFile(path) | ||
| 49 | // The open file is saved first, so the rename carries its edits. | ||
| 50 | if affected, !UnsavedChanges.resolve(session) { return } | ||
| 51 | let renamed = try await workspace.rename(path, to: name) | ||
| 52 | if affected, let open = session.path { | ||
| 53 | let moved = renamed + open.dropFirst(path.count) | ||
| 54 | session.open(moved) | ||
| 55 | selection = moved | ||
| 56 | } | ||
| 57 | case .trash(let path): | ||
| 58 | if holdsOpenFile(path) { | ||
| 59 | session.close() | ||
| 60 | selection = nil | ||
| 61 | } | ||
| 62 | try await workspace.trash(path) | ||
| 63 | } | ||
| 64 | } catch { | ||
| 65 | failure = error.localizedDescription | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | extension View { | ||
| 72 | func fileOperations(_ sidebar: Sidebar) -> some View { | ||
| 73 | let names: Binding<String> = sidebar.$name | ||
| 74 | return self | ||
| 75 | .alert(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == true } set: { if !$0 { sidebar.pending = nil } }) { | ||
| 76 | TextField("Name", text: names) | ||
| 77 | Button(sidebar.pending.map { if case .create = $0 { "Create" } else { "Rename" } } ?? "OK") { sidebar.finish() } | ||
| 78 | Button("Cancel", role: .cancel) { sidebar.pending = nil } | ||
| 79 | } message: { | ||
| 80 | if case .create = sidebar.pending { Text("A name without an extension gets .org. Use folder/name to make folders.") } | ||
| 81 | } | ||
| 82 | .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) { | ||
| 83 | Button("Move to Trash", role: .destructive) { sidebar.finish() } | ||
| 84 | } message: { | ||
| 85 | if case .trash(let path) = sidebar.pending, sidebar.holdsOpenFile(path), sidebar.session.isDirty { | ||
| 86 | Text("Unsaved changes to the open file will be lost.") | ||
| 87 | } else { | ||
| 88 | Text("You can put it back from the Trash in Finder.") | ||
| 89 | } | ||
| 90 | } | ||
| 91 | .alert("Couldn't change the file", isPresented: Binding { sidebar.failure != nil } set: { if !$0 { sidebar.failure = nil } }) { | ||
| 92 | Button("OK") { sidebar.failure = nil } | ||
| 93 | } message: { | ||
| 94 | Text(sidebar.failure ?? "") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | private func title(_ operation: FileOperation?) -> String { | ||
| 99 | switch operation { | ||
| 100 | case .create(let folder): "New file in “\((folder as NSString).lastPathComponent)”" | ||
| 101 | case .rename(let path): "Rename “\((path as NSString).lastPathComponent)”" | ||
| 102 | case .trash(let path): "Move “\((path as NSString).lastPathComponent)” to the Trash?" | ||
| 103 | case nil: "" | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | extension FileOperation { | ||
| 109 | var needsName: Bool { | ||
| 110 | if case .trash = self { return false } | ||
| 111 | return true | ||
| 112 | } | ||
| 113 | } | ||
Tests/OrgAppTests/AppTests.swift +25
| @@ -74,6 +74,31 @@ struct WorkspaceModelTests { | |||
| 74 | #expect(reopened.search("apples").isEmpty) | 74 | #expect(reopened.search("apples").isEmpty) |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | @Test func createRenameAndTrashFiles() async throws { | ||
| 78 | let notes = try Folder() | ||
| 79 | try notes.write("a.org", "* Alpha\n") | ||
| 80 | let model = try WorkspaceModel(directory: nil) | ||
| 81 | model.trasher = { try FileManager.default.removeItem(at: $0) } | ||
| 82 | try await model.addRoot(notes.url) | ||
| 83 | let root = try #require(model.rootPaths.first) | ||
| 84 | |||
| 85 | let created = try await model.createFile(named: "projects/plan", in: root) | ||
| 86 | #expect(created == root + "/projects/plan.org") | ||
| 87 | #expect(model.files.map(\.path).contains(created)) | ||
| 88 | await #expect(throws: WorkspaceModel.FileError.self) { try await model.createFile(named: "projects/plan.org", in: root) } | ||
| 89 | await #expect(throws: WorkspaceModel.FileError.self) { try await model.createFile(named: "../escape", in: root) } | ||
| 90 | |||
| 91 | let renamed = try await model.rename(created, to: "roadmap.org") | ||
| 92 | #expect(model.files.map(\.path).contains(renamed) && !model.files.map(\.path).contains(created)) | ||
| 93 | let folder = try await model.rename(root + "/projects", to: "work") | ||
| 94 | #expect(model.files.map(\.path).contains(folder + "/roadmap.org")) | ||
| 95 | |||
| 96 | try await model.trash(folder + "/roadmap.org") | ||
| 97 | #expect(!FileManager.default.fileExists(atPath: folder + "/roadmap.org")) | ||
| 98 | #expect(!model.files.map(\.path).contains(folder + "/roadmap.org")) | ||
| 99 | #expect(model.search("alpha").count == 1) | ||
| 100 | } | ||
| 101 | |||
| 77 | @Test func changedFilesAreReindexed() async throws { | 102 | @Test func changedFilesAreReindexed() async throws { |
| 78 | let notes = try Folder() | 103 | let notes = try Folder() |
| 79 | let file = try notes.write("a.org", "* Old\n") | 104 | let file = try notes.write("a.org", "* Old\n") |