Files
MikeMail/MikeMail/AccountOrderDrop.swift
T
2026-11-08 22:00:47 +01:00

278 lines
14 KiB
Swift

import SwiftUI
/// Flattened tree entries give each account and folder its own native table row and drop target.
struct SidebarEntry: Identifiable, Equatable {
let id: String
var account: MailAccount?
var folder: MailFolder?
var depth = 0
var title: String
var icon: String
static func visible(accounts: [MailAccount], folders: [UUID: [MailFolder]], inbox: Bool, trash: Bool,
expanded: (String) -> Bool) -> [SidebarEntry] {
var entries: [SidebarEntry] = []
if inbox { entries.append(SidebarEntry(id: "unified:inbox", title: "Gemeinsamer Posteingang", icon: "tray.2")) }
if trash { entries.append(SidebarEntry(id: "unified:trash", title: "Gemeinsamer Papierkorb", icon: "trash")) }
func appendFolders(_ children: [MailFolder], depth: Int) {
for folder in children {
entries.append(SidebarEntry(id: folder.id, folder: folder, depth: depth,
title: folder.name, icon: folder.isTrash ? "trash" : folder.icon))
if expanded("folderExpanded." + folder.id), let children = folder.children {
appendFolders(children, depth: depth + 1)
}
}
}
for account in accounts {
entries.append(SidebarEntry(id: "account:" + account.id.uuidString, account: account,
title: account.name.isEmpty ? account.email : account.name, icon: "person.crop.circle"))
if expanded("accountExpanded." + account.id.uuidString) {
appendFolders(folders[account.id] ?? [], depth: 1)
}
}
return entries
}
static func destination(_ offset: Int, rows: [SidebarEntry], accounts: [MailAccount]) -> Int {
guard offset < rows.count else { return accounts.count }
// A drop within an account's folders goes before the next account, keeping the whole tree together.
let next = rows.dropFirst(max(0, offset)).compactMap(\.account).first
return next.flatMap { account in accounts.firstIndex { $0.id == account.id } } ?? accounts.count
}
}
struct MailSidebarList: NSViewRepresentable {
let store: MailStore
@Binding var selection: String?
let showInbox: Bool
let showTrash: Bool
let create: (UUID, MailFolder?) -> Void
let delete: (MailFolder) -> Void
let empty: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.drawsBackground = false
let table = NSTableView()
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("sidebar"))
table.addTableColumn(column)
table.headerView = nil
table.backgroundColor = .clear
table.selectionHighlightStyle = .sourceList
table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
table.intercellSpacing = NSSize(width: 0, height: 2)
table.delegate = context.coordinator
table.dataSource = context.coordinator
table.setDraggingSourceOperationMask(.move, forLocal: true)
table.registerForDraggedTypes([Coordinator.accountType, .string])
table.setAccessibilityIdentifier("accountSidebar")
let menu = NSMenu()
menu.delegate = context.coordinator
table.menu = menu
scroll.documentView = table
context.coordinator.table = table
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
context.coordinator.parent = self
context.coordinator.reload()
}
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
static let accountType = NSPasteboard.PasteboardType("de.mikemail.account-order")
var parent: MailSidebarList
weak var table: NSTableView?
var rows: [SidebarEntry] = []
var reloading = false
var displayedCounts: [String: Int] = [:]
init(_ parent: MailSidebarList) { self.parent = parent }
func expanded(_ key: String) -> Bool {
UserDefaults.standard.object(forKey: key) as? Bool ?? true
}
func reload(force: Bool = false) {
reloading = true
let updated = SidebarEntry.visible(accounts: parent.store.accounts, folders: parent.store.folders,
inbox: parent.showInbox, trash: parent.showTrash, expanded: expanded)
let counts = parent.store.unreadCounts
if force || updated != rows || counts != displayedCounts {
rows = updated
displayedCounts = counts
table?.reloadData()
}
if let index = rows.firstIndex(where: { $0.id == parent.selection }) {
if table?.selectedRow != index { table?.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) }
} else if table?.selectedRow != -1 { table?.deselectAll(nil) }
reloading = false
}
func numberOfRows(in tableView: NSTableView) -> Int { rows.count }
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
rows[row].account == nil ? 30 : 46
}
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
rows[row].folder?.selectable != false
}
func tableViewSelectionDidChange(_ notification: Notification) {
guard !reloading, let table, rows.indices.contains(table.selectedRow) else { return }
parent.selection = rows[table.selectedRow].id
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let entry = rows[row]
let cell = NSTableCellView()
let stack = NSStackView()
stack.orientation = .horizontal
stack.distribution = .fill
stack.alignment = .centerY
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8 + CGFloat(entry.depth) * 12),
stack.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
stack.centerYAnchor.constraint(equalTo: cell.centerYAnchor)
])
let key = entry.account.map { "accountExpanded." + $0.id.uuidString }
?? (entry.folder?.children?.isEmpty == false ? "folderExpanded." + entry.id : nil)
if let key {
let button = NSButton(image: NSImage(systemSymbolName: expanded(key) ? "chevron.down" : "chevron.right",
accessibilityDescription: expanded(key) ? "Zuklappen" : "Aufklappen") ?? NSImage(),
target: self, action: #selector(toggle(_:)))
button.isBordered = false
button.identifier = NSUserInterfaceItemIdentifier(key)
button.setAccessibilityIdentifier(key)
button.widthAnchor.constraint(equalToConstant: 16).isActive = true
stack.addArrangedSubview(button)
} else {
let spacer = NSView()
spacer.widthAnchor.constraint(equalToConstant: 16).isActive = true
stack.addArrangedSubview(spacer)
}
let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage())
let isTrash = entry.id == "unified:trash" || entry.folder?.isTrash == true
icon.contentTintColor = isTrash
? (NSColor.systemRed.blended(withFraction: 0.35, of: .black) ?? .systemRed)
: .controlAccentColor
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
stack.addArrangedSubview(icon)
let texts = NSStackView()
texts.orientation = .vertical
texts.alignment = .leading
texts.spacing = 2
let title = NSTextField(labelWithString: entry.title)
title.lineBreakMode = .byTruncatingTail
title.font = .systemFont(ofSize: 13)
texts.addArrangedSubview(title)
if let account = entry.account {
let email = NSTextField(labelWithString: account.email)
email.font = .systemFont(ofSize: 11)
email.textColor = .secondaryLabelColor
email.lineBreakMode = .byTruncatingTail
texts.addArrangedSubview(email)
cell.setAccessibilityIdentifier("accountRow-" + account.email)
}
texts.setContentHuggingPriority(.defaultLow, for: .horizontal)
stack.addArrangedSubview(texts)
if entry.account == nil {
let count = parent.store.unreadCount(folderID: entry.id)
if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) }
}
return cell
}
@objc func toggle(_ sender: NSButton) {
guard let key = sender.identifier?.rawValue else { return }
UserDefaults.standard.set(!expanded(key), forKey: key)
reload(force: true)
}
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
guard let account = rows[row].account else { return nil }
let item = NSPasteboardItem()
item.setString(account.id.uuidString, forType: Self.accountType)
return item
}
func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo,
proposedRow row: Int, proposedDropOperation operation: NSTableView.DropOperation) -> NSDragOperation {
if info.draggingPasteboard.string(forType: Self.accountType) != nil {
tableView.setDropRow(row, dropOperation: .above)
return .move
}
if rows.indices.contains(row), rows[row].folder?.selectable == true,
let value = info.draggingPasteboard.string(forType: .string), !MailDrag.decode(value).isEmpty {
tableView.setDropRow(row, dropOperation: .on)
return .move
}
return []
}
func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo,
row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
if let value = info.draggingPasteboard.string(forType: Self.accountType),
let id = UUID(uuidString: value),
let source = parent.store.accounts.firstIndex(where: { $0.id == id }) {
parent.store.moveAccounts(from: IndexSet(integer: source),
to: SidebarEntry.destination(row, rows: rows, accounts: parent.store.accounts))
reload()
return true
}
guard rows.indices.contains(row), let folder = rows[row].folder,
folder.selectable, let value = info.draggingPasteboard.string(forType: .string) else { return false }
let ids = MailDrag.decode(value)
guard !ids.isEmpty else { return false }
Task { await parent.store.moveMessages(ids: ids, to: folder) }
return true
}
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
guard let table, rows.indices.contains(table.clickedRow) else { return }
let row = rows[table.clickedRow]
func add(_ title: String, _ action: String, enabled: Bool = true) {
let item = NSMenuItem(title: title, action: #selector(menuAction(_:)), keyEquivalent: "")
item.target = self
item.representedObject = [row.id, action]
item.isEnabled = enabled
menu.addItem(item)
}
menu.autoenablesItems = false
if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) {
add("Neuen Ordner erstellen…", "create")
add("Konto nach oben", "up", enabled: index > 0)
add("Konto nach unten", "down", enabled: index + 1 < parent.store.accounts.count)
} else if let folder = row.folder {
add("Unterordner erstellen…", "create")
add("Ordner löschen…", "delete", enabled: !folder.isInbox)
if folder.isTrash { add("Papierkorb leeren…", "empty") }
} else if row.id == "unified:trash" { add("Papierkorb leeren…", "empty") }
}
@objc func menuAction(_ item: NSMenuItem) {
guard let values = item.representedObject as? [String], values.count == 2,
let row = rows.first(where: { $0.id == values[0] }) else { return }
switch values[1] {
case "create":
if let account = row.account { parent.create(account.id, nil) }
else if let folder = row.folder { parent.create(folder.accountID, folder) }
case "delete": if let folder = row.folder { parent.delete(folder) }
case "empty": parent.empty(row.id)
case "up", "down":
if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) {
parent.store.moveAccounts(from: IndexSet(integer: index), to: values[1] == "up" ? index - 1 : index + 2)
reload()
}
default: break
}
}
}
}