Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `[<upstream>]` 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.
Expand Down
12 changes: 10 additions & 2 deletions Sources/Timber/TimberApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
66 changes: 61 additions & 5 deletions Sources/Timber/TimberCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/<repo>/**/<repo>`
/// 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] = []
Expand All @@ -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)
Expand All @@ -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
))
}
}
Expand Down Expand Up @@ -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
}
}
6 changes: 6 additions & 0 deletions Sources/Timber/TimberState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 40 additions & 11 deletions Sources/Timber/TimberViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -92,6 +97,11 @@ struct TimberPopover: View {
}
}
.frame(maxHeight: CGFloat(maxRows) * rowHeight)
.onHover { hovering in
if !hovering {
state.hoverExited()
}
}
}
}
.padding()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions Sources/TimberModel/TimberListStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

// Status/Todo badges for the popover rows, mirroring the `timber ls`
// columns (minus the `[<upstream>]` suffix). Pure logic next to
// TimberModel so it stays unit-tested in isolation.

public extension TimberModel {
/// Status column from `timber ls`, minus the `[<upstream>]` 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)"
}
}
38 changes: 35 additions & 3 deletions Sources/TimberModel/TimberModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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
}
}

Expand Down Expand Up @@ -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)
}
Expand Down
53 changes: 53 additions & 0 deletions Tests/TimberModelTests/TimberModelListStatusTests.swift
Original file line number Diff line number Diff line change
@@ -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, "")
}
}
4 changes: 4 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Loading