Sidebar: new, rename and trash files !40
5 files changed, +221 −1
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 | 131 | /// Makes `buffer`, built from `document`, the live state of the open file. |
| 119 | 132 | public func attach(_ buffer: DocumentBuffer) { |
| 120 | 133 | self.buffer = buffer |
Sources/OrgApp/WorkspaceModel.swift +60
| @@ -185,6 +185,66 @@ public final class WorkspaceModel { | ||
| 185 | 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 | 248 | // MARK: - Queries |
| 189 | 249 | |
| 190 | 250 | public func tree(forRoot path: String) -> FileTreeNode { |
Sources/Orgstar/ContentView.swift +10 −1
| @@ -115,7 +115,7 @@ struct ContentView: View { | ||
| 115 | 115 | |
| 116 | 116 | private var main: some View { |
| 117 | 117 | NavigationSplitView { |
| 118 | Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true } | |
| 118 | Sidebar(workspace: workspace, session: session, selection: $selectedFile) { commands.showingFolderPicker = true } | |
| 119 | 119 | .navigationSplitViewColumnWidth(min: 200, ideal: 260) |
| 120 | 120 | } detail: { |
| 121 | 121 | HSplitView { |
| @@ -427,11 +427,16 @@ struct ContentView: View { | ||
| 427 | 427 | |
| 428 | 428 | struct Sidebar: View { |
| 429 | 429 | let workspace: WorkspaceModel |
| 430 | let session: DocumentSession | |
| 430 | 431 | @Binding var selection: String? |
| 431 | 432 | var addFolder: () -> Void = {} |
| 432 | 433 | /// Folders shown collapsed, by path, one per line. |
| 433 | 434 | @AppStorage("collapsedFolders") private var collapsedText = "" |
| 434 | 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 | 441 | private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) } |
| 437 | 442 | |
| @@ -453,6 +458,7 @@ struct Sidebar: View { | ||
| 453 | 458 | Label(node.name, systemImage: icon(node)) |
| 454 | 459 | .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary) |
| 455 | 460 | .tag(node.file?.path) |
| 461 | .contextMenu { fileMenu(node) } | |
| 456 | 462 | } |
| 457 | 463 | } header: { |
| 458 | 464 | Text((root as NSString).lastPathComponent) |
| @@ -496,10 +502,13 @@ struct Sidebar: View { | ||
| 496 | 502 | } message: { |
| 497 | 503 | Text("The folder and its files stay on disk.") |
| 498 | 504 | } |
| 505 | .fileOperations(self) | |
| 499 | 506 | } |
| 500 | 507 | |
| 501 | 508 | @ViewBuilder |
| 502 | 509 | private func folderMenu(_ root: String) -> some View { |
| 510 | Button("New File…") { ask(.create(folder: root)) } | |
| 511 | Divider() | |
| 503 | 512 | Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() } |
| 504 | 513 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) } |
| 505 | 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 | 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 | 102 | @Test func changedFilesAreReindexed() async throws { |
| 78 | 103 | let notes = try Folder() |
| 79 | 104 | let file = try notes.write("a.org", "* Old\n") |