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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### ✨ Features

- Add a PR companion web view that opens the selected local worktree's existing GitHub pull request in DiffsHub via `gh pr view`.

## [0.4.5] - 2026-04-29

### 🎨 Design
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

## [Unreleased]

### ✨ 新功能

- 新增 PR 工具面板:通过 `gh pr view` 查找当前本地 worktree 已存在的 GitHub Pull Request,并在 DiffsHub WebView 中打开。

## [0.4.5] - 2026-04-29

### 🎨 界面优化
Expand Down
58 changes: 55 additions & 3 deletions Sources/Mori/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var appState: AppState?
private var terminalAreaController: TerminalAreaViewController?
private var companionToolController: CompanionToolPaneController?
private var companionContainer: CompanionContainerController?
private var pullRequestViewController: PullRequestWebViewController?
private var commandPaletteController: CommandPaletteController?
private var rootSplitVC: RootSplitViewController?
private var keyMonitor: Any?
Expand Down Expand Up @@ -109,6 +111,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.closeCompanionTool()
self.terminalAreaController?.focusCurrentSurface()
}
let pullRequestVC = PullRequestWebViewController()
self.pullRequestViewController = pullRequestVC
let companionContainer = CompanionContainerController(terminalPane: companionTool, pullRequestPane: pullRequestVC)
self.companionContainer = companionContainer

// Wire ghostty keybinding actions to Mori's tmux-based implementation.
// Ghostty maps keys to intents (new_tab, close_tab, etc.); Mori provides
Expand Down Expand Up @@ -249,7 +255,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let splitVC = RootSplitViewController(
sidebarController: sidebarController,
contentController: terminalArea,
companionController: companionTool
companionController: companionContainer
)
self.rootSplitVC = splitVC
companionToolState.width = splitVC.currentCompanionWidth
Expand All @@ -267,6 +273,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowController.onToggleGit = { [weak self] in
self?.toggleCompanionTool(.lazygit)
}
windowController.onTogglePullRequest = { [weak self] in
self?.togglePullRequest()
}
windowController.onSplitRight = { [weak self] in
self?.splitRightMenuAction()
}
Expand Down Expand Up @@ -303,7 +312,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let window = windowController.window {
adapter.syncWorkspaceWindowAppearance(window)
}
companionTool.updateAppearance(themeInfo: themeInfo, isKeyWindow: windowController.window?.isKeyWindow ?? true)
companionContainer.updateAppearance(themeInfo: themeInfo, isKeyWindow: windowController.window?.isKeyWindow ?? true)
// Restore saved frame after all layout is complete
windowController.restoreSavedFrame()
NSApp.activate(ignoringOtherApps: true)
Expand Down Expand Up @@ -955,7 +964,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let isKeyWindow = mainWindowController?.window?.isKeyWindow ?? true
sidebarController?.updateAppearance(themeInfo: themeInfo)
terminalAreaController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow)
companionToolController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow)
companionContainer?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow)
}

private func refreshSettingsWindowAppearance(adapter: GhosttyAdapter, themeInfo: GhosttyThemeInfo) {
Expand Down Expand Up @@ -1373,6 +1382,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}

private func showCompanionTool(_ tool: CompanionTool, context: CompanionToolLaunchContext, focus: Bool = true) {
companionContainer?.show(tool: tool)
companionToolState.activeTool = tool
companionToolState.presentation = .docked
companionToolController?.show(tool: tool, context: context, focus: focus)
Expand All @@ -1386,6 +1396,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return
}

if tool == .pullRequest {
guard manager.hasSelectedWorktree else {
closeCompanionTool()
return
}
Task { @MainActor [weak self] in
guard let self else { return }
if let url = await manager.fetchCurrentPullRequestURL() {
self.pullRequestViewController?.loadPullRequest(url)
} else {
NSSound.beep()
self.closeCompanionTool()
}
}
return
}

guard let context = manager.companionToolLaunchContext() else {
closeCompanionTool()
return
Expand All @@ -1402,6 +1429,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
toggleCompanionTool(.yazi)
}

private func togglePullRequest() {
let prVisible = companionToolState.activeTool == .pullRequest && companionToolState.isVisible
if prVisible {
closeCompanionTool()
terminalAreaController?.focusCurrentSurface()
return
}
guard let manager = workspaceManager, manager.hasSelectedWorktree else {
NSSound.beep()
return
}
Task { @MainActor [weak self] in
guard let self else { return }
guard let url = await manager.fetchCurrentPullRequestURL() else {
NSSound.beep()
return
}
self.companionContainer?.show(tool: .pullRequest)
self.companionToolState.activeTool = .pullRequest
self.companionToolState.presentation = .docked
self.rootSplitVC?.updateCompanionPane(state: self.companionToolState)
Comment on lines +1449 to +1452

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate PR context before applying async fetch result

After await manager.fetchCurrentPullRequestURL() returns, this block unconditionally switches the companion pane to PR and overwrites companionToolState. If the user changes selection or opens another companion tool while gh pr view is still running, the stale task result can reopen/replace the current pane with an out-of-date PR. Gate these assignments on current intent (e.g., still targeting PR for the same selected worktree) before mutating UI state.

Useful? React with 👍 / 👎.

self.pullRequestViewController?.loadPullRequest(url)
}
}

@objc private func splitRightMenuAction() {
guard let manager = workspaceManager else { return }
Task { @MainActor in await manager.splitCurrentPane(horizontal: true) }
Expand Down
61 changes: 61 additions & 0 deletions Sources/Mori/App/CompanionContainerController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import AppKit
import MoriTerminal

@MainActor
final class CompanionContainerController: NSViewController {
let terminalPane: CompanionToolPaneController
let pullRequestPane: PullRequestWebViewController

private var activeChild: NSViewController?

init(terminalPane: CompanionToolPaneController, pullRequestPane: PullRequestWebViewController) {
self.terminalPane = terminalPane
self.pullRequestPane = pullRequestPane
super.init(nibName: nil, bundle: nil)
}

@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }

override func loadView() {
let root = NSView()
root.wantsLayer = true
root.translatesAutoresizingMaskIntoConstraints = false
self.view = root
show(terminalPane)
}

func show(tool: CompanionTool) {
if tool == .pullRequest {
guard activeChild !== pullRequestPane else { return }
swap(to: pullRequestPane)
} else {
guard activeChild !== terminalPane else { return }
swap(to: terminalPane)
}
}

func updateAppearance(themeInfo: GhosttyThemeInfo, isKeyWindow: Bool) {
terminalPane.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow)
pullRequestPane.updateAppearance(themeInfo: themeInfo)
}

private func show(_ child: NSViewController) {
addChild(child)
child.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child.view)
NSLayoutConstraint.activate([
child.view.topAnchor.constraint(equalTo: view.topAnchor),
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
activeChild = child
}

private func swap(to child: NSViewController) {
activeChild?.view.removeFromSuperview()
activeChild?.removeFromParent()
show(child)
}
}
10 changes: 9 additions & 1 deletion Sources/Mori/App/CompanionToolPaneController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import MoriTerminal
enum CompanionTool: String, CaseIterable {
case yazi
case lazygit
case pullRequest

var command: String { rawValue }
var command: String {
switch self {
case .yazi, .lazygit: rawValue
case .pullRequest: ""
}
}

var title: String {
switch self {
case .yazi: .localized("Files")
case .lazygit: .localized("Git")
case .pullRequest: .localized("Pull Request")
}
}
}
Expand Down Expand Up @@ -127,6 +134,7 @@ final class CompanionToolPaneController: NSViewController {
}

func show(tool: CompanionTool, context: CompanionToolLaunchContext, focus: Bool = true) {
guard tool != .pullRequest else { return }
activeTool = tool
titleLabel.stringValue = tool.title
let identity = "tool|\(tool.rawValue)|\(context.location.endpointKey)|\(context.workspaceID)"
Expand Down
9 changes: 8 additions & 1 deletion Sources/Mori/App/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class MainWindowController: NSWindowController {
static let settings = NSToolbarItem.Identifier("settings")
static let files = NSToolbarItem.Identifier("openFiles")
static let git = NSToolbarItem.Identifier("openGit")
static let pullRequest = NSToolbarItem.Identifier("openPullRequest")
static let splitRight = NSToolbarItem.Identifier("splitRight")
static let splitDown = NSToolbarItem.Identifier("splitDown")
}
Expand Down Expand Up @@ -53,6 +54,9 @@ final class MainWindowController: NSWindowController {
ToolbarItemDef(id: ToolbarID.git, label: .localized("Git"),
toolTip: .localized("Open Git Companion Pane (⌘G)"),
symbol: "point.topleft.down.curvedto.point.bottomright.up", hint: "⌘G", callback: \.onToggleGit),
ToolbarItemDef(id: ToolbarID.pullRequest, label: .localized("PR"),
toolTip: .localized("Open Pull Request Companion Pane"),
symbol: "arrow.triangle.pull", hint: "", callback: \.onTogglePullRequest),
ToolbarItemDef(id: ToolbarID.splitRight, label: .localized("Split Right"),
toolTip: .localized("Split the current pane to the right (⌘D)"),
symbol: "rectangle.split.2x1", hint: "⌘D", callback: \.onSplitRight),
Expand All @@ -68,6 +72,7 @@ final class MainWindowController: NSWindowController {
var onOpenSettings: (() -> Void)?
var onToggleFiles: (() -> Void)?
var onToggleGit: (() -> Void)?
var onTogglePullRequest: (() -> Void)?
var onSplitRight: (() -> Void)?
var onSplitDown: (() -> Void)?
var onShowCreateWorktreePanel: (() -> Void)?
Expand Down Expand Up @@ -217,7 +222,8 @@ final class MainWindowController: NSWindowController {
themeFrame.layoutSubtreeIfNeeded()

for def in Self.toolbarItemDefs {
guard let anchor = toolbarButtonViews[def.id], anchor.window != nil else { continue }
guard !def.hint.isEmpty,
let anchor = toolbarButtonViews[def.id], anchor.window != nil else { continue }

let anchorRect = anchor.convert(anchor.bounds, to: themeFrame)
let pill = NSHostingView(rootView: ShortcutHintPill(def.hint))
Expand Down Expand Up @@ -296,6 +302,7 @@ extension MainWindowController: NSToolbarDelegate {
.flexibleSpace,
ToolbarID.files,
ToolbarID.git,
ToolbarID.pullRequest,
ToolbarID.splitRight,
ToolbarID.splitDown,
ToolbarID.settings,
Expand Down
79 changes: 79 additions & 0 deletions Sources/Mori/App/PullRequestWebViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import AppKit
import WebKit
import MoriTerminal

@MainActor
final class PullRequestWebViewController: NSViewController {
private var webView: WKWebView!
private let headerView = NSView()
private let titleLabel = NSTextField(labelWithString: "")
private let dividerView = NSView()

override func loadView() {
let root = NSView()
root.wantsLayer = true
root.translatesAutoresizingMaskIntoConstraints = false

headerView.translatesAutoresizingMaskIntoConstraints = false
headerView.wantsLayer = true

titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.stringValue = .localized("Pull Request")
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
titleLabel.lineBreakMode = .byTruncatingTail

dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.wantsLayer = true

let config = WKWebViewConfiguration()
let wv = WKWebView(frame: .zero, configuration: config)
wv.translatesAutoresizingMaskIntoConstraints = false
wv.underPageBackgroundColor = .clear
self.webView = wv

root.addSubview(headerView)
root.addSubview(dividerView)
root.addSubview(wv)
headerView.addSubview(titleLabel)

NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: root.topAnchor),
headerView.leadingAnchor.constraint(equalTo: root.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: root.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 28),

titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 10),
titleLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -10),
titleLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),

dividerView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
dividerView.leadingAnchor.constraint(equalTo: root.leadingAnchor),
dividerView.trailingAnchor.constraint(equalTo: root.trailingAnchor),
dividerView.heightAnchor.constraint(equalToConstant: 1),

wv.topAnchor.constraint(equalTo: dividerView.bottomAnchor),
wv.leadingAnchor.constraint(equalTo: root.leadingAnchor),
wv.trailingAnchor.constraint(equalTo: root.trailingAnchor),
wv.bottomAnchor.constraint(equalTo: root.bottomAnchor),
])

self.view = root
}

func loadPullRequest(_ url: URL) {
titleLabel.stringValue = url.absoluteString
webView.load(URLRequest(url: url))
}

func updateAppearance(themeInfo: GhosttyThemeInfo) {
view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua)
view.layer?.backgroundColor = themeInfo.effectiveBackground.cgColor
let blend: CGFloat = themeInfo.isDark ? 0.12 : 0.06
let tint = themeInfo.isDark ? NSColor.white : NSColor.black
let headerBg = (themeInfo.effectiveBackground.usingColorSpace(.deviceRGB) ?? themeInfo.effectiveBackground)
.blended(withFraction: blend, of: tint) ?? themeInfo.effectiveBackground
headerView.layer?.backgroundColor = headerBg.cgColor
dividerView.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor
titleLabel.textColor = .secondaryLabelColor
}
}
Loading
Loading