diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba9800..fc39167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 7270554..eaae010 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### ✨ 新功能 + +- 新增 PR 工具面板:通过 `gh pr view` 查找当前本地 worktree 已存在的 GitHub Pull Request,并在 DiffsHub WebView 中打开。 + ## [0.4.5] - 2026-04-29 ### 🎨 界面优化 diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index ff8b51b..eab593d 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -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? @@ -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 @@ -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 @@ -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() } @@ -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) @@ -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) { @@ -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) @@ -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 @@ -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) + self.pullRequestViewController?.loadPullRequest(url) + } + } + @objc private func splitRightMenuAction() { guard let manager = workspaceManager else { return } Task { @MainActor in await manager.splitCurrentPane(horizontal: true) } diff --git a/Sources/Mori/App/CompanionContainerController.swift b/Sources/Mori/App/CompanionContainerController.swift new file mode 100644 index 0000000..2947b6f --- /dev/null +++ b/Sources/Mori/App/CompanionContainerController.swift @@ -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) + } +} diff --git a/Sources/Mori/App/CompanionToolPaneController.swift b/Sources/Mori/App/CompanionToolPaneController.swift index e2831f4..f3f9478 100644 --- a/Sources/Mori/App/CompanionToolPaneController.swift +++ b/Sources/Mori/App/CompanionToolPaneController.swift @@ -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") } } } @@ -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)" diff --git a/Sources/Mori/App/MainWindowController.swift b/Sources/Mori/App/MainWindowController.swift index 82b0486..3b7911b 100644 --- a/Sources/Mori/App/MainWindowController.swift +++ b/Sources/Mori/App/MainWindowController.swift @@ -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") } @@ -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), @@ -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)? @@ -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)) @@ -296,6 +302,7 @@ extension MainWindowController: NSToolbarDelegate { .flexibleSpace, ToolbarID.files, ToolbarID.git, + ToolbarID.pullRequest, ToolbarID.splitRight, ToolbarID.splitDown, ToolbarID.settings, diff --git a/Sources/Mori/App/PullRequestWebViewController.swift b/Sources/Mori/App/PullRequestWebViewController.swift new file mode 100644 index 0000000..c14c44b --- /dev/null +++ b/Sources/Mori/App/PullRequestWebViewController.swift @@ -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 + } +} diff --git a/Sources/Mori/App/WorkspaceManager.swift b/Sources/Mori/App/WorkspaceManager.swift index 7e7098a..c3ed4ab 100644 --- a/Sources/Mori/App/WorkspaceManager.swift +++ b/Sources/Mori/App/WorkspaceManager.swift @@ -2080,11 +2080,54 @@ final class WorkspaceManager { ) } + func fetchCurrentPullRequestURL() async -> URL? { + guard let worktree = selectedWorktree else { return nil } + return await fetchPullRequestURL(for: worktree) + } + + func fetchPullRequestURL(for worktree: Worktree) async -> URL? { + guard case .local = location(for: worktree) else { return nil } + guard let githubURL = await runPullRequestURLCommand(in: worktree.path) else { return nil } + return diffshubURL(from: githubURL) + } + /// Whether a worktree is currently selected (used to decide empty-state UI). var hasSelectedWorktree: Bool { selectedWorktree != nil } + private func runPullRequestURLCommand(in worktreePath: String) async -> URL? { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", "cd \(SSHCommandSupport.shellEscape(worktreePath)) && gh pr view --json url --jq .url"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } + + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return URL(string: output) + }.value + } + + private func diffshubURL(from githubURL: URL) -> URL? { + guard githubURL.host == "github.com" else { return githubURL } + var components = URLComponents(url: githubURL, resolvingAgainstBaseURL: false) + components?.host = "diffshub.com" + return components?.url + } + /// Recreate the tmux session for the current worktree and re-attach the terminal. /// Returns true when re-attach succeeded. @discardableResult diff --git a/Sources/Mori/Resources/en.lproj/Localizable.strings b/Sources/Mori/Resources/en.lproj/Localizable.strings index 511fcde..ddd49fb 100644 --- a/Sources/Mori/Resources/en.lproj/Localizable.strings +++ b/Sources/Mori/Resources/en.lproj/Localizable.strings @@ -72,12 +72,15 @@ "Open Lazygit" = "Open Lazygit"; "Open Project" = "Open Project"; "Open Project (⇧⌘O)" = "Open Project (⇧⌘O)"; +"Open Pull Request Companion Pane" = "Open Pull Request Companion Pane"; "Open Project…" = "Open Project…"; "Off" = "Off"; "Open Yazi" = "Open Yazi"; "Paste" = "Paste"; "Path:" = "Path:"; "Please select a project first." = "Please select a project first."; +"PR" = "PR"; +"Pull Request" = "Pull Request"; "Previous Pane" = "Previous Pane"; "Previous Tab" = "Previous Tab"; "Project" = "Project"; diff --git a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings index 4c83ec3..5fde260 100644 --- a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings @@ -72,12 +72,15 @@ "Open Lazygit" = "打开 Lazygit"; "Open Project" = "打开项目"; "Open Project (⇧⌘O)" = "打开项目 (⇧⌘O)"; +"Open Pull Request Companion Pane" = "打开 Pull Request 工具面板"; "Open Project..." = "打开项目..."; "Off" = "关闭"; "Open Yazi" = "打开 Yazi"; "Paste" = "粘贴"; "Path:" = "路径:"; "Please select a project first." = "请先选择一个项目。"; +"PR" = "PR"; +"Pull Request" = "Pull Request"; "Previous Pane" = "上一个窗格"; "Previous Tab" = "上一个标签页"; "Project" = "项目";