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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 状态徽标),可在一处切换项目、标签页与窗格。

Expand Down
140 changes: 140 additions & 0 deletions Packages/MoriCore/Sources/MoriCore/Models/PullRequestInfo.swift
Original file line number Diff line number Diff line change
@@ -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 <branch> --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 <branch> --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
}
}
4 changes: 4 additions & 0 deletions Packages/MoriCore/Sources/MoriCore/State/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions Packages/MoriCore/Tests/MoriCoreTests/PullRequestInfoTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
3 changes: 3 additions & 0 deletions Packages/MoriCore/Tests/MoriCoreTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,9 @@ testBinaryResolverDefaultSearchDirectoriesIncludeExplicitFallbacks()
// KeyBinding
runKeyBindingTests()

// PullRequestInfo
runPullRequestInfoTests()

printResults()

if failCount > 0 {
Expand Down
95 changes: 95 additions & 0 deletions Packages/MoriUI/Sources/MoriUI/PullRequestBadge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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)
}

/// 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 .open:
switch info.reviewDecision {
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 {
switch displayState {
case .draft: return MoriTokens.Color.muted
case .merged: return MoriTokens.Color.active
case .closed, .changesRequested: return MoriTokens.Color.error
case .open, .approved: return MoriTokens.Color.success
case .reviewRequired: return MoriTokens.Color.info
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "在访达中显示";
Expand Down
Loading
Loading