Sidebar: new, rename and trash files !40

merged merged by cmc on 2026-10-05 18:24 UTC · krz/orgstar:sidebar-file-ops into main

5 files changed, +221 −1

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +13
@@ -115,6 +115,19 @@ public final class DocumentSession {
115115 }
116116 }
117117
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
118131 /// Makes `buffer`, built from `document`, the live state of the open file.
119132 public func attach(_ buffer: DocumentBuffer) {
120133 self.buffer = buffer
Sources/OrgApp/WorkspaceModel.swift +60
@@ -185,6 +185,66 @@ public final class WorkspaceModel {
185185 return url
186186 }
187187
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
188248 // MARK: - Queries
189249
190250 public func tree(forRoot path: String) -> FileTreeNode {
Sources/Orgstar/ContentView.swift +10 −1
@@ -115,7 +115,7 @@ struct ContentView: View {
115115
116116 private var main: some View {
117117 NavigationSplitView {
118 Sidebar(workspace: workspace, selection: $selectedFile) { commands.showingFolderPicker = true }
118 Sidebar(workspace: workspace, session: session, selection: $selectedFile) { commands.showingFolderPicker = true }
119119 .navigationSplitViewColumnWidth(min: 200, ideal: 260)
120120 } detail: {
121121 HSplitView {
@@ -427,11 +427,16 @@ struct ContentView: View {
427427
428428struct Sidebar: View {
429429 let workspace: WorkspaceModel
430 let session: DocumentSession
430431 @Binding var selection: String?
431432 var addFolder: () -> Void = {}
432433 /// Folders shown collapsed, by path, one per line.
433434 @AppStorage("collapsedFolders") private var collapsedText = ""
434435 @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?
435440
436441 private var collapsed: Set<String> { Set(collapsedText.split(separator: "\n").map(String.init)) }
437442
@@ -453,6 +458,7 @@ struct Sidebar: View {
453458 Label(node.name, systemImage: icon(node))
454459 .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
455460 .tag(node.file?.path)
461 .contextMenu { fileMenu(node) }
456462 }
457463 } header: {
458464 Text((root as NSString).lastPathComponent)
@@ -496,10 +502,13 @@ struct Sidebar: View {
496502 } message: {
497503 Text("The folder and its files stay on disk.")
498504 }
505 .fileOperations(self)
499506 }
500507
501508 @ViewBuilder
502509 private func folderMenu(_ root: String) -> some View {
510 Button("New File…") { ask(.create(folder: root)) }
511 Divider()
503512 Button(collapsed.contains(root) ? "Expand" : "Collapse") { expanded(root).wrappedValue.toggle() }
504513 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: root)]) }
505514 Divider()
Sources/Orgstar/SidebarFiles.swift added +113
@@ -0,0 +1,113 @@
1import AppKit
2import OrgApp
3import SwiftUI
4
5/// Creating, renaming and trashing files from the sidebar.
6enum FileOperation: Equatable {
7 case create(folder: String)
8 case rename(path: String)
9 case trash(path: String)
10}
11
12extension 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
71extension 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
108extension 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 {
7474 #expect(reopened.search("apples").isEmpty)
7575 }
7676
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
77102 @Test func changedFilesAreReindexed() async throws {
78103 let notes = try Folder()
79104 let file = try notes.write("a.org", "* Old\n")