From cb2ddef46fb770463f72f8ee3f511cb3eec9a3cc Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 24 Jun 2026 13:25:35 +0800 Subject: [PATCH 1/3] MoriUI: GitHub PR badge on the selected worktree row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected worktree row carries a compact, tappable PR badge — `#number` tinted by state (open/draft/review/approved/changes/merged) plus a CI glyph (✓/✕/⧗), full state in the tooltip. Inline on the row, so the sidebar stays two levels (no PR sub-row). Click opens the PR in the browser. - MoriCore: PullRequestInfo model + JSON parser (gh's mixed CheckRun / StatusContext rollup), transient AppState.pullRequests cache. Parser tested. - App: GitHubBackend actor (thin read-only gh wrapper); WorkspaceManager refreshes the selected worktree's PR on selection + each poll, local only. - MoriUI: PullRequestBadge rendered inline on the row. --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../MoriCore/Models/PullRequestInfo.swift | 140 ++++++++++++++++++ .../Sources/MoriCore/State/AppState.swift | 4 + .../MoriCoreTests/PullRequestInfoTests.swift | 86 +++++++++++ .../MoriCore/Tests/MoriCoreTests/main.swift | 3 + .../Sources/MoriUI/PullRequestBadge.swift | 82 ++++++++++ .../Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Sources/MoriUI/SidebarContainerView.swift | 4 + .../Sources/MoriUI/WorktreeRowView.swift | 9 ++ .../Sources/MoriUI/WorktreeSidebarView.swift | 18 ++- Sources/Mori/App/GitHubBackend.swift | 66 +++++++++ Sources/Mori/App/HostingControllers.swift | 1 + Sources/Mori/App/WorkspaceManager.swift | 26 ++++ 15 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift create mode 100644 Packages/MoriCore/Tests/MoriCoreTests/PullRequestInfoTests.swift create mode 100644 Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift create mode 100644 Sources/Mori/App/GitHubBackend.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b75456ba..9c4c8233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **macOS**: Added a configurable worktree location in Settings → General. New local worktrees are created under this base directory (defaults to `~/.mori`); existing worktrees are left where they are. Remote SSH worktrees still use the repo's parent directory. - **macOS**: Mori now imports a repo's existing git worktrees. Adding a project pulls in all of its on-disk worktrees automatically (not just the root), and a new "Import Existing Worktrees" item in the project context menu rescans on demand. Already-tracked worktrees are skipped, so re-running only picks up new ones. +- **macOS**: The selected worktree's sidebar row now carries a compact GitHub PR badge — `#number` tinted by PR state (open/draft/review/approved/changes/merged) plus a CI glyph (✓/✕/⧗), with the full state in the tooltip. The badge is display-only; right-click the worktree to open the PR on **GitHub** or on **DiffsHub**. Fetched live via `gh` for local worktrees; nothing shows when there's no PR or `gh` isn't installed. The badge sits inline on the row, so the sidebar stays at two levels. - **iOS (MoriRemote)**: Reworked the keyboard accessory bar by role and frequency — low-frequency app chrome (switch host, customize keys, detach) folds into one leading "•••" overflow menu; the high-frequency context actions (sessions switcher, tmux) stay pinned; the rest of the bar is typing keys with keyboard-dismiss pinned at the end. Removes the back/gear buttons that sat next to `ctrl`/`esc` and were easy to fat-finger. - **iOS (MoriRemote)**: The sidebar now mirrors the desktop hierarchy — projects group their tmux sessions (branches), and each window lists its panes (with agent-state badges) so you can switch project, tab, and pane from one place. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 7d85f6e0..11f4f4c0 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -20,6 +20,7 @@ - **macOS**:在「设置 → 通用」中新增可自定义的 worktree 位置。新建的本地 worktree 会创建在该基础目录下(默认 `~/.mori`);已有的 worktree 保持原位不动。远程 SSH worktree 仍使用仓库的父目录。 - **macOS**:Mori 现在会导入仓库已有的 git worktree。添加项目时会自动带出磁盘上的全部 worktree(不再只有根目录),项目右键菜单新增「导入已有 Worktree」可随时重新扫描。已跟踪的 worktree 会跳过,重复执行只会拾取新增的。 +- **macOS**:选中的 worktree 的侧栏行现在带一个紧凑的 GitHub PR 徽标——`#编号` 按 PR 状态(open/draft/review/approved/changes/merged)着色,外加一个 CI 图标(✓/✕/⧗),完整状态见 tooltip。徽标只用于展示;右键该 worktree 可在 **GitHub** 或 **DiffsHub** 打开 PR。本地 worktree 通过 `gh` 实时获取;没有 PR 或未装 `gh` 时不显示。徽标内嵌在行内,侧栏保持两级。 - **iOS(MoriRemote)**:键盘功能栏按角色和频率重排——低频 app 操作(切主机、自定义键、detach)折进最左的「•••」溢出菜单;高频上下文操作(会话切换器、tmux)常驻;其余为打字键,收键盘键钉在末尾。移除了原先紧贴 `ctrl`/`esc`、容易误触的返回键与齿轮键。 - **iOS(MoriRemote)**:侧栏改为与桌面端一致的层级——项目下分组各自的 tmux 会话(分支),每个窗口列出其窗格(带 agent 状态徽标),可在一处切换项目、标签页与窗格。 diff --git a/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift new file mode 100644 index 00000000..e20997d7 --- /dev/null +++ b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift @@ -0,0 +1,140 @@ +import Foundation + +/// A snapshot of the GitHub pull request associated with a worktree's branch. +/// +/// Populated live from `gh pr view --json …`; never persisted (it is +/// volatile and re-fetched on demand). Absence of an entry for a worktree means +/// "no PR, or not fetched yet" — both render as nothing in the UI. +public struct PullRequestInfo: Equatable, Sendable { + public enum State: String, Sendable { + case open, closed, merged + } + + /// Aggregate CI status rolled up from all check runs / status contexts. + public enum Checks: Sendable { + case none // no checks configured + case pending // at least one still running, none failing + case passing // all completed successfully + case failing // at least one failed + } + + public enum ReviewDecision: Sendable { + case none // no review required / requested + case required // review required, not yet given + case approved + case changesRequested + } + + public let number: Int + public let title: String + public let url: String + public let state: State + public let isDraft: Bool + public let checks: Checks + public let reviewDecision: ReviewDecision + + public init( + number: Int, + title: String, + url: String, + state: State, + isDraft: Bool, + checks: Checks, + reviewDecision: ReviewDecision + ) { + self.number = number + self.title = title + self.url = url + self.state = state + self.isDraft = isDraft + self.checks = checks + self.reviewDecision = reviewDecision + } + + /// Parse the JSON object emitted by + /// `gh pr view --json number,title,url,state,isDraft,reviewDecision,statusCheckRollup`. + /// Returns nil when the payload can't be decoded. + public static func parse(jsonData: Data) -> PullRequestInfo? { + guard let dto = try? JSONDecoder().decode(GhPullRequestDTO.self, from: jsonData) else { + return nil + } + let state: State = switch dto.state.uppercased() { + case "MERGED": .merged + case "CLOSED": .closed + default: .open + } + let review: ReviewDecision = switch (dto.reviewDecision ?? "").uppercased() { + case "APPROVED": .approved + case "CHANGES_REQUESTED": .changesRequested + case "REVIEW_REQUIRED": .required + default: .none + } + return PullRequestInfo( + number: dto.number, + title: dto.title, + url: dto.url, + state: state, + isDraft: dto.isDraft ?? false, + checks: rollUpChecks(dto.statusCheckRollup ?? []), + reviewDecision: review + ) + } + + /// Collapse the heterogeneous check list (CheckRun + StatusContext) into one + /// status. Any failure wins, then any pending, then passing, else none. + private static func rollUpChecks(_ items: [GhCheckDTO]) -> Checks { + guard !items.isEmpty else { return .none } + var sawPending = false + for item in items { + switch item.result { + case .failing: return .failing + case .pending: sawPending = true + case .passing: break + } + } + return sawPending ? .pending : .passing + } +} + +// MARK: - gh JSON DTOs + +private struct GhPullRequestDTO: Decodable { + let number: Int + let title: String + let url: String + let state: String + let isDraft: Bool? + let reviewDecision: String? + let statusCheckRollup: [GhCheckDTO]? +} + +/// A single entry in `statusCheckRollup`. GitHub mixes two shapes: GitHub Actions +/// `CheckRun` (status + conclusion) and external `StatusContext` (state). Decode +/// all three optionally and normalize. +private struct GhCheckDTO: Decodable { + enum Result { case passing, pending, failing } + + let status: String? // CheckRun: QUEUED | IN_PROGRESS | COMPLETED + let conclusion: String? // CheckRun: SUCCESS | FAILURE | NEUTRAL | … + let state: String? // StatusContext: SUCCESS | FAILURE | PENDING | ERROR + + var result: Result { + if let conclusion, !conclusion.isEmpty { + switch conclusion.uppercased() { + case "SUCCESS", "NEUTRAL", "SKIPPED": return .passing + case "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE": + return .failing + default: return .pending + } + } + if let state, !state.isEmpty { + switch state.uppercased() { + case "SUCCESS": return .passing + case "FAILURE", "ERROR": return .failing + default: return .pending + } + } + // CheckRun not yet completed (no conclusion). + return (status?.uppercased() == "COMPLETED") ? .passing : .pending + } +} diff --git a/Packages/MoriCore/Sources/MoriCore/State/AppState.swift b/Packages/MoriCore/Sources/MoriCore/State/AppState.swift index ca35e1a7..a4689cfc 100644 --- a/Packages/MoriCore/Sources/MoriCore/State/AppState.swift +++ b/Packages/MoriCore/Sources/MoriCore/State/AppState.swift @@ -9,6 +9,10 @@ public final class AppState { public var runtimePanes: [RuntimePane] = [] public var uiState: UIState = UIState() + /// Live GitHub PR snapshots keyed by worktree id. Transient (not persisted); + /// fetched on demand for the selected worktree. Absent = no PR or not fetched. + public var pullRequests: [UUID: PullRequestInfo] = [:] + public init() {} // MARK: - Derived state diff --git a/Packages/MoriCore/Tests/MoriCoreTests/PullRequestInfoTests.swift b/Packages/MoriCore/Tests/MoriCoreTests/PullRequestInfoTests.swift new file mode 100644 index 00000000..aba4fad2 --- /dev/null +++ b/Packages/MoriCore/Tests/MoriCoreTests/PullRequestInfoTests.swift @@ -0,0 +1,86 @@ +import Foundation +import MoriCore + +private func pr(_ json: String) -> PullRequestInfo? { + PullRequestInfo.parse(jsonData: Data(json.utf8)) +} + +func testPullRequestParseOpenPassing() { + let info = pr(""" + {"number":91,"title":"redesign sidebar","url":"https://github.com/o/r/pull/91", + "state":"OPEN","isDraft":false,"reviewDecision":"REVIEW_REQUIRED", + "statusCheckRollup":[{"status":"COMPLETED","conclusion":"SUCCESS"}, + {"status":"COMPLETED","conclusion":"NEUTRAL"}]} + """) + assertNotNil(info) + assertEqual(info?.number, 91) + assertEqual(info?.title, "redesign sidebar") + assertEqual(info?.state, .open) + assertFalse(info?.isDraft ?? true) + assertEqual(info?.checks, .passing) + assertEqual(info?.reviewDecision, .required) +} + +func testPullRequestParseFailingCheckWins() { + let info = pr(""" + {"number":1,"title":"x","url":"u","state":"OPEN", + "statusCheckRollup":[{"status":"COMPLETED","conclusion":"SUCCESS"}, + {"status":"IN_PROGRESS","conclusion":""}, + {"status":"COMPLETED","conclusion":"FAILURE"}]} + """) + assertEqual(info?.checks, .failing) +} + +func testPullRequestParsePendingWhenRunning() { + let info = pr(""" + {"number":2,"title":"x","url":"u","state":"OPEN", + "statusCheckRollup":[{"status":"COMPLETED","conclusion":"SUCCESS"}, + {"status":"IN_PROGRESS","conclusion":""}]} + """) + assertEqual(info?.checks, .pending) +} + +func testPullRequestParseStatusContextState() { + // External StatusContext entries carry `state`, not conclusion. + let info = pr(""" + {"number":3,"title":"x","url":"u","state":"OPEN", + "statusCheckRollup":[{"state":"SUCCESS"},{"state":"PENDING"}]} + """) + assertEqual(info?.checks, .pending) +} + +func testPullRequestParseNoChecks() { + let info = pr(""" + {"number":4,"title":"x","url":"u","state":"OPEN","statusCheckRollup":[]} + """) + assertEqual(info?.checks, PullRequestInfo.Checks.none) +} + +func testPullRequestParseDraftAndMerged() { + let draft = pr(""" + {"number":5,"title":"wip","url":"u","state":"OPEN","isDraft":true} + """) + assertEqual(draft?.isDraft, true) + assertEqual(draft?.checks, PullRequestInfo.Checks.none) + + let merged = pr(""" + {"number":6,"title":"done","url":"u","state":"MERGED","reviewDecision":"APPROVED"} + """) + assertEqual(merged?.state, .merged) + assertEqual(merged?.reviewDecision, .approved) +} + +func testPullRequestParseInvalidReturnsNil() { + assertNil(pr("not json")) + assertNil(pr("{\"title\":\"missing number\"}")) +} + +func runPullRequestInfoTests() { + testPullRequestParseOpenPassing() + testPullRequestParseFailingCheckWins() + testPullRequestParsePendingWhenRunning() + testPullRequestParseStatusContextState() + testPullRequestParseNoChecks() + testPullRequestParseDraftAndMerged() + testPullRequestParseInvalidReturnsNil() +} diff --git a/Packages/MoriCore/Tests/MoriCoreTests/main.swift b/Packages/MoriCore/Tests/MoriCoreTests/main.swift index 3f53387b..75e39d61 100644 --- a/Packages/MoriCore/Tests/MoriCoreTests/main.swift +++ b/Packages/MoriCore/Tests/MoriCoreTests/main.swift @@ -1469,6 +1469,9 @@ testBinaryResolverDefaultSearchDirectoriesIncludeExplicitFallbacks() // KeyBinding runKeyBindingTests() +// PullRequestInfo +runPullRequestInfoTests() + printResults() if failCount > 0 { diff --git a/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift b/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift new file mode 100644 index 00000000..f5a519bc --- /dev/null +++ b/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift @@ -0,0 +1,82 @@ +import SwiftUI +import MoriCore + +/// Compact inline PR indicator shown on the worktree row itself (not a sub-row), +/// so the sidebar stays two levels. Reads at a glance — `#number` tinted by PR +/// state, plus a CI check glyph; the exact state spells out in the tooltip. +/// +/// Deliberately non-interactive: opening the PR in a browser is heavyweight and +/// hard to undo, so it lives in the row's right-click menu rather than as a click +/// target inside the selection row, where a stray click would fire it. +struct PullRequestBadge: View { + let info: PullRequestInfo + let isSelected: Bool + + var body: some View { + HStack(spacing: 3) { + Text("#\(info.number)") + .font(MoriTokens.Font.monoSmall) + .foregroundStyle(numberColor) + checksGlyph + } + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + Capsule().fill(isSelected + ? Color.white.opacity(0.18) + : stateColor.opacity(0.14)) + ) + .help("\(stateLabel) · #\(info.number) — right-click the worktree to open\n\(info.url)") + } + + private var numberColor: Color { + isSelected ? Color.white.opacity(0.9) : stateColor + } + + @ViewBuilder + private var checksGlyph: some View { + switch info.checks { + case .passing: glyph("checkmark", MoriTokens.Color.success) + case .failing: glyph("xmark", MoriTokens.Color.error) + case .pending: glyph("clock", MoriTokens.Color.warning) + case .none: EmptyView() + } + } + + private func glyph(_ name: String, _ color: Color) -> some View { + Image(systemName: name) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(isSelected ? Color.white.opacity(0.85) : color) + } + + /// Short state used in the tooltip — review decision wins over a plain "open". + private var stateLabel: String { + if info.isDraft { return "Draft" } + switch info.state { + case .merged: return "Merged" + case .closed: return "Closed" + case .open: + switch info.reviewDecision { + case .changesRequested: return "Changes requested" + case .approved: return "Approved" + case .required: return "Review required" + case .none: return "Open" + } + } + } + + private var stateColor: Color { + if info.isDraft { return MoriTokens.Color.muted } + switch info.state { + case .merged: return MoriTokens.Color.active + case .closed: return MoriTokens.Color.error + case .open: + switch info.reviewDecision { + case .changesRequested: return MoriTokens.Color.error + case .approved: return MoriTokens.Color.success + case .required: return MoriTokens.Color.info + case .none: return MoriTokens.Color.success + } + } + } +} diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index 558a3704..4f8c79fb 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -96,6 +96,8 @@ "Remove Project…" = "Remove Project…"; "Import Existing Worktrees" = "Import Existing Worktrees"; "Remove Worktree…" = "Remove Worktree…"; +"Open PR on GitHub" = "Open PR on GitHub"; +"Open PR on DiffsHub" = "Open PR on DiffsHub"; "Resize Pane" = "Resize Pane"; "Restart Mori to apply language change." = "Restart Mori to apply language change."; "Reveal in Finder" = "Reveal in Finder"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index 98b2c3b6..3744cda8 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -96,6 +96,8 @@ "Remove Project…" = "移除项目…"; "Import Existing Worktrees" = "导入已有 Worktree"; "Remove Worktree…" = "移除工作树…"; +"Open PR on GitHub" = "在 GitHub 打开 PR"; +"Open PR on DiffsHub" = "在 DiffsHub 打开 PR"; "Resize Pane" = "调整面板大小"; "Restart Mori to apply language change." = "重启 Mori 以应用语言更改。"; "Reveal in Finder" = "在访达中显示"; diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index b60e4d22..52140989 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -31,6 +31,7 @@ public struct SidebarContainerView: View { private let onSendKeys: ((String, String) -> Void)? private let onUpdateProject: ((Project) -> Void)? private let onReorderProjects: (([UUID]) -> Void)? + private let pullRequests: [UUID: PullRequestInfo] private let isSidebarCollapsed: Bool public init( @@ -57,6 +58,7 @@ public struct SidebarContainerView: View { onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, + pullRequests: [UUID: PullRequestInfo] = [:], isSidebarCollapsed: Bool = false ) { self.projects = projects @@ -82,6 +84,7 @@ public struct SidebarContainerView: View { self.onSendKeys = onSendKeys self.onUpdateProject = onUpdateProject self.onReorderProjects = onReorderProjects + self.pullRequests = pullRequests self.isSidebarCollapsed = isSidebarCollapsed } @@ -114,6 +117,7 @@ public struct SidebarContainerView: View { onSendKeys: onSendKeys, onUpdateProject: onUpdateProject, onReorderProjects: onReorderProjects, + pullRequests: pullRequests, isSidebarCollapsed: isSidebarCollapsed ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index e599f80c..066c5424 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -20,6 +20,9 @@ public struct WorktreeRowView: View { /// Non-nil when a *hidden* window needs attention while collapsed; drives a /// small dot on the chip so alerts surface without forcing the level open. let hiddenAlertColor: Color? + /// GitHub PR for this worktree's branch. Rendered as a status strip below the + /// row only while selected, keeping the sidebar quiet for unselected rows. + let pullRequest: PullRequestInfo? let onSelect: () -> Void var onToggleExpand: (() -> Void)? var onRemove: (() -> Void)? @@ -33,6 +36,7 @@ public struct WorktreeRowView: View { windowCount: Int = 0, isExpanded: Bool = false, hiddenAlertColor: Color? = nil, + pullRequest: PullRequestInfo? = nil, onSelect: @escaping () -> Void, onToggleExpand: (() -> Void)? = nil, onRemove: (() -> Void)? = nil @@ -43,6 +47,7 @@ public struct WorktreeRowView: View { self.windowCount = windowCount self.isExpanded = isExpanded self.hiddenAlertColor = hiddenAlertColor + self.pullRequest = pullRequest self.onSelect = onSelect self.onToggleExpand = onToggleExpand self.onRemove = onRemove @@ -73,6 +78,10 @@ public struct WorktreeRowView: View { .lineLimit(1) } + if let pullRequest { + PullRequestBadge(info: pullRequest, isSelected: isSelected) + } + windowChip if isHovered { diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index 0d261753..bb163297 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -26,6 +26,8 @@ public struct WorktreeSidebarView: View { private let onSendKeys: ((String, String) -> Void)? private let onUpdateProject: ((Project) -> Void)? private let onReorderProjects: (([UUID]) -> Void)? + /// Live PR snapshots keyed by worktree id; only the selected row renders one. + private let pullRequests: [UUID: PullRequestInfo] private let shortcutHintsVisible: Bool private let isSidebarCollapsed: Bool @@ -42,9 +44,9 @@ public struct WorktreeSidebarView: View { @State private var expandedWorktrees: Set = [] public init( - projects: [Project] = [], selectedProjectId: UUID? = nil, worktrees: [Worktree], windows: [RuntimeWindow], panes: [RuntimePane] = [], selectedWorktreeId: UUID?, selectedWindowId: String?, shortcutHintsVisible: Bool = false, onSelectProject: ((UUID) -> Void)? = nil, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, onSelectPane: ((String) -> Void)? = nil, onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, onImportWorktrees: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, onAddProject: (() -> Void)? = nil, onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, isSidebarCollapsed: Bool = false + projects: [Project] = [], selectedProjectId: UUID? = nil, worktrees: [Worktree], windows: [RuntimeWindow], panes: [RuntimePane] = [], selectedWorktreeId: UUID?, selectedWindowId: String?, shortcutHintsVisible: Bool = false, onSelectProject: ((UUID) -> Void)? = nil, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, onSelectPane: ((String) -> Void)? = nil, onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, onImportWorktrees: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, onAddProject: (() -> Void)? = nil, onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, pullRequests: [UUID: PullRequestInfo] = [:], isSidebarCollapsed: Bool = false ) { - self.projects = projects; self.selectedProjectId = selectedProjectId; self.worktrees = worktrees; self.windows = windows; self.panes = panes; self.selectedWorktreeId = selectedWorktreeId; self.selectedWindowId = selectedWindowId; self.onSelectProject = onSelectProject; self.onSelectWorktree = onSelectWorktree; self.onSelectWindow = onSelectWindow; self.onSelectPane = onSelectPane; self.onShowCreatePanel = onShowCreatePanel; self.onRemoveWorktree = onRemoveWorktree; self.onRemoveProject = onRemoveProject; self.onImportWorktrees = onImportWorktrees; self.onEditRemoteProject = onEditRemoteProject; self.onCloseWindow = onCloseWindow; self.onToggleCollapse = onToggleCollapse; self.onAddProject = onAddProject; self.onRequestPaneOutput = onRequestPaneOutput; self.onSendKeys = onSendKeys; self.onUpdateProject = onUpdateProject; self.onReorderProjects = onReorderProjects; self.shortcutHintsVisible = shortcutHintsVisible; self.isSidebarCollapsed = isSidebarCollapsed + self.projects = projects; self.selectedProjectId = selectedProjectId; self.worktrees = worktrees; self.windows = windows; self.panes = panes; self.selectedWorktreeId = selectedWorktreeId; self.selectedWindowId = selectedWindowId; self.onSelectProject = onSelectProject; self.onSelectWorktree = onSelectWorktree; self.onSelectWindow = onSelectWindow; self.onSelectPane = onSelectPane; self.onShowCreatePanel = onShowCreatePanel; self.onRemoveWorktree = onRemoveWorktree; self.onRemoveProject = onRemoveProject; self.onImportWorktrees = onImportWorktrees; self.onEditRemoteProject = onEditRemoteProject; self.onCloseWindow = onCloseWindow; self.onToggleCollapse = onToggleCollapse; self.onAddProject = onAddProject; self.onRequestPaneOutput = onRequestPaneOutput; self.onSendKeys = onSendKeys; self.onUpdateProject = onUpdateProject; self.onReorderProjects = onReorderProjects; self.pullRequests = pullRequests; self.shortcutHintsVisible = shortcutHintsVisible; self.isSidebarCollapsed = isSidebarCollapsed } public var body: some View { @@ -194,7 +196,7 @@ public struct WorktreeSidebarView: View { private func worktreeRow(_ worktree: Worktree) -> some View { let wins = allWindows(for: worktree) let expanded = expandedWorktrees.contains(worktree.id) - return WorktreeRowView(worktree: worktree, agentName: nil, isSelected: worktree.id == selectedWorktreeId, windowCount: wins.count, isExpanded: expanded, hiddenAlertColor: hiddenWindowAlert(wins, expanded: expanded), onSelect: { onSelectWorktree(worktree.id) }, onToggleExpand: { toggleExpand(worktree.id) }, onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }) + return WorktreeRowView(worktree: worktree, agentName: nil, isSelected: worktree.id == selectedWorktreeId, windowCount: wins.count, isExpanded: expanded, hiddenAlertColor: hiddenWindowAlert(wins, expanded: expanded), pullRequest: pullRequests[worktree.id], onSelect: { onSelectWorktree(worktree.id) }, onToggleExpand: { toggleExpand(worktree.id) }, onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }) .padding(.leading, 14) .padding(.horizontal, MoriTokens.Spacing.sm) .overlay(alignment: .leading) { Rectangle().fill(Color.primary.opacity(MoriTokens.Opacity.subtle)).frame(width: 1).padding(.leading, 18) } @@ -271,7 +273,15 @@ public struct WorktreeSidebarView: View { if case .ssh = (project.location ?? .local), let onEditRemoteProject { Button { onEditRemoteProject(project.id) } label: { Label("Update Remote Credentials…", systemImage: "key") } } if let onRemoveProject { Divider(); Button(role: .destructive) { onRemoveProject(project.id) } label: { Label("Remove Project…", systemImage: "trash") } } } - @ViewBuilder private func worktreeActions(_ worktree: Worktree) -> some View { let editors = EditorLauncher.installed; if !editors.isEmpty { ForEach(editors) { editor in Button { editor.open(path: worktree.path) } label: { Label("Open in \(editor.name)", systemImage: editor.icon) } }; Divider() }; Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) } label: { Label("Reveal in Finder", systemImage: "folder") }; if !worktree.isMainWorktree, let onRemoveWorktree { Divider(); Button(role: .destructive) { onRemoveWorktree(worktree.id) } label: { Label("Remove Worktree…", systemImage: "trash") } } } + @ViewBuilder private func worktreeActions(_ worktree: Worktree) -> some View { let editors = EditorLauncher.installed; if !editors.isEmpty { ForEach(editors) { editor in Button { editor.open(path: worktree.path) } label: { Label("Open in \(editor.name)", systemImage: editor.icon) } }; Divider() }; Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) } label: { Label("Reveal in Finder", systemImage: "folder") }; if let pr = pullRequests[worktree.id], let github = URL(string: pr.url) { Divider(); Button { NSWorkspace.shared.open(github) } label: { Label("Open PR on GitHub", systemImage: "arrow.up.forward.app") }; if let diffshub = diffsHubURL(from: pr.url) { Button { NSWorkspace.shared.open(diffshub) } label: { Label("Open PR on DiffsHub", systemImage: "arrow.up.forward.square") } } }; if !worktree.isMainWorktree, let onRemoveWorktree { Divider(); Button(role: .destructive) { onRemoveWorktree(worktree.id) } label: { Label("Remove Worktree…", systemImage: "trash") } } } + + /// DiffsHub mirrors a GitHub PR at the same path on a different host — swap only + /// the host so any github.com URL maps cleanly, and bail if it isn't one. + private func diffsHubURL(from githubURL: String) -> URL? { + guard var components = URLComponents(string: githubURL), components.host == "github.com" else { return nil } + components.host = "diffshub.com" + return components.url + } } private enum SidebarFilter { case all, waiting, running } diff --git a/Sources/Mori/App/GitHubBackend.swift b/Sources/Mori/App/GitHubBackend.swift new file mode 100644 index 00000000..cfb72eda --- /dev/null +++ b/Sources/Mori/App/GitHubBackend.swift @@ -0,0 +1,66 @@ +import Foundation +import MoriCore + +/// Thin wrapper around the `gh` CLI for reading the pull request tied to a +/// worktree's branch. Read-only and best-effort: any failure (gh missing, no PR, +/// network/auth error) surfaces as `nil` rather than an error, since the UI +/// treats "no PR info" and "no PR" identically. +actor GitHubBackend { + private var resolvedBinaryPath: String? + + /// Fields requested from `gh pr view --json`. Kept in sync with `PullRequestInfo.parse`. + private static let jsonFields = "number,title,url,state,isDraft,reviewDecision,statusCheckRollup" + + /// Fetch the PR for `branch` in the repo at `directory`. Returns nil when + /// there is no open PR for the branch or gh is unavailable. + func pullRequest(forBranch branch: String, directory: String) async -> PullRequestInfo? { + guard let gh = binaryPath() else { return nil } + let result = await run( + gh, + ["pr", "view", branch, "--json", Self.jsonFields], + in: directory + ) + guard let result, result.exitCode == 0 else { return nil } + return PullRequestInfo.parse(jsonData: Data(result.stdout.utf8)) + } + + // MARK: - Private + + private func binaryPath() -> String? { + if let resolvedBinaryPath { return resolvedBinaryPath } + let path = BinaryResolver.resolve(command: "gh") + resolvedBinaryPath = path + return path + } + + private func run( + _ executablePath: String, + _ arguments: [String], + in directory: String + ) async -> (stdout: String, exitCode: Int32)? { + await withCheckedContinuation { continuation in + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: directory) + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + // gh must never block on a prompt; detach stdin. + process.standardInput = FileHandle.nullDevice + // Ensure gh resolves on PATH for any subprocesses it spawns. + process.environment = BinaryResolver.synthesizedEnvironment() + + process.terminationHandler = { proc in + let data = stdout.fileHandleForReading.readDataToEndOfFile() + continuation.resume(returning: (String(data: data, encoding: .utf8) ?? "", proc.terminationStatus)) + } + + do { + try process.run() + } catch { + continuation.resume(returning: nil) + } + } + } +} diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 54590d36..8025d5d8 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -140,6 +140,7 @@ struct SidebarContentView: View { onSendKeys: onSendKeys, onUpdateProject: onUpdateProject, onReorderProjects: onReorderProjects, + pullRequests: appState.pullRequests, isSidebarCollapsed: layoutState.isCollapsed ) } diff --git a/Sources/Mori/App/WorkspaceManager.swift b/Sources/Mori/App/WorkspaceManager.swift index 90370be2..f13239f6 100644 --- a/Sources/Mori/App/WorkspaceManager.swift +++ b/Sources/Mori/App/WorkspaceManager.swift @@ -77,6 +77,8 @@ final class WorkspaceManager { /// Local git backend. let gitBackend: GitBackend let gitStatusCoordinator: GitStatusCoordinator + /// Read-only GitHub PR lookups for the selected worktree (local only). + let gitHubBackend = GitHubBackend() let unreadTracker: UnreadTracker let notificationManager: NotificationManager let hookRunner: HookRunner @@ -512,9 +514,28 @@ final class WorkspaceManager { // Fire onWorktreeFocus hook fireHook(event: .onWorktreeFocus, worktreeId: worktreeId) + // Refresh the GitHub PR strip for the newly selected worktree. + Task { await refreshPullRequest(for: worktreeId) } + saveUIState() } + // MARK: - GitHub PR + + /// Fetch the PR for a worktree's branch and update `appState.pullRequests`. + /// Local worktrees only; remote (SSH) worktrees are skipped. Best-effort — + /// a missing gh, no PR, or any error just leaves the cache entry empty. + func refreshPullRequest(for worktreeId: UUID) async { + guard let worktree = appState.worktrees.first(where: { $0.id == worktreeId }), + let branch = worktree.branch, + case .local = location(for: worktree) else { return } + + let info = await gitHubBackend.pullRequest(forBranch: branch, directory: worktree.path) + if appState.pullRequests[worktreeId] != info { + appState.pullRequests[worktreeId] = info + } + } + // MARK: - Select Window func selectWindow(_ windowId: String) { @@ -1563,6 +1584,11 @@ final class WorkspaceManager { // Update worktree fields from git status updateWorktreeGitStatus(gitStatuses) + // Refresh the GitHub PR strip for the selected worktree (local only). + if let selectedId = appState.uiState.selectedWorktreeId { + await refreshPullRequest(for: selectedId) + } + // Roll up unread counts and aggregate badges updateUnreadCounts() updateAggregatedBadges() From e70bd87bff7a2bfbfa5b34fb883addf895c5e943 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 24 Jun 2026 14:38:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?MoriUI:=20share=20one=20worktree=20menu=20f?= =?UTF-8?q?or=20the=20row's=20=E2=80=A2=E2=80=A2=E2=80=A2=20and=20right-cl?= =?UTF-8?q?ick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover overflow menu and the context menu had drifted into two copies of the same items; the GitHub/DiffsHub PR actions only landed in one. Extract a single WorktreeContextActions view both render, and hang the github.com→ diffshub.com host swap off PullRequestInfo so there's one definition of each. --- .../MoriCore/Models/PullRequestInfo.swift | 9 +++ .../MoriUI/WorktreeContextActions.swift | 57 +++++++++++++++++++ .../Sources/MoriUI/WorktreeRowView.swift | 27 +-------- .../Sources/MoriUI/WorktreeSidebarView.swift | 11 +--- 4 files changed, 68 insertions(+), 36 deletions(-) create mode 100644 Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift diff --git a/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift index e20997d7..b9e90ea5 100644 --- a/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift +++ b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift @@ -51,6 +51,15 @@ public struct PullRequestInfo: Equatable, Sendable { self.reviewDecision = reviewDecision } + /// The same PR viewed on DiffsHub, which mirrors github.com PRs at an identical + /// path on its own host. Nil when `url` isn't a github.com URL (e.g. GHE), so + /// callers can hide the option rather than build a broken link. + public var diffsHubURL: URL? { + guard var components = URLComponents(string: url), components.host == "github.com" else { return nil } + components.host = "diffshub.com" + return components.url + } + /// Parse the JSON object emitted by /// `gh pr view --json number,title,url,state,isDraft,reviewDecision,statusCheckRollup`. /// Returns nil when the payload can't be decoded. diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift new file mode 100644 index 00000000..2432fbf8 --- /dev/null +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift @@ -0,0 +1,57 @@ +import SwiftUI +import MoriCore + +/// The single source of truth for a worktree's menu items, shared by the row's +/// hover "•••" menu and its right-click context menu so the two never drift. +/// Renders as menu content (a sequence of Buttons/Dividers) — drop it inside a +/// `Menu { }` or `.contextMenu { }`. +struct WorktreeContextActions: View { + let worktree: Worktree + let pullRequest: PullRequestInfo? + var onRemove: (() -> Void)? + + var body: some View { + let editors = EditorLauncher.installed + if !editors.isEmpty { + ForEach(editors) { editor in + Button { + editor.open(path: worktree.path) + } label: { + Label("Open in \(editor.name)", systemImage: editor.icon) + } + } + Divider() + } + + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) + } label: { + Label("Reveal in Finder", systemImage: "folder") + } + + if let pullRequest, let github = URL(string: pullRequest.url) { + Divider() + Button { + NSWorkspace.shared.open(github) + } label: { + Label("Open PR on GitHub", systemImage: "arrow.up.forward.app") + } + if let diffshub = pullRequest.diffsHubURL { + Button { + NSWorkspace.shared.open(diffshub) + } label: { + Label("Open PR on DiffsHub", systemImage: "arrow.up.forward.square") + } + } + } + + if !worktree.isMainWorktree, let onRemove { + Divider() + Button(role: .destructive) { + onRemove() + } label: { + Label("Remove Worktree…", systemImage: "trash") + } + } + } +} diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index 066c5424..39fa4af6 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -226,32 +226,7 @@ public struct WorktreeRowView: View { private var overflowMenu: some View { Menu { - let editors = EditorLauncher.installed - if !editors.isEmpty { - ForEach(editors) { editor in - Button { - editor.open(path: worktree.path) - } label: { - Label("Open in \(editor.name)", systemImage: editor.icon) - } - } - Divider() - } - - Button { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) - } label: { - Label("Reveal in Finder", systemImage: "folder") - } - - if !worktree.isMainWorktree, let onRemove { - Divider() - Button(role: .destructive) { - onRemove() - } label: { - Label("Remove Worktree…", systemImage: "trash") - } - } + WorktreeContextActions(worktree: worktree, pullRequest: pullRequest, onRemove: onRemove) } label: { Image(systemName: "ellipsis") .font(MoriTokens.Font.sidebarAccessory) diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index bb163297..0771bc17 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -200,7 +200,7 @@ public struct WorktreeSidebarView: View { .padding(.leading, 14) .padding(.horizontal, MoriTokens.Spacing.sm) .overlay(alignment: .leading) { Rectangle().fill(Color.primary.opacity(MoriTokens.Opacity.subtle)).frame(width: 1).padding(.leading, 18) } - .contextMenu { worktreeActions(worktree) } + .contextMenu { WorktreeContextActions(worktree: worktree, pullRequest: pullRequests[worktree.id], onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }) } } private func toggleExpand(_ id: UUID) { if expandedWorktrees.contains(id) { expandedWorktrees.remove(id) } else { expandedWorktrees.insert(id) } } @@ -273,15 +273,6 @@ public struct WorktreeSidebarView: View { if case .ssh = (project.location ?? .local), let onEditRemoteProject { Button { onEditRemoteProject(project.id) } label: { Label("Update Remote Credentials…", systemImage: "key") } } if let onRemoveProject { Divider(); Button(role: .destructive) { onRemoveProject(project.id) } label: { Label("Remove Project…", systemImage: "trash") } } } - @ViewBuilder private func worktreeActions(_ worktree: Worktree) -> some View { let editors = EditorLauncher.installed; if !editors.isEmpty { ForEach(editors) { editor in Button { editor.open(path: worktree.path) } label: { Label("Open in \(editor.name)", systemImage: editor.icon) } }; Divider() }; Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) } label: { Label("Reveal in Finder", systemImage: "folder") }; if let pr = pullRequests[worktree.id], let github = URL(string: pr.url) { Divider(); Button { NSWorkspace.shared.open(github) } label: { Label("Open PR on GitHub", systemImage: "arrow.up.forward.app") }; if let diffshub = diffsHubURL(from: pr.url) { Button { NSWorkspace.shared.open(diffshub) } label: { Label("Open PR on DiffsHub", systemImage: "arrow.up.forward.square") } } }; if !worktree.isMainWorktree, let onRemoveWorktree { Divider(); Button(role: .destructive) { onRemoveWorktree(worktree.id) } label: { Label("Remove Worktree…", systemImage: "trash") } } } - - /// DiffsHub mirrors a GitHub PR at the same path on a different host — swap only - /// the host so any github.com URL maps cleanly, and bail if it isn't one. - private func diffsHubURL(from githubURL: String) -> URL? { - guard var components = URLComponents(string: githubURL), components.host == "github.com" else { return nil } - components.host = "diffshub.com" - return components.url - } } private enum SidebarFilter { case all, waiting, running } From 12d0c918c84dafb87498c471388e2bb2e5760c41 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 24 Jun 2026 15:03:26 +0800 Subject: [PATCH 3/3] MoriUI: tidy up the PR badge feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Throttle the gh fetch to ~1/min; the 5s poll was spawning a subprocess every tick. Selection still forces an immediate refresh. - Derive the badge's display state once so its label and color can't diverge, instead of two copies of the isDraft→state→reviewDecision tree. - Move the github.com→diffshub.com host swap into the menu view; it's a presentation concern, not something the core PullRequestInfo model should know. --- .../MoriCore/Models/PullRequestInfo.swift | 9 ---- .../Sources/MoriUI/PullRequestBadge.swift | 51 ++++++++++++------- .../MoriUI/WorktreeContextActions.swift | 11 +++- Sources/Mori/App/WorkspaceManager.swift | 14 ++++- 4 files changed, 54 insertions(+), 31 deletions(-) diff --git a/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift index b9e90ea5..e20997d7 100644 --- a/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift +++ b/Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift @@ -51,15 +51,6 @@ public struct PullRequestInfo: Equatable, Sendable { self.reviewDecision = reviewDecision } - /// The same PR viewed on DiffsHub, which mirrors github.com PRs at an identical - /// path on its own host. Nil when `url` isn't a github.com URL (e.g. GHE), so - /// callers can hide the option rather than build a broken link. - public var diffsHubURL: URL? { - guard var components = URLComponents(string: url), components.host == "github.com" else { return nil } - components.host = "diffshub.com" - return components.url - } - /// Parse the JSON object emitted by /// `gh pr view --json number,title,url,state,isDraft,reviewDecision,statusCheckRollup`. /// Returns nil when the payload can't be decoded. diff --git a/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift b/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift index f5a519bc..d17d4119 100644 --- a/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift +++ b/Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift @@ -49,34 +49,47 @@ struct PullRequestBadge: View { .foregroundStyle(isSelected ? Color.white.opacity(0.85) : color) } - /// Short state used in the tooltip — review decision wins over a plain "open". - private var stateLabel: String { - if info.isDraft { return "Draft" } + /// The PR's display state, derived once (draft wins, then merged/closed, then + /// the review decision on an open PR) so the label and color can't diverge. + private enum DisplayState { + case draft, merged, closed, open, reviewRequired, approved, changesRequested + } + + private var displayState: DisplayState { + if info.isDraft { return .draft } switch info.state { - case .merged: return "Merged" - case .closed: return "Closed" + case .merged: return .merged + case .closed: return .closed case .open: switch info.reviewDecision { - case .changesRequested: return "Changes requested" - case .approved: return "Approved" - case .required: return "Review required" - case .none: return "Open" + case .changesRequested: return .changesRequested + case .approved: return .approved + case .required: return .reviewRequired + case .none: return .open } } } + /// Short state used in the tooltip. + private var stateLabel: String { + switch displayState { + case .draft: return "Draft" + case .merged: return "Merged" + case .closed: return "Closed" + case .open: return "Open" + case .reviewRequired: return "Review required" + case .approved: return "Approved" + case .changesRequested: return "Changes requested" + } + } + private var stateColor: Color { - if info.isDraft { return MoriTokens.Color.muted } - switch info.state { + switch displayState { + case .draft: return MoriTokens.Color.muted case .merged: return MoriTokens.Color.active - case .closed: return MoriTokens.Color.error - case .open: - switch info.reviewDecision { - case .changesRequested: return MoriTokens.Color.error - case .approved: return MoriTokens.Color.success - case .required: return MoriTokens.Color.info - case .none: return MoriTokens.Color.success - } + case .closed, .changesRequested: return MoriTokens.Color.error + case .open, .approved: return MoriTokens.Color.success + case .reviewRequired: return MoriTokens.Color.info } } } diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift index 2432fbf8..9ef38987 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeContextActions.swift @@ -36,7 +36,7 @@ struct WorktreeContextActions: View { } label: { Label("Open PR on GitHub", systemImage: "arrow.up.forward.app") } - if let diffshub = pullRequest.diffsHubURL { + if let diffshub = diffsHubURL(from: github) { Button { NSWorkspace.shared.open(diffshub) } label: { @@ -54,4 +54,13 @@ struct WorktreeContextActions: View { } } } + + /// DiffsHub mirrors a github.com PR at the same path on its own host. Returns + /// nil for non-github.com URLs (e.g. GHE) so the option simply doesn't appear. + private func diffsHubURL(from github: URL) -> URL? { + guard var components = URLComponents(url: github, resolvingAgainstBaseURL: false), + components.host == "github.com" else { return nil } + components.host = "diffshub.com" + return components.url + } } diff --git a/Sources/Mori/App/WorkspaceManager.swift b/Sources/Mori/App/WorkspaceManager.swift index f13239f6..f5d4b6a5 100644 --- a/Sources/Mori/App/WorkspaceManager.swift +++ b/Sources/Mori/App/WorkspaceManager.swift @@ -515,21 +515,31 @@ final class WorkspaceManager { fireHook(event: .onWorktreeFocus, worktreeId: worktreeId) // Refresh the GitHub PR strip for the newly selected worktree. - Task { await refreshPullRequest(for: worktreeId) } + Task { await refreshPullRequest(for: worktreeId, force: true) } saveUIState() } // MARK: - GitHub PR + /// Wall-clock of the last `gh` fetch per worktree, to keep the 5s poll from + /// spawning a subprocess every tick. CI/review state moves on a minute scale. + private var lastPullRequestFetch: [UUID: Date] = [:] + private static let pullRequestThrottle: TimeInterval = 60 + /// Fetch the PR for a worktree's branch and update `appState.pullRequests`. /// Local worktrees only; remote (SSH) worktrees are skipped. Best-effort — /// a missing gh, no PR, or any error just leaves the cache entry empty. - func refreshPullRequest(for worktreeId: UUID) async { + /// `force` bypasses the throttle (used on selection for an immediate refresh). + func refreshPullRequest(for worktreeId: UUID, force: Bool = false) async { guard let worktree = appState.worktrees.first(where: { $0.id == worktreeId }), let branch = worktree.branch, case .local = location(for: worktree) else { return } + if !force, let last = lastPullRequestFetch[worktreeId], + Date().timeIntervalSince(last) < Self.pullRequestThrottle { return } + lastPullRequestFetch[worktreeId] = Date() + let info = await gitHubBackend.pullRequest(forBranch: branch, directory: worktree.path) if appState.pullRequests[worktreeId] != info { appState.pullRequests[worktreeId] = info