krz/orgstar

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

Sources/Orgstar/EmacsImportView.swift

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

130 lines · 5861 bytes

  1import OrgApp
  2import OrgCore
  3import SwiftUI
  4
  5/// Import from Emacs: what was read from a configuration, to pick from before anything changes.
  6struct EmacsImportView: View {
  7    let workspace: WorkspaceModel?
  8    @Environment(\.dismiss) private var dismiss
  9    @State private var location: URL?
 10    @State private var result: EmacsImport?
 11    @State private var selected: Set<Int> = []
 12    @State private var choosing = false
 13    @State private var summary: String?
 14    @State private var importing = false
 15
 16    var body: some View {
 17        VStack(alignment: .leading, spacing: 0) {
 18            header
 19            Divider()
 20            if let summary {
 21                Text(summary).padding(20).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
 22            } else if let result {
 23                list(result)
 24            } else {
 25                ContentUnavailableView("No Emacs configuration found", systemImage: "doc.questionmark",
 26                                       description: Text("Choose your init.el, or the folder holding it (~/.emacs.d, ~/.config/doom)."))
 27            }
 28            Divider()
 29            HStack {
 30                if let result, summary == nil {
 31                    Text(footnote(result)).font(.caption).foregroundStyle(.secondary)
 32                }
 33                Spacer()
 34                if summary == nil {
 35                    Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
 36                    Button("Import") { runImport() }
 37                        .keyboardShortcut(.defaultAction)
 38                        .disabled(selected.isEmpty || importing)
 39                } else {
 40                    Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
 41                }
 42            }
 43            .padding(12)
 44        }
 45        .frame(width: 640, height: 560)
 46        .onAppear {
 47            if location == nil, let first = EmacsImport.defaultLocations().first { read(first) }
 48        }
 49        .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder, .item]) { chosen in
 50            if case .success(let url) = chosen { read(url) }
 51        }
 52    }
 53
 54    private var header: some View {
 55        HStack {
 56            VStack(alignment: .leading, spacing: 2) {
 57                Text("Import from Emacs").font(.headline)
 58                Text(location.map { "Reading \($0.path)" + ((result?.isDoom ?? false) ? ", with Doom's own org defaults" : "") } ?? "")
 59                    .font(.caption).foregroundStyle(.secondary).lineLimit(2)
 60            }
 61            Spacer()
 62            Button("Choose…") { choosing = true }
 63        }
 64        .padding(12)
 65    }
 66
 67    private func list(_ result: EmacsImport) -> some View {
 68        let groups: [(String, [EmacsImport.Item])] = [
 69            ("Settings", result.items.filter { if case .setting = $0.change { true } else { false } }),
 70            ("Folders", result.items.filter { if case .folder = $0.change { true } else { false } }),
 71            ("Capture templates", result.items.filter { if case .capture = $0.change { true } else { false } }),
 72            ("Key bindings", result.items.filter { if case .binding = $0.change { true } else { false } }),
 73        ]
 74        return List {
 75            ForEach(groups.filter { !$0.1.isEmpty }, id: \.0) { title, items in
 76                Section(title) {
 77                    ForEach(items) { item in
 78                        Toggle(isOn: Binding { selected.contains(item.id) } set: { on in
 79                            if on { selected.insert(item.id) } else { selected.remove(item.id) }
 80                        }) {
 81                            VStack(alignment: .leading, spacing: 1) {
 82                                Text(item.summary).font(.system(.body, design: .monospaced))
 83                                Text([item.name == item.summary ? nil : item.name, item.source.isEmpty ? nil : item.source].compactMap { $0 }.joined(separator: " · "))
 84                                    .font(.caption).foregroundStyle(.secondary)
 85                            }
 86                        }
 87                    }
 88                }
 89            }
 90            let notes = result.unsupported.map { ($0.id, $0.name, $0.reason, $0.source) } + result.unreadable.map { ($0.id, $0.name, $0.reason, $0.source) }
 91            if !notes.isEmpty || !result.problems.isEmpty {
 92                Section("Not imported") {
 93                    ForEach(result.problems, id: \.self) { Text($0).foregroundStyle(.red) }
 94                    ForEach(notes, id: \.0) { note in
 95                        VStack(alignment: .leading, spacing: 1) {
 96                            Text(note.1).font(.system(.body, design: .monospaced))
 97                            Text("\(note.2) · \(note.3)").font(.caption).foregroundStyle(.secondary)
 98                        }
 99                    }
100                }
101            }
102        }
103    }
104
105    private func footnote(_ result: EmacsImport) -> String {
106        var parts = ["\(result.skipped) variables not about org left alone"]
107        if result.doomDefaultsLeftOut > 0 { parts.append("\(result.doomDefaultsLeftOut) Doom defaults Orgstar has no use for") }
108        return parts.joined(separator: "; ")
109    }
110
111    private func read(_ url: URL) {
112        let accessing = url.startAccessingSecurityScopedResource()
113        defer { if accessing { url.stopAccessingSecurityScopedResource() } }
114        location = url
115        let scanned = EmacsImport.scan(EmacsImport.files(in: url))
116        result = scanned
117        selected = Set(scanned.items.map(\.id))
118        summary = nil
119    }
120
121    private func runImport() {
122        guard let result else { return }
123        importing = true
124        let chosen = result.items.filter { selected.contains($0.id) }
125        Task {
126            summary = await EmacsImport.apply(chosen, workspace: workspace)
127            importing = false
128        }
129    }
130}