diff --git a/README.md b/README.md index 818cb6d..fdc5447 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ managed Git worktrees. Requires the `timber` CLI and an Xcode toolchain. ## Behavior (mirrors Panel.qml) -- Popover lists existing worktrees as `name@repo` rows, with a fuzzy filter +- Popover lists existing worktrees as `name@repo` rows (plus the `timber ls` + Status and Todo badges — the `[]` suffix is omitted, and Todo + hides when the worktree has no checklist items), with a fuzzy filter field (`type worktree@repo`). `@` narrows by repo, e.g. `f@scribble`. - Typing a qualified `name@repo` that does not exist yet offers a `+` row to create it (`timber create --no-herdr`), then opens it in Zed. diff --git a/Sources/Timber/TimberApp.swift b/Sources/Timber/TimberApp.swift index 8ea1db6..8c72906 100644 --- a/Sources/Timber/TimberApp.swift +++ b/Sources/Timber/TimberApp.swift @@ -26,9 +26,17 @@ enum PopoverSizing { CGFloat(TimberModel.popoverMaxWidth) } - /// Display string for a row, mirroring TimberRow's text. + /// Display string for a row, mirroring TimberRow's text (value plus + /// the Status/Todo badges) so the measured width covers them. static func displayString(for item: TimberItem) -> String { - (item.kind == .create ? "+ " : "") + item.value + var text = (item.kind == .create ? "+ " : "") + item.value + if !item.statusText.isEmpty { + text += " " + item.statusText + } + if !item.todoText.isEmpty { + text += " " + item.todoText + } + return text } static func textWidth(_ string: String) -> CGFloat { diff --git a/Sources/Timber/TimberCommand.swift b/Sources/Timber/TimberCommand.swift index 0d79d4d..395660c 100644 --- a/Sources/Timber/TimberCommand.swift +++ b/Sources/Timber/TimberCommand.swift @@ -81,12 +81,29 @@ enum TimberCommand { // MARK: - Enumeration + /// Status/Todo details for known worktrees, keyed by `name@repo`. + /// Empty when `timber list --json` fails: the filesystem scan below + /// stays the source of truth for membership, so one unreadable + /// worktree hides its badges instead of failing the whole listing. + static func listDetails() -> [String: TimberListDetail] { + guard let result = try? runTimber(["list", "--json"]), result.code == 0, + let data = result.stdout.data(using: .utf8), + let rows = try? JSONDecoder().decode([TimberListDetail].self, from: data) + else { return [:] } + var details: [String: TimberListDetail] = [:] + for row in rows { + details[TimberModel.worktreeValue(name: row.name, repo: row.repo)] = row + } + return details + } + /// Same enumeration timber's own zsh completion uses: registered repo /// names plus a scan of the worktree root for `//**/` - /// directories containing `.git`. `timber list` is avoided on purpose — - /// its styled table still emits ANSI under NO_COLOR and it enriches - /// every row with git status, so one missing worktree directory fails - /// the whole listing. + /// directories containing `.git`. The scan (not `timber list`) owns + /// membership: its styled table still emits ANSI under NO_COLOR and it + /// enriches every row with git status, so one missing worktree + /// directory fails the whole listing. `timber list --json` only + /// enriches scanned rows with Status/Todo details. static func enumerate() -> (repos: [TimberRepo], worktrees: [TimberWorktree]) { let fm = FileManager.default var repoNames: [String] = [] @@ -106,6 +123,7 @@ enum TimberCommand { let root = ProcessInfo.processInfo.environment["TIMBER_WORKTREE_ROOT"] ?? fm.homeDirectoryForCurrentUser.appendingPathComponent("worktrees").path let gitExe = findExecutable("git") + let details = listDetails() var worktrees: [TimberWorktree] = [] for repo in repoNames { let base = URL(fileURLWithPath: root).appendingPathComponent(repo) @@ -115,11 +133,18 @@ enum TimberCommand { ? String(parent.path.dropFirst(base.path.count + 1)) : "" guard !rel.isEmpty else { continue } + let detail = details[TimberModel.worktreeValue(name: rel, repo: repo)] worktrees.append(TimberWorktree( name: rel, repo: repo, path: dir.path, - lastCommitAt: commitDate(atPath: dir.path, gitExe: gitExe) + lastCommitAt: commitDate(atPath: dir.path, gitExe: gitExe), + ahead: detail?.ahead ?? 0, + behind: detail?.behind ?? 0, + merged: detail?.merged ?? false, + statusError: detail?.statusError ?? false, + todoDone: detail?.todoDone ?? 0, + todoTotal: detail?.todoTotal ?? 0 )) } } @@ -227,3 +252,34 @@ private extension TimberWorktree { TimberModel.worktreeValue(name: name, repo: repo) } } + +/// One `timber list --json` row: the Status/Todo columns for a worktree. +/// File scope (not nested in TimberCommand) for the nesting lint. +/// Decodes defensively (missing keys default) so a newer or older timber +/// still enriches rows instead of failing the parse. +struct TimberListDetail: Decodable { + var name = "" + var repo = "" + var ahead = 0 + var behind = 0 + var merged = false + var statusError = false + var todoDone = 0 + var todoTotal = 0 + + enum CodingKeys: String, CodingKey { + case name, repo, ahead, behind, merged, statusError, todoDone, todoTotal + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = (try? container.decode(String.self, forKey: .name)) ?? "" + repo = (try? container.decode(String.self, forKey: .repo)) ?? "" + ahead = (try? container.decode(Int.self, forKey: .ahead)) ?? 0 + behind = (try? container.decode(Int.self, forKey: .behind)) ?? 0 + merged = (try? container.decode(Bool.self, forKey: .merged)) ?? false + statusError = (try? container.decode(Bool.self, forKey: .statusError)) ?? false + todoDone = (try? container.decode(Int.self, forKey: .todoDone)) ?? 0 + todoTotal = (try? container.decode(Int.self, forKey: .todoTotal)) ?? 0 + } +} diff --git a/Sources/Timber/TimberState.swift b/Sources/Timber/TimberState.swift index cb64a48..2865617 100644 --- a/Sources/Timber/TimberState.swift +++ b/Sources/Timber/TimberState.swift @@ -111,6 +111,12 @@ final class TimberState: ObservableObject { armedRemoveValue = "" } + /// Mouse left the row list: dim all rows. Arrow keys re-arm via move. + func hoverExited() { + cursorActive = false + armedRemoveValue = "" + } + func move(_ delta: Int) { guard !items.isEmpty else { return } if !cursorActive { diff --git a/Sources/Timber/TimberViews.swift b/Sources/Timber/TimberViews.swift index 0fb8735..8fedc4a 100644 --- a/Sources/Timber/TimberViews.swift +++ b/Sources/Timber/TimberViews.swift @@ -39,15 +39,20 @@ struct TimberPopover: View { .disabled(state.busy) } - Picker("Sort", selection: $state.sort) { - ForEach(TimberModel.SortMode.allCases) { mode in - Text(mode.label).tag(mode) + HStack { + Spacer(minLength: 0) + Picker("Sort", selection: $state.sort) { + ForEach(TimberModel.SortMode.allCases) { mode in + Text(mode.label).tag(mode) + } } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .help("Sort worktrees (timber list --sort)") + .onChange(of: state.sort) { _, _ in state.rebuild() } + Spacer(minLength: 0) } - .pickerStyle(.segmented) - .labelsHidden() - .help("Sort worktrees (timber list --sort)") - .onChange(of: state.sort) { _, _ in state.rebuild() } if state.repoFormOpen { TextField("Remote URL or path", text: $state.repoURL) @@ -92,6 +97,11 @@ struct TimberPopover: View { } } .frame(maxHeight: CGFloat(maxRows) * rowHeight) + .onHover { hovering in + if !hovering { + state.hoverExited() + } + } } } .padding() @@ -149,9 +159,28 @@ struct TimberRow: View { Spacer(minLength: 8) - // The action cluster is always in the layout (hidden when the - // row is not selected) so the popover width never jumps and - // the window never clips the row when buttons appear. + // Status/Todo badges from `timber ls`, shown on every row + // (not just the selected one). They sit outside the action + // cluster so they never hide; like the cluster they outrank + // the value text, which truncates (from the middle) first. + HStack(spacing: 4) { + if !item.statusText.isEmpty { + Text(item.statusText) + .lineLimit(1) + .foregroundStyle(.secondary) + } + if !item.todoText.isEmpty { + Text(item.todoText) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .layoutPriority(1) + + // The action cluster is always in the layout (faded to 20% + // when the row is not selected) so the popover width never + // jumps and the window never clips the row when buttons + // appear, while still making the hovered row obvious. HStack(spacing: 4) { Button { state.openInZed(item) @@ -199,7 +228,7 @@ struct TimberRow: View { } } .layoutPriority(1) - .opacity(selected ? 1 : 0) + .opacity(selected ? 1 : 0.2) .disabled(!selected || state.busy) } .frame(height: 32) diff --git a/Sources/TimberModel/TimberListStatus.swift b/Sources/TimberModel/TimberListStatus.swift new file mode 100644 index 0000000..4e13dae --- /dev/null +++ b/Sources/TimberModel/TimberListStatus.swift @@ -0,0 +1,36 @@ +import Foundation + +// Status/Todo badges for the popover rows, mirroring the `timber ls` +// columns (minus the `[]` suffix). Pure logic next to +// TimberModel so it stays unit-tested in isolation. + +public extension TimberModel { + /// Status column from `timber ls`, minus the `[]` suffix: + /// `merged` names the state instead of divergence detail, otherwise + /// ahead/behind counts render as arrows, and unreadable git status + /// reports `error`. Empty when the worktree is in sync. Counts are + /// unpadded: the popover never aligns a column across rows. + static func listStatusText(merged: Bool, ahead: Int, behind: Int, statusError: Bool) -> String { + if statusError { + return "error" + } + if merged { + return "merged" + } + var parts: [String] = [] + if ahead > 0 { + parts.append("↑\(ahead)") + } + if behind > 0 { + parts.append("↓\(behind)") + } + return parts.joined(separator: " ") + } + + /// Todo column from `timber ls` (`done/total`). Empty when the + /// worktree has no checklist items, so quiet rows stay quiet. + static func todoText(done: Int, total: Int) -> String { + guard total > 0 else { return "" } + return "\(done)/\(total)" + } +} diff --git a/Sources/TimberModel/TimberModel.swift b/Sources/TimberModel/TimberModel.swift index 88a094d..196f4ca 100644 --- a/Sources/TimberModel/TimberModel.swift +++ b/Sources/TimberModel/TimberModel.swift @@ -17,11 +17,29 @@ public struct TimberWorktree: Equatable { public let path: String /// Newest commit date for recency sorting; nil when unknown. public let lastCommitAt: Date? - public init(name: String, repo: String, path: String, lastCommitAt: Date? = nil) { + /// Ahead/behind vs upstream (`timber list --json`). + public let ahead: Int + public let behind: Int + /// Branch merged to its upstream, from `timber list --json`. + public let merged: Bool + /// Unreadable git status / TODO.md progress (`timber list --json`). + public let statusError: Bool + public let todoDone: Int + public let todoTotal: Int + public init( + name: String, repo: String, path: String, lastCommitAt: Date? = nil, ahead: Int = 0, behind: Int = 0, + merged: Bool = false, statusError: Bool = false, todoDone: Int = 0, todoTotal: Int = 0 + ) { self.name = name self.repo = repo self.path = path self.lastCommitAt = lastCommitAt + self.ahead = ahead + self.behind = behind + self.merged = merged + self.statusError = statusError + self.todoDone = todoDone + self.todoTotal = todoTotal } } @@ -44,12 +62,21 @@ public struct TimberItem: Equatable, Identifiable { public let value: String /// Worktree path for `.open` rows, empty for `.create` rows. public let path: String - public init(kind: Kind, name: String, repo: String, value: String, path: String) { + /// `timber ls` Status (no `[upstream]`); empty when in sync. + public let statusText: String + /// `timber ls` Todo (`done/total`); empty without checklist items. + public let todoText: String + public init( + kind: Kind, name: String, repo: String, value: String, path: String, statusText: String = "", + todoText: String = "" + ) { self.kind = kind self.name = name self.repo = repo self.value = value self.path = path + self.statusText = statusText + self.todoText = todoText } } @@ -209,8 +236,13 @@ public enum TimberModel { let ranked = filterWorktrees(term, worktrees: worktrees).map { worktrees[$0] } for worktree in sortWorktrees(ranked, mode: sort) { let value = worktreeValue(name: worktree.name, repo: worktree.repo) + let status = listStatusText( + merged: worktree.merged, ahead: worktree.ahead, behind: worktree.behind, + statusError: worktree.statusError + ) let item = TimberItem( - kind: .open, name: worktree.name, repo: worktree.repo, value: value, path: worktree.path + kind: .open, name: worktree.name, repo: worktree.repo, value: value, path: worktree.path, + statusText: status, todoText: todoText(done: worktree.todoDone, total: worktree.todoTotal) ) items.append(item) } diff --git a/Tests/TimberModelTests/TimberModelListStatusTests.swift b/Tests/TimberModelTests/TimberModelListStatusTests.swift new file mode 100644 index 0000000..a48afb7 --- /dev/null +++ b/Tests/TimberModelTests/TimberModelListStatusTests.swift @@ -0,0 +1,53 @@ +@testable import TimberModel +import XCTest + +final class TimberModelListStatusTests: XCTestCase { + private let repos = [ + TimberRepo(name: "dispatch"), + TimberRepo(name: "macos-timber"), + ] + + func testListStatusTextInSyncIsEmpty() { + XCTAssertEqual(TimberModel.listStatusText(merged: false, ahead: 0, behind: 0, statusError: false), "") + } + + func testListStatusTextAheadAndBehind() { + XCTAssertEqual(TimberModel.listStatusText(merged: false, ahead: 77, behind: 0, statusError: false), "↑77") + XCTAssertEqual(TimberModel.listStatusText(merged: false, ahead: 0, behind: 3, statusError: false), "↓3") + XCTAssertEqual( + TimberModel.listStatusText(merged: false, ahead: 1, behind: 2, statusError: false), + "↑1 ↓2" + ) + } + + func testListStatusTextMergedAndError() { + // Merged names the state instead of divergence detail. + XCTAssertEqual( + TimberModel.listStatusText(merged: true, ahead: 3, behind: 1, statusError: false), + "merged" + ) + XCTAssertEqual(TimberModel.listStatusText(merged: false, ahead: 1, behind: 0, statusError: true), "error") + } + + func testTodoTextHidesEmptyChecklist() { + XCTAssertEqual(TimberModel.todoText(done: 0, total: 0), "") + XCTAssertEqual(TimberModel.todoText(done: 1, total: 2), "1/2") + } + + func testItemsCarryStatusAndTodo() { + let worktrees = [ + TimberWorktree( + name: "alpha", repo: "dispatch", path: "/wt/dispatch/alpha/dispatch", + ahead: 77, todoDone: 1, todoTotal: 2 + ), + TimberWorktree(name: "status", repo: "macos-timber", path: "/wt/macos-timber/status/macos-timber"), + ] + let items = TimberModel.itemsForTerm(repos: repos, worktrees: worktrees, term: "") + let alpha = items.first(where: { $0.value == "alpha@dispatch" }) + XCTAssertEqual(alpha?.statusText, "↑77") + XCTAssertEqual(alpha?.todoText, "1/2") + let quiet = items.first(where: { $0.value == "status@macos-timber" }) + XCTAssertEqual(quiet?.statusText, "") + XCTAssertEqual(quiet?.todoText, "") + } +} diff --git a/mise.toml b/mise.toml index 368fc84..2029960 100644 --- a/mise.toml +++ b/mise.toml @@ -16,6 +16,8 @@ set -euo pipefail mise run format mise run lint mise run test +# Keep this comment last: the hook passes changed files as trailing args +# and mise glues them onto the final line, where a comment swallows them. """ [tasks.build] @@ -68,6 +70,8 @@ if [ -z "${DEVELOPER_DIR:-}" ] && [ -d /Applications/Xcode.app ]; then export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer fi swift test --disable-sandbox --cache-path /tmp/swiftpm-cache --config-path /tmp/swiftpm-config --security-path /tmp/swiftpm-security +# Keep this comment last: trailing args (e.g. hook file lists) glue onto +# the final line, where a comment swallows them instead of swift test. """ [tasks.run]