krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/Orgstar/SidebarFiles.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/Orgstar/SidebarFiles.swift history · blame · raw

121 lines · 5186 bytes

  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.isDownloading ? 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    /// The open buffers of `path`, a file or a folder holding them.
 34    func openFiles(under path: String) -> [String] {
 35        session.openPaths.filter { $0 == path || $0.hasPrefix(path + "/") }
 36    }
 37
 38    /// Whether `path` is an open file with unsaved edits or a folder holding one.
 39    func holdsUnsavedFile(_ path: String) -> Bool { openFiles(under: path).contains(where: session.dirtyPaths.contains) }
 40
 41    func finish() {
 42        guard let operation = pending else { return }
 43        pending = nil
 44        Task {
 45            do {
 46                switch operation {
 47                case .create(let folder):
 48                    selection = try await workspace.createFile(named: name, in: folder)
 49                case .rename(let path):
 50                    let affected = openFiles(under: path)
 51                    // Open files are saved first, so the rename carries their edits, and open
 52                    // again under their new paths.
 53                    if !UnsavedChanges.resolve(session, paths: affected) { return }
 54                    let shown = session.path
 55                    let renamed = try await workspace.rename(path, to: name)
 56                    for open in affected {
 57                        session.kill(open)
 58                        session.open(renamed + open.dropFirst(path.count))
 59                    }
 60                    if let shown, affected.contains(shown) {
 61                        let moved = renamed + shown.dropFirst(path.count)
 62                        session.open(moved)
 63                        selection = moved
 64                    } else if let shown {
 65                        session.open(shown)
 66                    }
 67                case .trash(let path):
 68                    for open in openFiles(under: path) { session.kill(open) }
 69                    if session.path == nil { selection = nil }
 70                    try await workspace.trash(path)
 71                }
 72            } catch {
 73                failure = error.localizedDescription
 74            }
 75        }
 76    }
 77}
 78
 79extension View {
 80    func fileOperations(_ sidebar: Sidebar) -> some View {
 81        let names: Binding<String> = sidebar.$name
 82        return self
 83            .alert(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == true } set: { if !$0 { sidebar.pending = nil } }) {
 84                TextField("Name", text: names)
 85                Button(sidebar.pending.map { if case .create = $0 { "Create" } else { "Rename" } } ?? "OK") { sidebar.finish() }
 86                Button("Cancel", role: .cancel) { sidebar.pending = nil }
 87            } message: {
 88                if case .create = sidebar.pending { Text("A name without an extension gets .org. Use folder/name to make folders.") }
 89            }
 90            .confirmationDialog(title(sidebar.pending), isPresented: Binding { sidebar.pending?.needsName == false } set: { if !$0 { sidebar.pending = nil } }) {
 91                Button("Move to Trash", role: .destructive) { sidebar.finish() }
 92            } message: {
 93                if case .trash(let path) = sidebar.pending, sidebar.holdsUnsavedFile(path) {
 94                    Text("Unsaved changes to the open file will be lost.")
 95                } else {
 96                    Text("You can put it back from the Trash in Finder.")
 97                }
 98            }
 99            .alert("Couldn't change the file", isPresented: Binding { sidebar.failure != nil } set: { if !$0 { sidebar.failure = nil } }) {
100                Button("OK") { sidebar.failure = nil }
101            } message: {
102                Text(sidebar.failure ?? "")
103            }
104    }
105
106    private func title(_ operation: FileOperation?) -> String {
107        switch operation {
108        case .create(let folder): "New file in “\((folder as NSString).lastPathComponent)”"
109        case .rename(let path): "Rename “\((path as NSString).lastPathComponent)”"
110        case .trash(let path): "Move “\((path as NSString).lastPathComponent)” to the Trash?"
111        case nil: ""
112        }
113    }
114}
115
116extension FileOperation {
117    var needsName: Bool {
118        if case .trash = self { return false }
119        return true
120    }
121}