import AppKit import SwiftUI import os // MARK: - Focused values /// The frontmost board window's **identity**, published beside its store by `BoardWindowHost`. /// /// `FocusedBoardStoreKey` answers "which board is in front"; this answers "which *window*", which is /// a different question and the one File ▸ Duplicate has to ask: the flush that precedes a copy is /// keyed on the window's session, not on the store (`AppModel.flushPendingWork(for:)`). struct FocusedBoardWindowRefKey: FocusedValueKey { typealias Value = BoardWindowRef } /// The welcome window's selected recents row — File ▸ Reveal in Finder's welcome scope. struct FocusedWelcomeSelectionKey: FocusedValueKey { typealias Value = WelcomeRow } extension FocusedValues { var boardWindowRef: BoardWindowRef? { get { self[FocusedBoardWindowRefKey.self] } set { self[FocusedBoardWindowRefKey.self] = newValue } } var welcomeSelection: WelcomeRow? { get { self[FocusedWelcomeSelectionKey.self] } set { self[FocusedWelcomeSelectionKey.self] = newValue } } } // MARK: - New Board /// File ▸ New Board… (⌥⌘N) — the template chooser's entry point (11-command-nexus.md; /// 09-templates.md). /// /// **⌥⌘N, not ⌘N**: ⌘N is New *Card*, which is the command a board window user reaches for a hundred /// times a day, so the rarer creation wears the modifier. Available everywhere — a new board needs /// no board in front, and the welcome window's own button is this item's twin. struct NewBoardCommand: View { let appModel: AppModel var body: some View { Button("New Board…") { appModel.showTemplateChooser() } .keyboardShortcut("n", modifiers: [.option, .command]) } } // MARK: - Open Recent /// File ▸ Open Recent ▸ (11-command-nexus.md: "Everywhere; reads the board registry"). /// /// The registry, rendered as a menu — same rows as the welcome list, through the same derivation, so /// the two can never disagree about a board's name or about whether it can be opened. An /// unavailable board is **listed and disabled** rather than hidden, which is the recents row's own /// posture (02 § Graceful orphaning) applied to a menu: a board that has gone missing is information, /// and a menu that quietly shortened itself would be the app forgetting on the user's behalf. /// /// Clear Menu sits at the bottom, where Finder puts it. See `AppModel.clearRecents` for the /// equivalence it rests on — the registry *is* this menu, so clearing the menu clears the registry. struct OpenRecentMenu: View { let appModel: AppModel /// The failures are deliberately not joined in here: a menu item has no room for fail-fast's /// specifics, and a board that failed to open is still a board the user may want to try again. /// The failure's surface is the welcome row (02 § Launch and window lifecycle). private var rows: [WelcomeRow] { WelcomeRow.derive(recents: appModel.recents, failures: []).rows } var body: some View { let rows = self.rows Menu("Open Recent") { ForEach(rows) { row in Button(row.displayName) { guard let url = row.url else { return } appModel.openBoard(at: url) } .disabled(!row.canOpen) } if !rows.isEmpty { Divider() } Button("Clear Menu") { appModel.clearRecents() } .disabled(rows.isEmpty) } } } // MARK: - Duplicate /// File ▸ Duplicate (⇧⌘S) — **the board**, never the selection (11-command-nexus.md, 03-board-ui.md /// § Welcome screen & templates). /// /// ### What it does, in the order 03 fixes /// /// 1. **The flush first** — "The copy is preceded by the close flush ... so neither the tree nor the /// copied history misses pending work". Not a *close*: 09-templates.md states the rule with its /// exception attached ("sessions staying open"), and 03 is explicit that "the original stays open /// too". `AppModel.flushPendingWork(for:)` is that step of the sequence, run on its own. /// 2. **The copy** — `BoardDuplicator`, off the main actor so the spinner can spin, and cancellable: /// the in-progress row carries Cancel, which cancels the copy task, and the walk removes its own /// partial sibling on the way out ("a cancelled duplicate never happened"). /// 3. **The save panel, but only on a refusal** — "the silent Finder-style sibling is attempted /// first; on a permission refusal a save panel opens pre-filled with the parent folder and the /// 'copy' name — the panel's grant is the sandbox's own answer, and it doubles as a /// choose-another-location affordance" (03, settled). The board's bookmark grants its own subtree, /// not its parent, so the sibling may simply be unwritable; that is a question about *where*, and /// the panel is where the sandbox answers it. /// 4. **The copy opens in its own board window** — "macOS Duplicate convention" — through the /// ordinary open path, so it registers, bookmarks, and titles itself like any other board. /// /// ### Three endings, and only one of them speaks /// /// A copy that lands opens. A copy the user **cancelled** — the row's Cancel, or the save panel's — /// says nothing at all: "cancelling the panel cancels the duplicate quietly (no banner — the user /// declined, nothing failed)", and the row's Cancel is the same sentence about the same gesture. /// Everything else — a full disk, a name already taken — is the ordinary one-shot banner /// (02-architecture.md § Write-failure surfacing). `BoardDuplicator.Failure`'s three cases are those /// three endings, switched exhaustively below so a fourth could not be forgotten. /// /// ### Validation /// /// Board window only, so a welcome-selected recent can never be duplicated by accident — 03 says it /// "never acts on a welcome-selected recent", and scoping the item to the focused board window is /// how that is enforced rather than remembered. /// /// **Disabled under the read-only lock in every state** (03: "the flush can't run and the sibling /// destination shares the board's fate"). It uses `acceptsBoardMutations`, which adds the /// focused-inline-editor half of 04's rule to the lock 03 names — a deliberate reading rather than a /// slip: an open title editor holds the one pending change no flush can reach, and a duplicate taken /// mid-rename would be a fork missing the edit the user is in the middle of making. struct DuplicateBoardCommand: View { let appModel: AppModel @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardWindowRef) private var ref private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "duplicate") var body: some View { Button("Duplicate") { duplicate() } .keyboardShortcut("s", modifiers: [.shift, .command]) .disabled(!canDuplicate) } private var canDuplicate: Bool { guard let store, ref != nil else { return false } return store.acceptsBoardMutations } private func duplicate() { guard canDuplicate, let store, let ref else { return } let name = AppModel.displayName(of: store) let source = store.rootURL Task { @MainActor in let cancellation = BoardCopyCancellation() // The in-progress row 02 § The banner surface names for "big-board Duplicate": info // tone, pinned, cleared on completion, swapped for the error row on failure — and // carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy, // nothing lost") and 03 spends on this command by name. let operation = store.banners.beginOperation( label: "Duplicating '\(name)'…", cancel: { cancellation.cancel() } ) defer { store.banners.endOperation(operation) } await appModel.flushPendingWork(for: ref) // 1. The silent Finder-style sibling. var outcome = await copy(source, titled: name, into: nil, cancellation: cancellation) // 2. The save panel, and only where 03 puts it: a *permission* refusal of that sibling. if case let .failure(.refused(refusal)) = outcome { Self.logger.notice("duplicate refused: \(refusal.description, privacy: .public)") guard let chosen = Self.chooseDestination(for: source) else { // The user declined. Nothing failed, so nothing is said. return } outcome = await copy(source, titled: name, into: chosen, cancellation: cancellation) } switch outcome { case let .success(copy): appModel.openBoard(at: copy) case .failure(.cancelled): // The walk removed its own partial sibling; the row leaves with the `defer`. Self.logger.notice("duplicate cancelled — the partial copy was removed") // `.refused` cannot reach here (the panel path either returns above or asks a // destination that never refuses again), but it carries a real failure, so if it ever // did, it would be said out loud rather than swallowed. case let .failure(.failed(error)), let .failure(.refused(error)): Self.logger.error("duplicate failed: \(error.description, privacy: .public)") store.banners.post(error) } } } /// One attempt at the copy — off the main actor, wired to the banner row's Cancel. /// /// `Task.detached` rather than a child task, for both halves of the reason: the copy is real I/O /// on a board that may carry a large `.git`, and a spinner drawn by a blocked main thread is a /// still picture (see `BoardDuplicator` for why running it off the actor is safe); and a detached /// task's cancellation is *only* the Cancel button's, never something inherited from whatever /// else the enclosing task is doing. /// /// `destination` is `nil` for the Finder-style sibling and the panel's answer otherwise — the two /// `BoardDuplicator` entry points, which differ only in who chose the location and therefore in /// whether a permission failure is a question or an answer. private func copy( _ source: URL, titled name: String, into destination: URL?, cancellation: BoardCopyCancellation ) async -> Result { // Cancelled during the flush: the copy never starts, rather than starting and being told to // stop — the same outcome, reached without making a folder to delete. guard !cancellation.isCancelled else { return .failure(.cancelled) } let task = Task.detached(priority: .userInitiated) { if let destination { return try BoardDuplicator.duplicate(boardAt: source, titled: name, into: destination) } return try BoardDuplicator.duplicate(boardAt: source, titled: name) } cancellation.attach(task) do { return .success(try await task.value) } catch let failure as BoardDuplicator.Failure { return .failure(failure) } catch { return .failure(.failed(BoardWriteError( operation: .duplicateBoard(title: name), path: source.path, reason: .io(message: error.localizedDescription) ))) } } /// The save panel a refusal hands the question to (03, settled) — "pre-filled with the parent /// folder and the 'copy' name". /// /// Both pre-fills are the sibling the app just failed to write, so the panel opens showing /// exactly what would have happened silently, and one Return makes it happen. Whatever the user /// changes is then honored verbatim (`BoardDuplicator.duplicate(boardAt:titled:into:)`): the /// panel is a location grant *and* a choose-another-location affordance, and second-guessing the /// name it returns would break the second half. /// /// `nil` is the user declining, which this command answers with silence. The panel is modal, /// like the template chooser's — the in-progress row stays up behind it, because the duplicate /// genuinely is still in progress. private static func chooseDestination(for source: URL) -> URL? { let panel = NSSavePanel() panel.directoryURL = source.deletingLastPathComponent() panel.nameFieldStringValue = BoardDuplicator.copyDestination(for: source).lastPathComponent panel.canCreateDirectories = true panel.isExtensionHidden = false panel.allowsOtherFileTypes = true panel.prompt = "Duplicate" panel.message = "Choose where to keep the duplicate." guard panel.runModal() == .OK, let url = panel.url else { return nil } return url } } // MARK: - Save as Template /// File ▸ Save as Template — the board, into the user templates store (11-command-nexus.md: "Board /// window; 09-templates.md"). /// /// ### It is Duplicate's sequence with a different destination /// /// 09-templates.md ▸ Save as Template states the rule and names Duplicate in the same breath: "**The /// copy is preceded by the close flush** … so the template never misses the last keystrokes; the same /// rule covers File ▸ Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step — /// flush, then a cancellable copy off the main actor under an in-progress row — and the differences /// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is /// the app-side template store rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a /// collision auto-renames rather than failing, and a `template:` key lands on the copy. /// /// **No save panel, ever.** The store is the app's own container — "friction-free sandbox writes, no /// location ceremony" (09 ▸ Storage) — so there is no location question to ask, and therefore no /// `.refused` outcome to answer: a permission failure writing inside our own container is an /// ordinary failure with an ordinary banner. /// /// ### The ending that speaks is the quiet one /// /// A duplicate opens in a window, so it announces itself. A template lands in a folder nobody is /// looking at, so this posts a **passive signpost** — the info tone's calm half (02-architecture.md /// § The banner surface: "Passive info rows rank last and may collapse — calm by design"). It names /// the template rather than only the board, because that is where a Finder-style auto-rename becomes /// visible: "Saved 'Roadmap' as the template 'Roadmap 2'" is the only place the user is told which /// one they just made. A cancel says nothing (the partial is gone, the duplicate rule verbatim), and /// a failure is the ordinary one-shot banner. /// /// ### Validation is Duplicate's, minus 09's one carve-out /// /// Board window only, and disabled under the read-only lock — with the exception 09 spells out and /// 02-architecture.md ▸ Live-reload resilience scopes: **under the unwritable-location lock alone it /// stays live**, because it "reads the board and writes the app-side store" (archiving the /// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source /// session holds unsaved content — content that lock's suspended saves cannot flush, and which the /// template would therefore silently miss. The other two locks disable it outright: a vanished root /// has nothing to copy, and a board whose last reload failed is a tree whose state is least known. /// /// The focused-editor half of `acceptsBoardMutations` is kept in every branch, for /// `DuplicateBoardCommand`'s reason: an open inline title editor holds the one pending change no /// flush can reach, and a template taken mid-rename would miss the edit being made. struct SaveAsTemplateCommand: View { let appModel: AppModel @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardWindowRef) private var ref private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates") var body: some View { Button("Save as Template") { save() } .disabled(!canSave) } private var canSave: Bool { guard let store, let ref else { return false } return Self.allowsSave( lock: store.readOnlyLock, isEditingInline: store.isEditingInline, hasUnsavedCardContent: appModel.hasUnsavedCardContent(for: ref) ) } /// The item's validation as a pure function of the three facts it turns on — extracted from /// `canSave` so the carve-out can be tested at every combination rather than only through a /// menu. /// /// The carve-out itself is 02-architecture.md ▸ Live-reload resilience, settled: under the /// **unwritable-location lock alone** this stays live (copy-out is a read — archiving the /// read-only DMG board being inspected is a legitimate errand), and it "gates on the hazard /// itself, open sessions, not on lock provenance" — so an Edit or raw-source session holding /// unsaved content disables it, whether the lock arrived at open or from the symmetric probe /// mid-session, and nothing here asks which. The other two locks disable it outright. nonisolated static func allowsSave( lock: ReadOnlyLockReason?, isEditingInline: Bool, hasUnsavedCardContent: Bool ) -> Bool { guard !isEditingInline else { return false } switch lock { case .none: return true case .unwritableLocation: return !hasUnsavedCardContent case .vanishedRoot, .bracketedReloadFailed: return false } } private func save() { guard canSave, let store, let ref else { return } let name = AppModel.displayName(of: store) let source = store.rootURL Task { @MainActor in let cancellation = BoardCopyCancellation() // Copy-shaped work, so the row carries Cancel — "remove the partial copy, nothing lost" // (02 § The banner surface), which the engine honors by removing the partial store entry. let operation = store.banners.beginOperation( label: "Saving '\(name)' as a template…", cancel: { cancellation.cancel() } ) defer { store.banners.endOperation(operation) } await appModel.flushPendingWork(for: ref) // Cancelled during the flush: the copy never starts, rather than starting and being told // to stop (`DuplicateBoardCommand.copy(_:titled:into:cancellation:)`'s guard, verbatim). guard !cancellation.isCancelled else { return } // Detached, for the two halves of Duplicate's reason: the copy is real I/O on a board // that may carry a large `.git` — a spinner drawn by a blocked main thread is a still // picture — and a detached task's cancellation is only ever this row's Cancel. let task = Task.detached(priority: .userInitiated) { try TemplateEngine.saveAsTemplate(boardAt: source, titled: name) } cancellation.attach(task) do { let landed = try await task.value store.banners.postSignpost( Self.savedMessage(board: name, template: TemplateEngine.documentName(of: landed)) ) } catch TemplateEngine.Failure.cancelled { Self.logger.notice("save as template cancelled — the partial template was removed") } catch let TemplateEngine.Failure.failed(error) { Self.logger.error("save as template failed: \(error.description, privacy: .public)") store.banners.post(error) } catch { let write = BoardWriteError( operation: .saveAsTemplate(title: name), path: source.path, reason: .io(message: error.localizedDescription) ) Self.logger.error("save as template failed: \(write.description, privacy: .public)") store.banners.post(write) } } } /// The signpost's line. The template is named only when the store's collision ladder gave it a /// different one — saying "Saved 'Roadmap' as the template 'Roadmap'" would be noise, while /// leaving the rename unsaid would hide the one thing about this save the user could not predict. static func savedMessage(board: String, template: String) -> String { board == template ? "Saved '\(board)' as a template" : "Saved '\(board)' as the template '\(template)'" } } // MARK: - Share /// File ▸ Share… — the board staged into a `.zip` and handed to `NSSharingServicePicker` /// (11-command-nexus.md; design ruling 2026-08-09, card 72691b11 "create a share sheet target for /// board"). /// /// ### What it shares, and why a zip /// /// A board is a folder tree, and no share destination (Mail, Messages, AirDrop) knows how to carry /// one as an item the way it carries a file — so this stages the board into a single `.zip`, named /// `".zip"`, and hands the picker that one URL (`BoardShareStager`). The content rule /// is the ruling's own: **`.git` is the sole exclusion** — inert history a recipient has no use for /// and should not receive unasked — and **everything else rides along verbatim: attachments, /// comments, and `.trash/` included**, "a faithful copy" in the ruling's own words, echoing /// `BoardDuplicator`'s posture rather than `TemplateEngine`'s narrower one. The trash inclusion is /// flagged for owner review in the card's DECISIONS comment. /// /// ### The sequence is Duplicate's, once more, with a picker where the open used to be /// /// 1. **The flush first** — `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own step: /// "stage from a flushed state … never share a half-written buffer" is the ruling's phrasing for /// exactly what pending-work-landed-on-disk means. /// 2. **The stage** — `BoardShareStager.stage(boardAt:titled:)`, off the main actor so the banner's /// in-progress row can spin, cancellable the same way (`BoardCopyCancellation`). /// 3. **The picker** — `BoardSharePresentation`, anchored to the window that was key when Share was /// pressed (captured weakly before the flush's `await`, so a window closed mid-stage does not /// keep it alive, and re-resolved to the current key window if it went away — `PrintCoordinator /// .keyWindow()`'s reasoning, stretched across the async gap staging needs that printing does /// not). Presentation owns the staged files from there — it is the one thing that knows when the /// picker's interaction has actually finished — and it is what removes them. /// /// ### Validation /// /// **Simpler than Duplicate's or Save as Template's**, by the ruling's own words ("enabled when a /// board window is focused") — and defensibly so: unlike either sibling, this never writes a byte /// into the board's own tree, so the read-only lock's reason for existing (protecting board writes /// a sandbox refusal or a vanished root would refuse) does not apply — `PrintCommand`'s own "a /// print is a read" posture, applied to a copy that leaves rather than one that prints. **The one /// carve-out kept is the focused-inline-editor half**, not the whole of `acceptsBoardMutations`: an /// open rename or new-card placeholder holds the one pending change no flush can reach /// (`DuplicateBoardCommand`'s own note), and sharing mid-rename would zip a title the user is still /// typing. Flagged for owner review — the ruling's literal wording names only "board window /// focused", not this carve-out. struct ShareBoardCommand: View { let appModel: AppModel @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardWindowRef) private var ref private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "share") var body: some View { Button("Share…") { share() } .disabled(!canShare) } /// The row's whole decision, as a pure function of the three facts it turns on /// (`SaveAsTemplateCommand.allowsSave`'s reason for extracting validation from the view: a rule /// reachable only through a menu is a rule nobody tests). static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool { hasStore && hasRef && !isEditingInline } private var canShare: Bool { Self.isEnabled( hasStore: store != nil, hasRef: ref != nil, isEditingInline: store?.isEditingInline == true ) } private func share() { guard canShare, let store, let ref else { return } let name = AppModel.displayName(of: store) let source = store.rootURL Task { @MainActor in // Weak: the window this share was invoked from must not be kept alive by this task // across the flush's `await` just so the picker can anchor to it later. weak var invokedWindow = NSApp.keyWindow ?? NSApp.mainWindow let cancellation = BoardCopyCancellation() let operation = store.banners.beginOperation( label: "Preparing '\(name)' to share…", cancel: { cancellation.cancel() } ) defer { store.banners.endOperation(operation) } await appModel.flushPendingWork(for: ref) // Cancelled during the flush: staging never starts (`SaveAsTemplateCommand.save()`'s // own guard, verbatim). guard !cancellation.isCancelled else { return } // Detached, `DuplicateBoardCommand`'s two-part reason: real I/O (a board's trash and // attachments can be real bytes, and `ditto` zipping them is not instant) that must not // block the banner's spinner, and a detached task's cancellation is only ever this // row's Cancel. let task = Task.detached(priority: .userInitiated) { try BoardShareStager.stage(boardAt: source, titled: name) } cancellation.attach(task) do { let archive = try await task.value let anchorWindow = invokedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow BoardSharePresentation(archive: archive).present(anchorWindow: anchorWindow) } catch let failure as BoardShareStager.Failure { switch failure { case .cancelled: // The walk removed its own partial staging directory; the row leaves with the // `defer`. Self.logger.notice("share cancelled — the staged copy was removed") case let .failed(error): Self.logger.error("share failed: \(error.description, privacy: .public)") store.banners.post(error) } } catch { let write = BoardWriteError( operation: .shareBoard(title: name), path: source.path, reason: .io(message: error.localizedDescription) ) Self.logger.error("share failed: \(write.description, privacy: .public)") store.banners.post(write) } } } } /// The Cancel button's end of a running board copy — File ▸ Duplicate's, File ▸ Save as Template's /// and File ▸ Share…'s alike: the one piece of state the banner row's `cancel` closure and the copy /// task have to share. /// /// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's /// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The /// walk itself reads no shared state at all — it reads its own `Task.isCancelled`, which `cancel()` /// sets by cancelling the task — so this type exists only to close the window between the row /// appearing and the copy task existing. A Cancel pressed during the flush must not be forgotten by /// the task that starts after it, which is what `isCancelled` is for. /// /// **Generic over the task's success type** (`URL` for Duplicate and Save as Template, /// `BoardShareStager.StagedArchive` for Share) since it joined a third caller whose detached task /// answers a different value — the cancellation bookkeeping is identical either way and does not /// care what the copy produced. @MainActor private final class BoardCopyCancellation { private var task: Task? private(set) var isCancelled = false func attach(_ task: Task) { self.task = task if isCancelled { task.cancel() } } func cancel() { isCancelled = true task?.cancel() } } // MARK: - Reveal in Finder /// File ▸ Reveal in Finder — the welcome and board scopes (11-command-nexus.md: "Board window: the /// selection's folder(s), or the board root with nothing selected; … welcome: the selected recent's /// folder (disabled on unavailable rows) — the context-menu entry's required twin"). /// /// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's /// *required* twin, so shipping one without the other would leave the context menu as the only path /// to a command — the thing 04's contract forbids. /// /// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is /// not edit-shaped and stays enabled on trash selections" (04 ▸ The trash), and inspection is a /// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the /// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload /// will drop) disables rather than falling back to the root: revealing the wrong thing is worse /// than nothing, and only a genuinely empty selection means "the board". /// /// **The card-window scope is the third branch**, and it is the one the attachment row's context /// menu twins (11-command-nexus.md ▸ Context menus): "card window: the card's folder — the selected /// attachment's file instead when the attachments section is focused". The rule itself is /// `CardAttachments.revealURLs`, so the menu row and the row's own Reveal cannot disagree about what /// "the selected attachment" means. struct RevealInFinderCommand: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.cardAttachments) private var attachments @FocusedValue(\.welcomeSelection) private var selection var body: some View { Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting(urls) } .disabled(urls.isEmpty) } /// What the item would reveal, and therefore whether it is enabled — one answer for both, the /// codebase's usual shape. The board in front wins; the card-window branch stands when a card /// window is; the welcome branch stands when neither is. private var urls: [URL] { if let store { let ids = store.selection.ids guard !ids.isEmpty else { return [store.rootURL] } return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } if let attachments { return CardAttachments.revealURLs( cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused ) } guard let selection, selection.canReveal, let url = selection.url else { return [] } return [url] } }