Files
lanework/Kanban/UI/Board/BoardInfoPopover.swift
T
rzen c67c1037f1 The strip grows a Sync tab — a standing placeholder, nothing in it yet
BoardInfoTab gains .sync last in the strip, rendering BoardSyncTabView's one
honest caption. The tab claims the position where 07's remote half will
live without ruling where its setup surfaces land — that stays the open
Redesign card's question.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 22:00:19 -04:00

538 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The
/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
/// Pattern picker (the Background tab's original name, before the same session widened it past the
/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`,
/// which the pre-tab body's mode-aware git section rehomed into whole — postures and notes, none of
/// them re-ruled by the move. That tab carried a **Board Settings…** row to a separate sheet until
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
/// rehomed into the tab (see below).
///
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both
/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a
/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board
/// popover, the same-day pivot note) — see `BoardInfoTab`.
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** — "the keyboard path is reachability … not bindings").
///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the
/// switch menu (`BoardGitControls`), and Board ▸ Board Settings… left the menu bar with them. So
/// "one home per control" — the split's own promise — is now satisfied by there being one surface,
/// and ⌘I is the door to all of it.
// MARK: - Presentation state
/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {
var isPresented = false
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
/// disclosure that could only ever open would leave the popover with no keyboard way out.
func toggle() {
isPresented.toggle()
}
}
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
typealias Value = BoardInfoPresentation
}
extension FocusedValues {
var boardInfo: BoardInfoPresentation? {
get { self[FocusedBoardInfoKey.self] }
set { self[FocusedBoardInfoKey.self] = newValue }
}
}
// MARK: - The window-title widget
/// The titlebar widget: the board's glyph beside a two-line identity block — its name, and on a
/// git-mode board its branch under the name — with a trailing disclosure chevron, whose one job is
/// this popover.
///
/// **A two-line stack since 2026-08-07** (03-board-ui.md ▸ Board popover, the window-title widget
/// passage). It was one line reading `glyph Title — branch ⌄`, and the em-dash was the tell: a
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash
/// retired, and the glyph grew to span both lines — an icon sized to the block it labels rather than
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
/// centred beside the same glyph, which is the same block with one row.
///
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
/// window or the board — which is what makes the affordance and the surface read as one thing. A
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
/// popover exactly as a click on the chevron always has.
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The git state this board's **session** composed with — read once, at the moment the widget is
/// installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse never interrupts
/// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
/// or a branch switch, redraws the widget without anything here being re-created.
///
/// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state
/// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over"
/// (previews, the accessory-installation tests), which reads as mode `none`.
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere.
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once**
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both
/// `@Observable`, so reading them here is what makes the title and branch live.
private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL,
mode: git?.mode ?? .none,
branch: git?.branch
)
}
var body: some View {
Button {
presentation.toggle()
} label: {
HStack(spacing: 6) {
// The board's own glyph beside its name — the identity pair the popover header
// states, restated where the board is named all day (2026-08-07). Read inside
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
// restyle from the popover repaints this widget without reinstalling it. Lenient on
// both dimensions — an unresolvable glyph draws the board default, an unresolvable
// tint draws the standard secondary.
//
// **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt
// it draws about twice the height `.imageScale(.small)` gave it on the container's
// 13pt, which is what lets one glyph span the title and branch lines instead of
// sitting beside the upper one. The number is the icon's own size rather than a
// scale factor because that is the dimension being chosen — the block's height.
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
.font(.system(size: 22))
.foregroundStyle(iconTint)
// Decorative beside the name it repeats — the button's own label already says
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
// The identity block: the name, and the branch beneath it on a git-mode board. On
// any other board this is the single title line and the `HStack`'s own centring puts
// it level with the glyph — the stack is the same shape with one row, never a
// special case.
VStack(alignment: .leading, spacing: 1) {
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
if let branch = summary.branch {
// Smaller and secondary — the qualifier under the name it qualifies. The
// em-dash that separated the two on one line retired with the stack: a
// hierarchy that a layout can state does not need punctuation to state it.
Text(branch)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
// Yields space to the chevron first when the block doesn't fit inside the width cap
// below — the board's identity is the more load-bearing half of the pair, and both
// its lines truncate rather than the disclosure disappearing.
.layoutPriority(1)
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
.font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar — capped rather
// than left to grow, with the truncation above doing the rest. The height is the
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
// name-over-branch, and no taller than a standard title bar carries.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info")
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
// has actually been opened once). The widget is on screen from the start, so it is the
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, git: git)
}
}
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
/// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info"
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what
/// the button does.
private var accessibilityLabel: String {
guard let branch = summary.branch else { return summary.title }
return "\(summary.title), branch \(branch)"
}
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
/// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: store.snapshot.iconColor) {
return AnyShapeStyle(color)
}
return AnyShapeStyle(.secondary)
}
}
// MARK: - The widget's strings
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil`
/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable {
let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = mode == .git ? branch : nil
}
}
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
/// app's own call site passes the session's value explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
git: git,
presentation: presentation
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
// SwiftUI update, so this starting frame only has to survive the first layout pass before the
// widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
// The height is the widget's own two-line figure (2026-08-07), for the same reason the width is
// generous — a first pass clamped to the old one-line 18 would paint a clipped block.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - Tabs
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure — all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme**
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) — and **Sync** (`BoardSyncTabView`), added
/// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says
/// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get
/// ruled. The raw values are the segmented control's own labels, so the strip needs no separate
/// label function.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
case theme = "Theme"
case git = "Git"
case sync = "Sync"
var id: Self { self }
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
// something true to say, which then meant dropping it on a free board with no `.git` — the
// "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**,
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
// `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the
// absent posture is unreachable and every board carries all three tabs. The membership rule
// stands structurally — the strip still asks the posture — it just never hears 'absent'
// anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`,
// in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway.
}
// MARK: - The popover's content
/// The symbol/name header, then the tab bar, then the selected tab's surface — one view
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let git: HistoryStore?
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
private var inset: CGFloat {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil
) {
self.store = store
self.recents = recents
self.git = git
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
// The board's own icon, inline with its name — the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
// `undo:` — the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
},
// The colour row — the picker's 4×2 tint grid, writing `iconColor` through
// the same funnel the glyph writes `icon`: None removes the key, a well
// writes the palette name (2026-08-07).
currentColor: store.snapshot.iconColor.value,
onSelectColor: { name in
StyleCommand.apply(
iconColor: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store)
}
}
.padding(inset)
Divider()
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name. It iterates `allCases` — the strip's membership is
// still the board's git posture in principle (the Git session's ruling), and since
// 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board
// carries the whole strip (03-board-ui.md ▸ Board popover, the same-day pivot note).
Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface — the first three settled 2026-08-07, each in its own
// dedicated session and its own file; Sync is that day's standing placeholder. Each pads
// itself by `inset`, so the switch adds nothing.
switch tab {
case .info:
BoardInfoTabView(store: store, inset: inset)
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(store: store, git: git, inset: inset)
case .sync:
BoardSyncTabView(inset: inset)
}
}
// The style editor's popover width, taken from the editor rather than restated — the number
// that keeps a compact settings popover narrow enough to sit beside a card. It is
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
}
// MARK: - Rename
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {
let store: BoardStore
@State private var draft = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField(fallbackName, text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFocused)
.onSubmit { store.renameBoard(draft) }
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
// field abandons its edit and keeps the popover open, and an unedited one lets the press
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
// safe on the paths where the press never reaches here — the focus-loss commit that
// follows sees a draft equal to what is on disk and writes nothing.
.onKeyPress(.escape) {
guard draft != committed else { return .ignored }
draft = committed
return .handled
}
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
store.renameBoard(draft)
}
.onAppear { draft = committed }
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
// updates the field, but never under the user's fingers: a draft being typed is the
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
// resilience, the same courtesy the inline editors get by tracking their UUID).
.onChange(of: committed) { _, title in
guard !isFocused else { return }
draft = title
}
// The popover can be dismissed without the field ever reporting focus loss, and a
// dismissal is a commit like any other click-away. Idempotent with the handler above.
.onDisappear { store.renameBoard(draft) }
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
// scope) — and the popover *stays open* under it, which is the style popover's settled
// precedent: the lock is a condition the banner is already explaining, not a reason to
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
// field and the editor below it disable as one surface rather than in halves.
.disabled(!store.acceptsBoardMutations)
}
/// The `title` on disk, as the last reload read it — "" for a board that has none.
private var committed: String { store.snapshot.title.value ?? "" }
/// The folder name, sans extension: what the window title shows when `title` is absent
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
/// mid-session shows here immediately.
private var fallbackName: String {
store.rootURL.deletingPathExtension().lastPathComponent
}
}