feat(sidebar): show linked pull request metadata
This commit is contained in:
parent
5697f71fc6
commit
2d454df50f
9 changed files with 622 additions and 3 deletions
|
|
@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre
|
|||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertical + horizontal tabs</h3>
|
||||
Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
|
||||
|
|
@ -96,7 +96,7 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w
|
|||
|
||||
I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors.
|
||||
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
|
||||
The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
|
|||
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
|
||||
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
|
||||
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
|
||||
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
|
||||
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
|
|
@ -115,6 +118,48 @@ _cmux_prompt_command() {
|
|||
_CMUX_GIT_JOB_PID=$!
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# refresh on cwd change and periodically to avoid stale status.
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
IFS=$'\t' read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" || -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports: lightweight kick to the app's batched scanner every ~10s.
|
||||
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
|
||||
_cmux_ports_kick
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
|
|||
typeset -g _CMUX_GIT_HEAD_PATH=""
|
||||
typeset -g _CMUX_GIT_HEAD_MTIME=0
|
||||
typeset -g _CMUX_HAVE_ZSTAT=0
|
||||
typeset -g _CMUX_PR_LAST_PWD=""
|
||||
typeset -g _CMUX_PR_LAST_RUN=0
|
||||
typeset -g _CMUX_PR_JOB_PID=""
|
||||
typeset -g _CMUX_PR_FORCE=0
|
||||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
|
|
@ -143,7 +147,8 @@ _cmux_preexec() {
|
|||
local cmd="${1## }"
|
||||
case "$cmd" in
|
||||
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
|
||||
_CMUX_GIT_FORCE=1 ;;
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1 ;;
|
||||
esac
|
||||
|
||||
# Register TTY + kick batched port scan for foreground commands (servers).
|
||||
|
|
@ -200,6 +205,7 @@ _cmux_precmd() {
|
|||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1
|
||||
should_git=1
|
||||
fi
|
||||
fi
|
||||
|
|
@ -249,6 +255,63 @@ _cmux_precmd() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
|
||||
# - keep this independent from the git probe cadence to avoid hitting GitHub too often
|
||||
local should_pr=0
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
should_pr=1
|
||||
elif (( _CMUX_PR_FORCE )); then
|
||||
should_pr=1
|
||||
elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
should_pr=1
|
||||
fi
|
||||
|
||||
if (( should_pr )); then
|
||||
local can_launch_pr=1
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
else
|
||||
can_launch_pr=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( can_launch_pr )); then
|
||||
_CMUX_PR_FORCE=0
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
local IFS=$'\t'
|
||||
read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" ]] || [[ -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports: lightweight kick to the app's batched scanner.
|
||||
# - Periodic scan to avoid stale values.
|
||||
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
|
||||
|
|
|
|||
|
|
@ -2481,6 +2481,7 @@ private struct TabItemView: View {
|
|||
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
||||
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
|
||||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||||
|
|
@ -2767,6 +2768,33 @@ private struct TabItemView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Pull request row
|
||||
if sidebarShowPullRequest, let pullRequest = primaryPullRequestDisplay {
|
||||
Button(action: {
|
||||
updateSelection()
|
||||
NSWorkspace.shared.open(pullRequest.url)
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
PullRequestStatusIcon(
|
||||
status: pullRequest.status,
|
||||
color: pullRequestForegroundColor
|
||||
)
|
||||
Text("PR #\(pullRequest.number)")
|
||||
.underline()
|
||||
Text(pullRequestStatusLabel(pullRequest.status))
|
||||
if pullRequest.extraCount > 0 {
|
||||
Text("+\(pullRequest.extraCount)")
|
||||
.opacity(0.75)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(pullRequestForegroundColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Open Pull Request #\(pullRequest.number)")
|
||||
}
|
||||
|
||||
// Ports row
|
||||
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
||||
Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", "))
|
||||
|
|
@ -3270,6 +3298,36 @@ private struct TabItemView: View {
|
|||
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
||||
}
|
||||
|
||||
private struct PullRequestDisplay {
|
||||
let number: Int
|
||||
let url: URL
|
||||
let status: SidebarPullRequestStatus
|
||||
let extraCount: Int
|
||||
}
|
||||
|
||||
private var primaryPullRequestDisplay: PullRequestDisplay? {
|
||||
let pullRequests = tab.sidebarPullRequestsInDisplayOrder()
|
||||
guard let first = pullRequests.first else { return nil }
|
||||
return PullRequestDisplay(
|
||||
number: first.number,
|
||||
url: first.url,
|
||||
status: first.status,
|
||||
extraCount: max(0, pullRequests.count - 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var pullRequestForegroundColor: Color {
|
||||
isActive ? .white.opacity(0.75) : .secondary
|
||||
}
|
||||
|
||||
private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String {
|
||||
switch status {
|
||||
case .open: return "open"
|
||||
case .merged: return "merged"
|
||||
case .closed: return "closed"
|
||||
}
|
||||
}
|
||||
|
||||
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
|
||||
switch level {
|
||||
case .info: return "circle.fill"
|
||||
|
|
@ -3311,6 +3369,101 @@ private struct TabItemView: View {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
private struct PullRequestStatusIcon: View {
|
||||
let status: SidebarPullRequestStatus
|
||||
let color: Color
|
||||
private static let frameSize: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .open:
|
||||
PullRequestOpenIcon(color: color)
|
||||
case .merged:
|
||||
PullRequestMergedIcon(color: color)
|
||||
case .closed:
|
||||
Image(systemName: "xmark.circle")
|
||||
.font(.system(size: 9, weight: .regular))
|
||||
.foregroundColor(color)
|
||||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PullRequestOpenIcon: View {
|
||||
let color: Color
|
||||
private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round)
|
||||
private static let nodeDiameter: CGFloat = 3.4
|
||||
private static let frameSize: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 3.0, y: 4.8))
|
||||
path.addLine(to: CGPoint(x: 3.0, y: 9.2))
|
||||
|
||||
path.move(to: CGPoint(x: 4.8, y: 3.0))
|
||||
path.addLine(to: CGPoint(x: 9.4, y: 3.0))
|
||||
path.addLine(to: CGPoint(x: 11.0, y: 4.6))
|
||||
path.addLine(to: CGPoint(x: 11.0, y: 9.2))
|
||||
}
|
||||
.stroke(color, style: Self.stroke)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 3.0, y: 3.0)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 3.0, y: 11.0)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 11.0, y: 11.0)
|
||||
}
|
||||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PullRequestMergedIcon: View {
|
||||
let color: Color
|
||||
private static let stroke = StrokeStyle(lineWidth: 1.35, lineCap: .round, lineJoin: .round)
|
||||
private static let nodeDiameter: CGFloat = 3.4
|
||||
private static let frameSize: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 4.6, y: 4.6))
|
||||
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
|
||||
path.addLine(to: CGPoint(x: 9.2, y: 7.0))
|
||||
|
||||
path.move(to: CGPoint(x: 4.6, y: 9.4))
|
||||
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
|
||||
}
|
||||
.stroke(color, style: Self.stroke)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 3.0, y: 3.0)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 3.0, y: 11.0)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||||
.position(x: 11.0, y: 7.0)
|
||||
}
|
||||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTabColor(_ hex: String?, targetIds: [UUID]) {
|
||||
for targetId in targetIds {
|
||||
tabManager.setTabColor(tabId: targetId, color: hex)
|
||||
|
|
|
|||
|
|
@ -189,6 +189,16 @@ class TerminalController {
|
|||
return current.branch != branch || current.isDirty != isDirty
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplacePullRequest(
|
||||
current: SidebarPullRequestState?,
|
||||
number: Int,
|
||||
url: URL,
|
||||
status: SidebarPullRequestStatus
|
||||
) -> Bool {
|
||||
guard let current else { return true }
|
||||
return current.number != number || current.url != url || current.status != status
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
|
||||
let currentSorted = Array(Set(current ?? [])).sorted()
|
||||
let nextSorted = Array(Set(next)).sorted()
|
||||
|
|
@ -733,6 +743,12 @@ class TerminalController {
|
|||
case "clear_git_branch":
|
||||
return clearGitBranch(args)
|
||||
|
||||
case "report_pr":
|
||||
return reportPullRequest(args)
|
||||
|
||||
case "clear_pr":
|
||||
return clearPullRequest(args)
|
||||
|
||||
case "report_ports":
|
||||
return reportPorts(args)
|
||||
|
||||
|
|
@ -7879,6 +7895,8 @@ class TerminalController {
|
|||
clear_progress [--tab=X] - Clear progress bar
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
|
||||
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
|
||||
report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request
|
||||
clear_pr [--tab=X] [--panel=Y] - Clear pull request
|
||||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
|
|
@ -11092,6 +11110,119 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportPullRequest(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard parsed.positional.count >= 2 else {
|
||||
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
}
|
||||
|
||||
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
|
||||
guard let number = Int(numberToken), number > 0 else {
|
||||
return "ERROR: Invalid pull request number '\(rawNumber)'"
|
||||
}
|
||||
|
||||
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: rawURL),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return "ERROR: Invalid pull request URL '\(rawURL)'"
|
||||
}
|
||||
|
||||
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
|
||||
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
|
||||
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
|
||||
}
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
guard Self.shouldReplacePullRequest(
|
||||
current: tab.panelPullRequests[surfaceId],
|
||||
number: number,
|
||||
url: url,
|
||||
status: status
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPullRequest(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tab.clearPanelPullRequest(panelId: surfaceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
|
|
@ -11383,6 +11514,12 @@ class TerminalController {
|
|||
lines.append("git_branch=none")
|
||||
}
|
||||
|
||||
if let pr = tab.pullRequest {
|
||||
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
|
||||
} else {
|
||||
lines.append("pr=none")
|
||||
}
|
||||
|
||||
if tab.listeningPorts.isEmpty {
|
||||
lines.append("ports=none")
|
||||
} else {
|
||||
|
|
@ -11426,6 +11563,8 @@ class TerminalController {
|
|||
tab.progress = nil
|
||||
tab.gitBranch = nil
|
||||
tab.panelGitBranches.removeAll()
|
||||
tab.pullRequest = nil
|
||||
tab.panelPullRequests.removeAll()
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
tab.listeningPorts.removeAll()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@ struct SidebarGitBranchState {
|
|||
let isDirty: Bool
|
||||
}
|
||||
|
||||
enum SidebarPullRequestStatus: String {
|
||||
case open
|
||||
case merged
|
||||
case closed
|
||||
}
|
||||
|
||||
struct SidebarPullRequestState: Equatable {
|
||||
let number: Int
|
||||
let url: URL
|
||||
let status: SidebarPullRequestStatus
|
||||
}
|
||||
|
||||
enum SidebarBranchOrdering {
|
||||
struct BranchEntry: Equatable {
|
||||
let name: String
|
||||
|
|
@ -117,6 +129,43 @@ enum SidebarBranchOrdering {
|
|||
}
|
||||
}
|
||||
|
||||
static func orderedUniquePullRequests(
|
||||
orderedPanelIds: [UUID],
|
||||
panelPullRequests: [UUID: SidebarPullRequestState],
|
||||
fallbackPullRequest: SidebarPullRequestState?
|
||||
) -> [SidebarPullRequestState] {
|
||||
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
|
||||
switch status {
|
||||
case .merged: return 3
|
||||
case .open: return 2
|
||||
case .closed: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var orderedNumbers: [Int] = []
|
||||
var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:]
|
||||
|
||||
for panelId in orderedPanelIds {
|
||||
guard let state = panelPullRequests[panelId] else { continue }
|
||||
let number = state.number
|
||||
if pullRequestsByNumber[number] == nil {
|
||||
orderedNumbers.append(number)
|
||||
pullRequestsByNumber[number] = state
|
||||
continue
|
||||
}
|
||||
guard let existing = pullRequestsByNumber[number] else { continue }
|
||||
if statusPriority(state.status) > statusPriority(existing.status) {
|
||||
pullRequestsByNumber[number] = state
|
||||
}
|
||||
}
|
||||
|
||||
if orderedNumbers.isEmpty, let fallbackPullRequest {
|
||||
return [fallbackPullRequest]
|
||||
}
|
||||
|
||||
return orderedNumbers.compactMap { pullRequestsByNumber[$0] }
|
||||
}
|
||||
|
||||
static func orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [UUID],
|
||||
panelBranches: [UUID: SidebarGitBranchState],
|
||||
|
|
@ -300,6 +349,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var progress: SidebarProgressState?
|
||||
@Published var gitBranch: SidebarGitBranchState?
|
||||
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
||||
@Published var pullRequest: SidebarPullRequestState?
|
||||
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
|
||||
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
||||
@Published var listeningPorts: [Int] = []
|
||||
var surfaceTTYNames: [UUID: String] = [:]
|
||||
|
|
@ -803,6 +854,24 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) {
|
||||
let state = SidebarPullRequestState(number: number, url: url, status: status)
|
||||
let existing = panelPullRequests[panelId]
|
||||
if existing != state {
|
||||
panelPullRequests[panelId] = state
|
||||
}
|
||||
if panelId == focusedPanelId {
|
||||
pullRequest = state
|
||||
}
|
||||
}
|
||||
|
||||
func clearPanelPullRequest(panelId: UUID) {
|
||||
panelPullRequests.removeValue(forKey: panelId)
|
||||
if panelId == focusedPanelId {
|
||||
pullRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -851,6 +920,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
|
|
@ -901,6 +971,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
|
||||
SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: sidebarOrderedPanelIds(),
|
||||
panelPullRequests: panelPullRequests,
|
||||
fallbackPullRequest: pullRequest
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Panel Operations
|
||||
|
||||
/// Create a new split with a terminal panel
|
||||
|
|
@ -1797,6 +1875,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
currentDirectory = dir
|
||||
}
|
||||
gitBranch = panelGitBranches[targetPanelId]
|
||||
pullRequest = panelPullRequests[targetPanelId]
|
||||
}
|
||||
|
||||
/// Reconcile focus/first-responder convergence.
|
||||
|
|
@ -2018,6 +2097,7 @@ extension Workspace: BonsplitDelegate {
|
|||
currentDirectory = dir
|
||||
}
|
||||
gitBranch = panelGitBranches[panelId]
|
||||
pullRequest = panelPullRequests[panelId]
|
||||
|
||||
// Post notification
|
||||
NotificationCenter.default.post(
|
||||
|
|
@ -2156,6 +2236,7 @@ extension Workspace: BonsplitDelegate {
|
|||
surfaceIdToPanelId.removeValue(forKey: tabId)
|
||||
panelDirectories.removeValue(forKey: panelId)
|
||||
panelGitBranches.removeValue(forKey: panelId)
|
||||
panelPullRequests.removeValue(forKey: panelId)
|
||||
panelTitles.removeValue(forKey: panelId)
|
||||
panelCustomTitles.removeValue(forKey: panelId)
|
||||
pinnedPanelIds.remove(panelId)
|
||||
|
|
@ -2248,6 +2329,7 @@ extension Workspace: BonsplitDelegate {
|
|||
panels.removeValue(forKey: panelId)
|
||||
panelDirectories.removeValue(forKey: panelId)
|
||||
panelGitBranches.removeValue(forKey: panelId)
|
||||
panelPullRequests.removeValue(forKey: panelId)
|
||||
panelTitles.removeValue(forKey: panelId)
|
||||
panelCustomTitles.removeValue(forKey: panelId)
|
||||
pinnedPanelIds.remove(panelId)
|
||||
|
|
|
|||
|
|
@ -2568,6 +2568,7 @@ struct SettingsView: View {
|
|||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||||
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
@State private var topBlurBaselineOffset: CGFloat?
|
||||
|
|
@ -2773,6 +2774,16 @@ struct SettingsView: View {
|
|||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Pull Requests in Sidebar",
|
||||
subtitle: "Display PR status, number, and a clickable link when available."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowPullRequest)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: "Workspace Colors")
|
||||
|
|
@ -3300,6 +3311,7 @@ struct SettingsView: View {
|
|||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
sidebarShowPullRequest = true
|
||||
showOpenAccessConfirmation = false
|
||||
pendingOpenAccessMode = nil
|
||||
socketPasswordDraft = ""
|
||||
|
|
|
|||
|
|
@ -572,6 +572,37 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_pr(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report pull-request metadata for sidebar display."""
|
||||
cmd = f"report_pr {number} {url}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_pr(self, tab: str = None, panel: str = None) -> None:
|
||||
"""Clear pull-request metadata for sidebar display."""
|
||||
cmd = "clear_pr"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_ports(self, *ports: int, tab: str = None) -> None:
|
||||
"""Report listening ports for sidebar display."""
|
||||
port_str = " ".join(str(p) for p in ports)
|
||||
|
|
|
|||
94
tests/test_sidebar_pr.py
Normal file
94
tests/test_sidebar_pr.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar pull-request metadata.
|
||||
|
||||
Validates:
|
||||
1) report_pr writes sidebar PR state
|
||||
2) state transition open -> merged is reflected
|
||||
3) clear_pr removes PR metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
if state.get(key) == expected:
|
||||
return state
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
pr_number = 123
|
||||
pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}"
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
tab_id = client.current_workspace()
|
||||
surfaces = client.list_surfaces()
|
||||
if not surfaces:
|
||||
raise AssertionError("No surfaces found in selected workspace")
|
||||
panel_id = surfaces[0][1]
|
||||
|
||||
client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
|
||||
|
||||
client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}")
|
||||
|
||||
client.clear_pr(tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", "none")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar PR metadata test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar PR metadata test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue