From cf4281c235fdb579fa46d7e7d17b6993baa619a4 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Thu, 8 Oct 2026 19:27:08 +0200 Subject: [PATCH 1/2] feat(sidebar): cards that tell worktrees of one repo apart Thirteen worktrees of one repo made thirteen look-alike cards: the title repeated the branch, the cut ate the branch's distinctive end, a teammate's branch looked like the user's own, and every card showed the same diff, the tracked .claude-handover.md. - Title: the PR's title, while the workspace is still titled with its branch. A name set by hand differs from the branch and stays. - Line 2: the branch without the prefix it shares with the visible worktrees of its repo, cut at a / or -, truncated in the middle. The full branch in its tooltip. - A teammate's PR: their initial before the branch. The gh user's login is asked once per host; unknown, nothing is marked. - The diff stat leaves the mission handovers out. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Nirux/Util/PRDetect.swift | 38 +++++++++++++-- .../Views/NiruxShellView+SidebarGit.swift | 19 +++++++- Sources/Nirux/Views/SidebarCardLayout.swift | 11 +++++ Sources/Nirux/Views/SidebarModels.swift | 8 ++++ .../Views/SidebarWorkspaceCardRenderer.swift | 21 +++++++- Tests/NiruxTests/PRDetectTests.swift | 48 +++++++++++++++++++ Tests/NiruxTests/SidebarCardStateTests.swift | 32 +++++++++++++ 7 files changed, 169 insertions(+), 8 deletions(-) diff --git a/Sources/Nirux/Util/PRDetect.swift b/Sources/Nirux/Util/PRDetect.swift index fd7786c3..2c3d8aae 100644 --- a/Sources/Nirux/Util/PRDetect.swift +++ b/Sources/Nirux/Util/PRDetect.swift @@ -1,4 +1,5 @@ import Foundation +import os enum PRDetect { private struct GitHubCLIContext { @@ -51,11 +52,35 @@ enum PRDetect { context.branch == branch else { return .failure } - return fetch( + let result = fetch( branch: branch, ghPath: ghPath, context: context ) + guard case .success(let context, var info?) = result, + let host = context.upstreamRepository?.host + else { return result } + // The gh user unknown: no mark, rather than one on their own branches. + if let author = info.otherAuthor, viewerLogin(ghPath: ghPath, host: host) ?? author == author { + info.otherAuthor = nil + } + return .success(context: context, info: info) + } + + private static let viewerLogins = OSAllocatedUnfairLock(initialState: [String: String]()) + + /// The gh user on `host`, asked once per launch; nil while gh can't say. + private static func viewerLogin(ghPath: String, host: String) -> String? { + if let login = viewerLogins.withLock({ $0[host] }) { return login } + guard let result = GitHubCLIBoardClient.runGH( + ghPath, arguments: ["api", "user", "--hostname", host, "--jq", ".login"], timeout: 30 + ), result.terminationStatus == 0, + let login = String(data: result.standardOutput, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !login.isEmpty + else { return nil } + viewerLogins.withLock { $0[host] = login } + return login } static func fetch( @@ -131,7 +156,7 @@ enum PRDetect { "number", "state", "headRefOid", "headRepositoryOwner", "headRepository", "isDraft", "statusCheckRollup", "reviewDecision", "mergeable", "url", "additions", - "deletions", "changedFiles" + "deletions", "changedFiles", "title", "author" ].joined(separator: ",") let result = BoundedProcess.run( executableURL: URL(fileURLWithPath: cliContext.executablePath), @@ -196,7 +221,9 @@ enum PRDetect { url: candidate["url"] as? String ?? "", additions: candidate["additions"] as? Int, deletions: candidate["deletions"] as? Int, - changedFiles: candidate["changedFiles"] as? Int + changedFiles: candidate["changedFiles"] as? Int, + title: candidate["title"] as? String, + otherAuthor: (candidate["author"] as? [String: Any])?["login"] as? String ) } @@ -241,8 +268,11 @@ enum PRDetect { case .failure: return .failure } + // A mission's handover is the agent's note, not the work: a repo + // that tracks one would show it changed in every new worktree. + let handovers = BranchReview.Handover.names.map { ":(top,exclude)\($0)" } guard let output = gitOutput( - arguments: noIndexRefresh + ["diff", "--shortstat"], + arguments: noIndexRefresh + ["diff", "--shortstat", "--"] + handovers, cwd: cwd, gitPath: gitPath ) else { return .failure } diff --git a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift index a73fe5aa..82db9c4c 100644 --- a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift +++ b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift @@ -49,7 +49,10 @@ extension NiruxShellView { deferredAgent: Self.sidebarDeferredAgent(of: col) ) } - return WorkspaceInfo(id: workspace.id, index: index, title: workspace.title, + // Titled with its branch, a worktree reads better by its PR's + // title. A name set by hand differs from the branch and stays. + let title = workspace.title == workspace.gitBranch ? workspace.prInfo?.title ?? workspace.title : workspace.title + return WorkspaceInfo(id: workspace.id, index: index, title: title, profileID: workspace.profileID, isInactive: workspace.isInactive, columnCount: workspace.columns.count, focusedColumn: workspace.focusedIndex, @@ -60,7 +63,8 @@ extension NiruxShellView { blocker: workspace.blocker, phase: workspace.effectivePhase, lastSummary: workspace.lastSummary, lastActivityAt: workspace.lastActivityAt, reviewBadges: workspace.reviewBadges, - mergedCleanup: mergedCleanupOffer(workspaceIndex: index)) + mergedCleanup: mergedCleanupOffer(workspaceIndex: index), + branchLabel: sidebarBranchLabel(of: workspace, among: visibleIndices)) } tickHiddenSpaceAgents(visibleIndices: visibleIndices, foregroundProcesses: foregroundProcesses) updateColumnHeaders(infos: infos, foregroundProcesses: foregroundProcesses, now: now) @@ -88,6 +92,17 @@ extension NiruxShellView { refreshProjectBoards(snapshot: snapshot, now: now, foregroundProcesses: foregroundProcesses) } + /// Line 2's branch, without the prefix it shares with the visible + /// worktrees of its repo. + private func sidebarBranchLabel(of workspace: WorkspaceState, among visibleIndices: [Int]) -> String? { + guard let branch = workspace.gitBranch else { return nil } + guard let repository = workspace.gitContext?.upstreamRepository else { return branch } + let siblings = visibleIndices.map { workspaces[$0] } + .filter { $0.gitContext?.upstreamRepository == repository } + .compactMap(\.gitBranch) + return SidebarCardLayout.distinctiveBranch(branch, siblings: siblings) + } + /// Every column's foreground process, shown or not, and what follows /// from it before statuses are read: session bindings of a replaced /// agent, stuck agents (the one in another space is the one nobody diff --git a/Sources/Nirux/Views/SidebarCardLayout.swift b/Sources/Nirux/Views/SidebarCardLayout.swift index a641623d..e6997d35 100644 --- a/Sources/Nirux/Views/SidebarCardLayout.swift +++ b/Sources/Nirux/Views/SidebarCardLayout.swift @@ -208,6 +208,17 @@ struct SidebarCardLayout { var contentMaxX: CGFloat { cardX + cardWidth - Metrics.cardPaddingX } var indentX: CGFloat { contentX + Metrics.cardIndent } + /// The branch without the longest prefix it shares with a sibling's, + /// cut after that prefix's last `/` or `-`: worktrees of one repo tell + /// apart by their ends (`tomiir/payouts-ledger` → `ledger`). + static func distinctiveBranch(_ branch: String, siblings: [String]) -> String { + let shared = siblings.filter { $0 != branch } + .map { branch.commonPrefix(with: $0) } + .max { $0.count < $1.count } ?? "" + guard let separator = shared.lastIndex(where: { $0 == "/" || $0 == "-" }) else { return branch } + return String(branch[branch.index(after: separator)...]) + } + /// Between the feedback and the pull request, and the pull request and /// its checks. static func linkGaps(_ links: [SidebarCardLink]) -> CGFloat { diff --git a/Sources/Nirux/Views/SidebarModels.swift b/Sources/Nirux/Views/SidebarModels.swift index 3f133290..86966910 100644 --- a/Sources/Nirux/Views/SidebarModels.swift +++ b/Sources/Nirux/Views/SidebarModels.swift @@ -204,6 +204,11 @@ struct PRInfo: Hashable, Sendable { let additions: Int? let deletions: Int? let changedFiles: Int? + var title: String? + /// Who opened it, when that isn't the gh user: the card marks a + /// teammate's branch. `PRDetect` clears it for the user's own, and when + /// it can't tell who the user is. + var otherAuthor: String? var failedCheckUrl: String? { checks.lazy.filter { $0.result == .failure }.compactMap(\.url).first @@ -238,6 +243,9 @@ struct WorkspaceInfo: Hashable { var reviewBadges: ReviewBadges? = nil /// What the card shows after "#N merged"; nil shows nothing. var mergedCleanup: MergedCleanupOffer? + /// Line 2's branch, without what visible worktrees of its repo share + /// (`SidebarCardLayout.distinctiveBranch`); nil prints `gitBranch`. + var branchLabel: String? var sidebarAction: (text: String, isBlocker: Bool)? { if let blocker = normalizedContextText(blocker) { diff --git a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift index b136af36..c7124944 100644 --- a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift +++ b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift @@ -213,9 +213,26 @@ final class SidebarWorkspaceCardRenderer { maxX = diff.frame.minX - Theme.Space.sm } if let branchName = workspace.gitBranch { - let branch = textLabel(branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary) + var x = layout.indentX + // A teammate's branch: their initial. Nothing on the user's own. + if let author = workspace.prInfo?.otherAuthor, let initial = author.first { + let badge = SidebarBadgeView( + text: initial.uppercased(), textColor: Theme.Color.textSecondary, + fillColor: Theme.Color.fillPressed, font: Theme.Font.label + ) + badge.cornerRadius = Theme.Radius.chip + badge.frame = NSRect(x: x, y: rowY, width: Metrics.branchRowHeight, height: Metrics.branchRowHeight) + badge.toolTip = "Opened by \(author)" + badge.setAccessibilityRole(.staticText) + badge.setAccessibilityLabel("Opened by \(author)") + append(badge) + x = badge.frame.maxX + Theme.Space.xs + } + let branch = textLabel(workspace.branchLabel ?? branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary) + // The end tells worktrees apart, the start their owner. + branch.lineBreakMode = .byTruncatingMiddle branch.toolTip = branchName - branch.frame = NSRect(x: layout.indentX, y: rowY, width: max(0, maxX - layout.indentX), height: Metrics.branchRowHeight) + branch.frame = NSRect(x: x, y: rowY, width: max(0, maxX - x), height: Metrics.branchRowHeight) append(branch) } } diff --git a/Tests/NiruxTests/PRDetectTests.swift b/Tests/NiruxTests/PRDetectTests.swift index 7c0b9078..807c3e88 100644 --- a/Tests/NiruxTests/PRDetectTests.swift +++ b/Tests/NiruxTests/PRDetectTests.swift @@ -688,3 +688,51 @@ private func fakeGitHubCLIScript(_ configuration: FakeGitHubCLIConfiguration) -> kill "$watchdog_pid" 2>/dev/null || true """# } + +// The card: its title, its teammate mark, its diff. +extension PRDetectTests { + func testPullRequestInfoReadsTitleAndAuthor() { + let info = PRDetect.pullRequestInfo(from: [ + "number": 1174, "state": "OPEN", "title": "Ledger entries for payouts", "author": ["login": "tomiir"] + ]) + XCTAssertEqual(info.title, "Ledger entries for payouts") + XCTAssertEqual(info.otherAuthor, "tomiir") + } + + func testDiffStatsLeaveHandoversOut() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + try runGit(["init", "-q"], at: directory) + try FileManager.default.createDirectory( + at: directory.appendingPathComponent("sub"), withIntermediateDirectories: true + ) + for name in [".claude-handover.md", ".codex-handover.md", "tracked.txt", "sub/kept.txt"] { + try "clean\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + try runGit(["add", "."], at: directory) + try runGit([ + "-c", "user.name=Nirux Tests", + "-c", "user.email=nirux@example.test", + "commit", "-qm", "initial" + ], at: directory) + + for name in [".claude-handover.md", ".codex-handover.md"] { + try "next steps\nmore\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + // From a subdirectory too: the handovers sit at the top level. + let subdirectory = directory.appendingPathComponent("sub").path + guard case .observed(_, let handoversOnly) = PRDetect.diffStats(cwd: subdirectory) else { + return XCTFail("Expected observed diff stats") + } + XCTAssertNil(handoversOnly) + + try "changed\n".write(to: directory.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8) + guard case .observed(_, let stats) = PRDetect.diffStats(cwd: subdirectory) else { + return XCTFail("Expected observed diff stats") + } + XCTAssertEqual(stats, "1 file changed, 1 insertion(+), 1 deletion(-)") + } +} diff --git a/Tests/NiruxTests/SidebarCardStateTests.swift b/Tests/NiruxTests/SidebarCardStateTests.swift index 0f6e2f0c..ab0e1fde 100644 --- a/Tests/NiruxTests/SidebarCardStateTests.swift +++ b/Tests/NiruxTests/SidebarCardStateTests.swift @@ -37,6 +37,38 @@ final class SidebarCardStateTests: XCTestCase { ) } + func testBranchDropsThePrefixItSharesWithASibling() { + let siblings = ["tomiir/payouts-ledger-entries", "tomiir/payouts-payout-status", "cheun/review-payouts", "cheun/review-fo-account"] + let labels = siblings.map { SidebarCardLayout.distinctiveBranch($0, siblings: siblings) } + XCTAssertEqual(labels, ["ledger-entries", "payout-status", "payouts", "fo-account"]) + // Cut at the last separator of the shared prefix, never mid-word. + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["feat/check-in"]), "checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/x", siblings: ["feat/x-2"]), "x") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/x-2", siblings: ["feat/x"]), "x-2") + } + + func testBranchRowMarksATeammatesBranchAndPrintsItsDistinctiveEnd() { + var teammate = pullRequest() + teammate.otherAuthor = "tomiir" + var info = workspace([column()], pullRequest: teammate) + info.branchLabel = "out" + let views = SidebarWorkspaceCardRenderer(workspace: info, sidebarWidth: 260, yOffset: 400).render().views + + XCTAssertTrue(views.contains { ($0 as? SidebarBadgeView)?.accessibilityLabel() == "Opened by tomiir" }) + let branch = views.compactMap { $0 as? NSTextField }.first { $0.stringValue == "out" } + XCTAssertEqual(branch?.toolTip, "feat/checkout") + + let own = SidebarWorkspaceCardRenderer(workspace: workspace([column()], pullRequest: pullRequest()), sidebarWidth: 260, yOffset: 400) + XCTAssertFalse(own.render().views.contains { ($0 as? SidebarBadgeView)?.accessibilityLabel()?.hasPrefix("Opened by") == true }) + } + + func testBranchWithoutSiblingStaysWhole() { + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: []), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["feat/checkout"]), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["fix/login"]), "feat/checkout") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("main", siblings: ["mainline"]), "main") + } + private let stopped = SidebarStuckState.stoppedOnError(kind: "rate_limit", detail: nil, failedAt: 1, resume: .offered) // MARK: - State From 12fd58a3af15c3574f9017b108dfd6b8775da0c6 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Thu, 8 Oct 2026 19:43:24 +0200 Subject: [PATCH 2/2] fix(sidebar): steady card names, no rail churn, review fixes Reviews of the first commit: - The PR title named WorkspaceInfo, so the rail's initials too, and vanished on every commit (prInfo clears on a new head). The card's line 1 now reads WorkspaceState.lastPullRequest, kept until the branch changes, and drops the type(scope): prefix every PR of a repo shares. The rail and other surfaces keep the workspace title. - Bots (is_bot, app/dependabot) get no teammate mark. - A failed gh user lookup is remembered ten minutes, with a 10 s cap, rather than retried at every PR refresh. - A branch ending at its shared prefix's separator kept an empty line 2. - Inactive workspaces, folded away, no longer shorten listed cards. - The diff opened from the stat leaves the handovers out too. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Nirux/Model/WorkspaceState.swift | 5 ++ Sources/Nirux/Util/PRDetect.swift | 53 +++++++++++-------- .../Views/NiruxShellView+SidebarGit.swift | 14 +++-- Sources/Nirux/Views/SidebarCardLayout.swift | 9 ++-- Sources/Nirux/Views/SidebarModels.swift | 11 ++++ .../Views/SidebarWorkspaceCardRenderer.swift | 10 ++-- Tests/NiruxTests/PRDetectTests.swift | 3 ++ Tests/NiruxTests/SidebarCardStateTests.swift | 31 ++++++++++- Tests/NiruxTests/WorkspaceContextTests.swift | 21 ++++++++ 9 files changed, 116 insertions(+), 41 deletions(-) diff --git a/Sources/Nirux/Model/WorkspaceState.swift b/Sources/Nirux/Model/WorkspaceState.swift index ebb87f3d..da2dc87d 100644 --- a/Sources/Nirux/Model/WorkspaceState.swift +++ b/Sources/Nirux/Model/WorkspaceState.swift @@ -76,6 +76,9 @@ final class WorkspaceState { var prInfo: PRInfo? { didSet { if prInfo?.url != oldValue?.url || prInfo?.state != "OPEN" { prFeedback = nil } } } + /// The last pull request found on this branch: the card keeps its + /// title and author while a commit refetches `prInfo`. + private(set) var lastPullRequest: PRInfo? /// The open PR's feedback (docs/pr-feedback-inbox.md), read after each /// PR refresh. Cleared when the PR changes or stops being open. var prFeedback: PRFeedback? @@ -230,6 +233,7 @@ extension WorkspaceState { if !sameRevision || (context?.identity.isDirty == true && Self.isTerminalPullRequest(prInfo)) { prInfo = nil } + if previousContext?.branch != context?.branch { lastPullRequest = nil } diffStats = nil if !titleIsManual, let branch = context?.branch, !branch.isEmpty { title = branch @@ -259,6 +263,7 @@ extension WorkspaceState { prInfo != info else { return false } prInfo = info + if let info { lastPullRequest = info } return true } diff --git a/Sources/Nirux/Util/PRDetect.swift b/Sources/Nirux/Util/PRDetect.swift index 2c3d8aae..47bb3039 100644 --- a/Sources/Nirux/Util/PRDetect.swift +++ b/Sources/Nirux/Util/PRDetect.swift @@ -60,27 +60,31 @@ enum PRDetect { guard case .success(let context, var info?) = result, let host = context.upstreamRepository?.host else { return result } - // The gh user unknown: no mark, rather than one on their own branches. - if let author = info.otherAuthor, viewerLogin(ghPath: ghPath, host: host) ?? author == author { - info.otherAuthor = nil + if let author = info.otherAuthor { + // The gh user unknown: no mark, rather than one on their own branches. + let viewer = viewerLogin(ghPath: ghPath, host: host) + if viewer == nil || viewer == author { info.otherAuthor = nil } } return .success(context: context, info: info) } - private static let viewerLogins = OSAllocatedUnfairLock(initialState: [String: String]()) + private static let viewerLogins = OSAllocatedUnfairLock(initialState: [String: (login: String?, at: Date)]()) - /// The gh user on `host`, asked once per launch; nil while gh can't say. + /// The gh user on `host`, asked once per launch; nil while gh can't + /// say, asked again ten minutes later rather than at every refresh. private static func viewerLogin(ghPath: String, host: String) -> String? { - if let login = viewerLogins.withLock({ $0[host] }) { return login } - guard let result = GitHubCLIBoardClient.runGH( - ghPath, arguments: ["api", "user", "--hostname", host, "--jq", ".login"], timeout: 30 - ), result.terminationStatus == 0, - let login = String(data: result.standardOutput, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !login.isEmpty - else { return nil } - viewerLogins.withLock { $0[host] = login } - return login + if let known = viewerLogins.withLock({ $0[host] }), + known.login != nil || Date().timeIntervalSince(known.at) < 600 { + return known.login + } + let result = GitHubCLIBoardClient.runGH( + ghPath, arguments: ["api", "user", "--hostname", host, "--jq", ".login"], timeout: 10 + ) + let login = result.flatMap { $0.terminationStatus == 0 ? String(data: $0.standardOutput, encoding: .utf8) : nil }? + .trimmingCharacters(in: .whitespacesAndNewlines) + let known = (login: login?.isEmpty == false ? login : nil, at: Date()) + viewerLogins.withLock { $0[host] = known } + return known.login } static func fetch( @@ -223,10 +227,16 @@ enum PRDetect { deletions: candidate["deletions"] as? Int, changedFiles: candidate["changedFiles"] as? Int, title: candidate["title"] as? String, - otherAuthor: (candidate["author"] as? [String: Any])?["login"] as? String + otherAuthor: author(of: candidate) ) } + /// A bot is no teammate: no mark. + private static func author(of candidate: [String: Any]) -> String? { + guard let author = candidate["author"] as? [String: Any], author["is_bot"] as? Bool != true else { return nil } + return author["login"] as? String + } + private static func repository(for candidate: [String: Any]) -> GitHubRepository? { guard let owner = candidate["headRepositoryOwner"] as? [String: Any], let login = owner["login"] as? String, @@ -268,11 +278,8 @@ enum PRDetect { case .failure: return .failure } - // A mission's handover is the agent's note, not the work: a repo - // that tracks one would show it changed in every new worktree. - let handovers = BranchReview.Handover.names.map { ":(top,exclude)\($0)" } guard let output = gitOutput( - arguments: noIndexRefresh + ["diff", "--shortstat", "--"] + handovers, + arguments: noIndexRefresh + ["diff", "--shortstat"] + excludingHandovers, cwd: cwd, gitPath: gitPath ) else { return .failure } @@ -283,7 +290,7 @@ enum PRDetect { private static func diffPaths(cwd: String) -> [String] { // User-initiated: unlike the background shortstat, this may refresh // the index, or it would list touched-but-unchanged files. - guard let output = gitOutput(arguments: ["diff", "--name-only"], cwd: cwd) else { return [] } + guard let output = gitOutput(arguments: ["diff", "--name-only"] + excludingHandovers, cwd: cwd) else { return [] } return output .split(separator: "\n") .map(String.init) @@ -291,6 +298,10 @@ enum PRDetect { .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } + /// A mission's handover is the agent's note, not the work: a repo that + /// tracks one would show it changed in every new worktree. + private static let excludingHandovers = ["--"] + BranchReview.Handover.names.map { ":(top,exclude)\($0)" } + /// `git diff` refreshes and rewrites `.git/index` even under /// GIT_OPTIONAL_LOCKS=0. `--shortstat` still leaves stat-only changes /// out without the refresh (`--name-only` would not). diff --git a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift index 82db9c4c..bf9fc188 100644 --- a/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift +++ b/Sources/Nirux/Views/NiruxShellView+SidebarGit.swift @@ -49,10 +49,7 @@ extension NiruxShellView { deferredAgent: Self.sidebarDeferredAgent(of: col) ) } - // Titled with its branch, a worktree reads better by its PR's - // title. A name set by hand differs from the branch and stays. - let title = workspace.title == workspace.gitBranch ? workspace.prInfo?.title ?? workspace.title : workspace.title - return WorkspaceInfo(id: workspace.id, index: index, title: title, + return WorkspaceInfo(id: workspace.id, index: index, title: workspace.title, profileID: workspace.profileID, isInactive: workspace.isInactive, columnCount: workspace.columns.count, focusedColumn: workspace.focusedIndex, @@ -64,7 +61,8 @@ extension NiruxShellView { lastSummary: workspace.lastSummary, lastActivityAt: workspace.lastActivityAt, reviewBadges: workspace.reviewBadges, mergedCleanup: mergedCleanupOffer(workspaceIndex: index), - branchLabel: sidebarBranchLabel(of: workspace, among: visibleIndices)) + branchLabel: sidebarBranchLabel(of: workspace, among: visibleIndices), + lastPullRequest: workspace.lastPullRequest) } tickHiddenSpaceAgents(visibleIndices: visibleIndices, foregroundProcesses: foregroundProcesses) updateColumnHeaders(infos: infos, foregroundProcesses: foregroundProcesses, now: now) @@ -92,13 +90,13 @@ extension NiruxShellView { refreshProjectBoards(snapshot: snapshot, now: now, foregroundProcesses: foregroundProcesses) } - /// Line 2's branch, without the prefix it shares with the visible - /// worktrees of its repo. + /// Line 2's branch, without the prefix it shares with the listed + /// worktrees of its repo (the inactive ones are folded away). private func sidebarBranchLabel(of workspace: WorkspaceState, among visibleIndices: [Int]) -> String? { guard let branch = workspace.gitBranch else { return nil } guard let repository = workspace.gitContext?.upstreamRepository else { return branch } let siblings = visibleIndices.map { workspaces[$0] } - .filter { $0.gitContext?.upstreamRepository == repository } + .filter { !$0.isInactive && $0.gitContext?.upstreamRepository == repository } .compactMap(\.gitBranch) return SidebarCardLayout.distinctiveBranch(branch, siblings: siblings) } diff --git a/Sources/Nirux/Views/SidebarCardLayout.swift b/Sources/Nirux/Views/SidebarCardLayout.swift index e6997d35..55b3fe6c 100644 --- a/Sources/Nirux/Views/SidebarCardLayout.swift +++ b/Sources/Nirux/Views/SidebarCardLayout.swift @@ -212,11 +212,10 @@ struct SidebarCardLayout { /// cut after that prefix's last `/` or `-`: worktrees of one repo tell /// apart by their ends (`tomiir/payouts-ledger` → `ledger`). static func distinctiveBranch(_ branch: String, siblings: [String]) -> String { - let shared = siblings.filter { $0 != branch } - .map { branch.commonPrefix(with: $0) } - .max { $0.count < $1.count } ?? "" - guard let separator = shared.lastIndex(where: { $0 == "/" || $0 == "-" }) else { return branch } - return String(branch[branch.index(after: separator)...]) + let shared = siblings.filter { $0 != branch }.map { branch.commonPrefix(with: $0).count }.max() ?? 0 + guard let separator = branch.prefix(shared).lastIndex(where: { $0 == "/" || $0 == "-" }) else { return branch } + let rest = branch[branch.index(after: separator)...] + return rest.isEmpty ? branch : String(rest) } /// Between the feedback and the pull request, and the pull request and diff --git a/Sources/Nirux/Views/SidebarModels.swift b/Sources/Nirux/Views/SidebarModels.swift index 86966910..4682b9ee 100644 --- a/Sources/Nirux/Views/SidebarModels.swift +++ b/Sources/Nirux/Views/SidebarModels.swift @@ -246,6 +246,17 @@ struct WorkspaceInfo: Hashable { /// Line 2's branch, without what visible worktrees of its repo share /// (`SidebarCardLayout.distinctiveBranch`); nil prints `gitBranch`. var branchLabel: String? + /// `WorkspaceState.lastPullRequest`: names the card, marks a teammate's. + var lastPullRequest: PRInfo? + + /// Line 1: the PR's title while the workspace is titled with its + /// branch (a name set by hand differs and stays), without the + /// `type(scope): ` every PR of a repo shares. + var cardTitle: String { + guard title == gitBranch, let pullRequestTitle = lastPullRequest?.title else { return title } + let name = pullRequestTitle.replacing(/^\w+(\([^)]*\))?!?:\s+/, with: "") + return name.isEmpty ? pullRequestTitle : name + } var sidebarAction: (text: String, isBlocker: Bool)? { if let blocker = normalizedContextText(blocker) { diff --git a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift index c7124944..5497a12c 100644 --- a/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift +++ b/Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift @@ -114,7 +114,7 @@ final class SidebarWorkspaceCardRenderer { let titleX = layout.contentX + Metrics.stateDotSize + Theme.Space.sm let isQuiet = !workspace.isActive && [.idle, .done].contains(layout.state) let title = textLabel( - workspace.title, font: Theme.Font.title, + workspace.cardTitle, font: Theme.Font.title, color: isQuiet ? Theme.Color.textSecondary : Theme.Color.textPrimary ) title.toolTip = titleToolTip() @@ -141,7 +141,7 @@ final class SidebarWorkspaceCardRenderer { /// The title's tooltip: what the card doesn't print — the phase, the /// purpose, the last summary, another card's next step. private func titleToolTip() -> String { - var lines = [workspace.title, "Phase: \(workspace.phase.displayName)"] + var lines = [workspace.cardTitle, "Phase: \(workspace.phase.displayName)"] if let purpose = workspace.purpose { lines.append("Purpose: \(purpose)") } if let summary = workspace.lastSummary { lines.append("Last: \(summary)") } // The selected card prints the next step, every card its blocker. @@ -215,7 +215,7 @@ final class SidebarWorkspaceCardRenderer { if let branchName = workspace.gitBranch { var x = layout.indentX // A teammate's branch: their initial. Nothing on the user's own. - if let author = workspace.prInfo?.otherAuthor, let initial = author.first { + if let author = workspace.lastPullRequest?.otherAuthor, let initial = author.first { let badge = SidebarBadgeView( text: initial.uppercased(), textColor: Theme.Color.textSecondary, fillColor: Theme.Color.fillPressed, font: Theme.Font.label @@ -229,7 +229,7 @@ final class SidebarWorkspaceCardRenderer { x = badge.frame.maxX + Theme.Space.xs } let branch = textLabel(workspace.branchLabel ?? branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary) - // The end tells worktrees apart, the start their owner. + // A long branch keeps both ends. branch.lineBreakMode = .byTruncatingMiddle branch.toolTip = branchName branch.frame = NSRect(x: x, y: rowY, width: max(0, maxX - x), height: Metrics.branchRowHeight) @@ -487,7 +487,7 @@ final class SidebarWorkspaceCardRenderer { )) let titleX = x + 12 + Theme.Space.sm - let title = textLabel(workspace.title, font: Theme.Font.body, color: Theme.Color.textSecondary) + let title = textLabel(workspace.cardTitle, font: Theme.Font.body, color: Theme.Color.textSecondary) title.toolTip = titleToolTip() let height = ceil(title.fittingSize.height) title.frame = NSRect(x: titleX, y: midY - height / 2, width: max(0, badge.frame.minX - Theme.Space.sm - titleX), height: height) diff --git a/Tests/NiruxTests/PRDetectTests.swift b/Tests/NiruxTests/PRDetectTests.swift index 807c3e88..2fde2914 100644 --- a/Tests/NiruxTests/PRDetectTests.swift +++ b/Tests/NiruxTests/PRDetectTests.swift @@ -697,6 +697,9 @@ extension PRDetectTests { ]) XCTAssertEqual(info.title, "Ledger entries for payouts") XCTAssertEqual(info.otherAuthor, "tomiir") + + let bot = PRDetect.pullRequestInfo(from: ["author": ["login": "app/dependabot", "is_bot": true]]) + XCTAssertNil(bot.otherAuthor, "a bot is no teammate") } func testDiffStatsLeaveHandoversOut() throws { diff --git a/Tests/NiruxTests/SidebarCardStateTests.swift b/Tests/NiruxTests/SidebarCardStateTests.swift index ab0e1fde..6f63ad69 100644 --- a/Tests/NiruxTests/SidebarCardStateTests.swift +++ b/Tests/NiruxTests/SidebarCardStateTests.swift @@ -37,6 +37,29 @@ final class SidebarCardStateTests: XCTestCase { ) } + // MARK: - Title and branch rows + + func testCardIsNamedByItsPullRequestWhileTitledWithItsBranch() { + var pullRequest = pullRequest() + pullRequest.title = "feat(payouts): ledger entries" + var info = workspace([column()]) + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "checkout", "a name set by hand stays") + + info = WorkspaceInfo( + id: "ws", index: 1, title: "feat/checkout", profileID: WorkspaceProfile.defaultID, isInactive: false, + columnCount: 1, focusedColumn: 0, gitBranch: "feat/checkout", notification: nil, isActive: false, + columns: [column()], prInfo: nil, diffStats: nil, purpose: nil, nextStep: nil, blocker: nil, + phase: .active, lastSummary: nil, lastActivityAt: nil + ) + XCTAssertEqual(info.cardTitle, "feat/checkout") + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "ledger entries", "the type(scope) every PR shares is dropped") + pullRequest.title = "Ledger entries: part 2" + info.lastPullRequest = pullRequest + XCTAssertEqual(info.cardTitle, "Ledger entries: part 2") + } + func testBranchDropsThePrefixItSharesWithASibling() { let siblings = ["tomiir/payouts-ledger-entries", "tomiir/payouts-payout-status", "cheun/review-payouts", "cheun/review-fo-account"] let labels = siblings.map { SidebarCardLayout.distinctiveBranch($0, siblings: siblings) } @@ -50,7 +73,8 @@ final class SidebarCardStateTests: XCTestCase { func testBranchRowMarksATeammatesBranchAndPrintsItsDistinctiveEnd() { var teammate = pullRequest() teammate.otherAuthor = "tomiir" - var info = workspace([column()], pullRequest: teammate) + var info = workspace([column()]) + info.lastPullRequest = teammate info.branchLabel = "out" let views = SidebarWorkspaceCardRenderer(workspace: info, sidebarWidth: 260, yOffset: 400).render().views @@ -58,7 +82,9 @@ final class SidebarCardStateTests: XCTestCase { let branch = views.compactMap { $0 as? NSTextField }.first { $0.stringValue == "out" } XCTAssertEqual(branch?.toolTip, "feat/checkout") - let own = SidebarWorkspaceCardRenderer(workspace: workspace([column()], pullRequest: pullRequest()), sidebarWidth: 260, yOffset: 400) + var mine = workspace([column()], pullRequest: pullRequest()) + mine.lastPullRequest = pullRequest() + let own = SidebarWorkspaceCardRenderer(workspace: mine, sidebarWidth: 260, yOffset: 400) XCTAssertFalse(own.render().views.contains { ($0 as? SidebarBadgeView)?.accessibilityLabel()?.hasPrefix("Opened by") == true }) } @@ -67,6 +93,7 @@ final class SidebarCardStateTests: XCTestCase { XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["feat/checkout"]), "feat/checkout") XCTAssertEqual(SidebarCardLayout.distinctiveBranch("feat/checkout", siblings: ["fix/login"]), "feat/checkout") XCTAssertEqual(SidebarCardLayout.distinctiveBranch("main", siblings: ["mainline"]), "main") + XCTAssertEqual(SidebarCardLayout.distinctiveBranch("fix-", siblings: ["fix-login"]), "fix-") } private let stopped = SidebarStuckState.stoppedOnError(kind: "rate_limit", detail: nil, failedAt: 1, resume: .offered) diff --git a/Tests/NiruxTests/WorkspaceContextTests.swift b/Tests/NiruxTests/WorkspaceContextTests.swift index 7d62a98c..0b2e30c2 100644 --- a/Tests/NiruxTests/WorkspaceContextTests.swift +++ b/Tests/NiruxTests/WorkspaceContextTests.swift @@ -106,6 +106,27 @@ final class WorkspaceContextTests: XCTestCase { XCTAssertEqual(workspace.lastSummary, "Keep the completed work summary") } + func testCardKeepsItsPullRequestAcrossCommitsOfItsBranch() { + let workspace = WorkspaceState(title: "feature/task", cwd: "/tmp") + let pullRequest = PRInfo( + number: 42, state: "OPEN", isDraft: false, ciStatus: nil, checks: [], reviewDecision: nil, + mergeable: nil, url: "https://example.test/pull/42", additions: nil, deletions: nil, changedFiles: nil, + title: "Ledger entries" + ) + let original = GitContext(branch: "feature/task", identity: GitIdentity(repositoryRoot: "/repo", head: "head-a")) + let newCommit = GitContext(branch: "feature/task", identity: GitIdentity(repositoryRoot: "/repo", head: "head-b")) + let otherBranch = GitContext(branch: "feature/other", identity: GitIdentity(repositoryRoot: "/repo", head: "head-b")) + + XCTAssertTrue(workspace.updateGitContext(original)) + XCTAssertTrue(workspace.applyPullRequestInfo(pullRequest, for: original)) + XCTAssertTrue(workspace.updateGitContext(newCommit)) + XCTAssertNil(workspace.prInfo) + XCTAssertEqual(workspace.lastPullRequest, pullRequest) + + XCTAssertTrue(workspace.updateGitContext(otherBranch)) + XCTAssertNil(workspace.lastPullRequest) + } + func testGitContextChangesRejectStalePRMetadata() { let workspace = WorkspaceState(title: "context", cwd: "/tmp") let pullRequest = PRInfo(