diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 46d3c147f7..0f14db895c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1859,6 +1859,64 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr Task { await gitDiffSidebarState.setVisible(false, cwd: nil) } } + @objc func openInEditor(_ sender: Any?) { + // If the dropdown segment (1) was clicked, show the editor menu + if let segmented = sender as? NSSegmentedControl, segmented.selectedSegment == 1 { + if let menu = segmented.menu(forSegment: 1) { + let screenRect = segmented.window?.convertToScreen( + segmented.convert(segmented.bounds, to: nil) + ) ?? .zero + let origin = NSPoint(x: screenRect.minX, y: screenRect.minY) + menu.popUp(positioning: nil, at: origin, in: nil) + } + return + } + + guard let editor = WorktrunkPreferences.preferredEditor else { return } + openIn(editor: editor) + } + + @objc func openInSpecificEditor(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let editor = menuItem.representedObject as? ExternalEditor else { return } + openIn(editor: editor) + } + + private func openIn(editor: ExternalEditor) { + guard let appURL = editor.appURL else { return } + let cwd = currentEditorPath() + guard let cwd else { return } + + WorktrunkPreferences.lastEditor = editor + refreshEditorToolbarIcon(for: editor) + + let url = URL(fileURLWithPath: cwd) + let config = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: config) + } + + private func refreshEditorToolbarIcon(for editor: ExternalEditor) { + guard let toolbar = window?.toolbar else { return } + for item in toolbar.items { + guard item.itemIdentifier == .openInEditor, + let segmented = item.view as? NSSegmentedControl else { continue } + EditorSplitButton.updateIcon(segmented, editor: editor) + break + } + } + + private func currentEditorPath() -> String? { + // Prefer selected worktree path from sidebar, fall back to focused surface pwd + switch worktrunkSidebarState.selection { + case .worktree(_, let path): + return path + case .session(_, _, let worktreePath): + return worktreePath + default: + return focusedSurface?.pwd + } + } + private func resumeAISession(_ session: AISession) { var base = Ghostty.SurfaceConfiguration() base.workingDirectory = session.cwd diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 8c4c05abb8..86db73e46e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -286,23 +286,24 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // MARK: NSToolbarDelegate func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - var items: [NSToolbarItem.Identifier] = [ + [ .toggleSidebar, .sidebarTrackingSeparator, .title, .flexibleSpace, .space, + .openInEditor, ] - return items } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [ + [ .toggleSidebar, .sidebarTrackingSeparator, .flexibleSpace, .title, .flexibleSpace, + .openInEditor, ] } @@ -335,11 +336,32 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool item.label = "Toggle Sidebar" item.isNavigational = true return item + case .openInEditor: + return makeOpenInEditorItem() default: return NSToolbarItem(itemIdentifier: itemIdentifier) } } + private func makeOpenInEditorItem() -> NSToolbarItem? { + let installed = ExternalEditor.installedEditors() + guard !installed.isEmpty else { return nil } + + let controller = windowController as? TerminalController + + let item = NSToolbarItem(itemIdentifier: .openInEditor) + item.label = "Open in Editor" + item.toolTip = "Open in Editor" + + let segmented = EditorSplitButton.make( + editors: installed, + target: controller + ) + + item.view = segmented + return item + } + // MARK: SwiftUI class ViewModel: ObservableObject { diff --git a/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift b/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift index d225a97cd0..7822e6f4bd 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation enum WorktrunkAgent: String, CaseIterable, Identifiable { @@ -132,12 +133,147 @@ enum WorktrunkOpenBehavior: String, CaseIterable, Identifiable { } } +enum ExternalEditorCategory: String { + case editors + case git + case finder +} + +enum ExternalEditor: String, CaseIterable, Identifiable { + // Editors + case cursor + case vscode + case vscodium + case zed + case sublime + case nova + case textmate + case xcode + // JetBrains + case intellij + case webstorm + case pycharm + case goland + case rubymine + case clion + case rider + case phpstorm + case fleet + // Git clients + case tower + case fork + case gitkraken + case sourcetree + case githubDesktop + // Finder + case finder + + var id: String { rawValue } + + var category: ExternalEditorCategory { + switch self { + case .tower, .fork, .gitkraken, .sourcetree, .githubDesktop: + return .git + case .finder: + return .finder + default: + return .editors + } + } + + var title: String { + switch self { + case .cursor: return "Cursor" + case .vscode: return "VS Code" + case .vscodium: return "VSCodium" + case .zed: return "Zed" + case .sublime: return "Sublime Text" + case .nova: return "Nova" + case .xcode: return "Xcode" + case .textmate: return "TextMate" + case .intellij: return "IntelliJ IDEA" + case .webstorm: return "WebStorm" + case .pycharm: return "PyCharm" + case .goland: return "GoLand" + case .rubymine: return "RubyMine" + case .clion: return "CLion" + case .rider: return "Rider" + case .phpstorm: return "PhpStorm" + case .fleet: return "Fleet" + case .tower: return "Tower" + case .fork: return "Fork" + case .gitkraken: return "GitKraken" + case .sourcetree: return "Sourcetree" + case .githubDesktop: return "GitHub Desktop" + case .finder: return "Finder" + } + } + + var bundleIdentifier: String { + switch self { + case .cursor: return "com.todesktop.230313mzl4w4u92" + case .vscode: return "com.microsoft.VSCode" + case .vscodium: return "com.vscodium" + case .zed: return "dev.zed.Zed" + case .sublime: return "com.sublimetext.4" + case .nova: return "com.panic.Nova" + case .xcode: return "com.apple.dt.Xcode" + case .textmate: return "com.macromates.TextMate" + case .intellij: return "com.jetbrains.intellij" + case .webstorm: return "com.jetbrains.WebStorm" + case .pycharm: return "com.jetbrains.pycharm" + case .goland: return "com.jetbrains.goland" + case .rubymine: return "com.jetbrains.rubymine" + case .clion: return "com.jetbrains.CLion" + case .rider: return "com.jetbrains.rider" + case .phpstorm: return "com.jetbrains.PhpStorm" + case .fleet: return "fleet.app" + case .tower: return "com.fournova.Tower3" + case .fork: return "com.DanPristupov.Fork" + case .gitkraken: return "com.axosoft.gitkraken" + case .sourcetree: return "com.torusknot.SourceTreeNotMAS" + case .githubDesktop: return "com.github.GitHubClient" + case .finder: return "com.apple.finder" + } + } + + var appURL: URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + + var isInstalled: Bool { + appURL != nil + } + + var appIcon: NSImage? { + guard let url = appURL else { return nil } + return NSWorkspace.shared.icon(forFile: url.path) + } + + static func installedEditors() -> [ExternalEditor] { + allCases.filter { $0.isInstalled } + } + + static func installedByCategory() -> [(category: ExternalEditorCategory, editors: [ExternalEditor])] { + let installed = installedEditors() + var result: [(category: ExternalEditorCategory, editors: [ExternalEditor])] = [] + for cat in [ExternalEditorCategory.editors, .git, .finder] { + let group = installed.filter { $0.category == cat } + if !group.isEmpty { + result.append((category: cat, editors: group)) + } + } + return result + } +} + enum WorktrunkPreferences { static let openBehaviorKey = "GhosttyWorktrunkOpenBehavior.v1" static let worktreeTabsKey = "GhosttyWorktreeTabs.v1" static let sidebarTabsKey = "GhostreeWorktrunkSidebarTabs.v1" static let defaultAgentKey = "GhosttyWorktrunkDefaultAgent.v1" static let githubIntegrationKey = "GhostreeGitHubIntegration.v1" + static let lastEditorKey = "GhostreeLastEditor.v1" static var worktreeTabsEnabled: Bool { UserDefaults.standard.bool(forKey: worktreeTabsKey) @@ -157,4 +293,22 @@ enum WorktrunkPreferences { } return UserDefaults.standard.bool(forKey: githubIntegrationKey) } + + static var lastEditor: ExternalEditor? { + get { + guard let raw = UserDefaults.standard.string(forKey: lastEditorKey) else { return nil } + return ExternalEditor(rawValue: raw) + } + set { + UserDefaults.standard.set(newValue?.rawValue, forKey: lastEditorKey) + } + } + + static var preferredEditor: ExternalEditor? { + let installed = ExternalEditor.installedEditors() + if let last = lastEditor, installed.contains(last) { + return last + } + return installed.first + } } diff --git a/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift b/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift index 71bafc988a..0c20f21e67 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift @@ -46,11 +46,11 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace] + [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace] + [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor] } func toolbar( @@ -84,11 +84,30 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { item.label = "Toggle Sidebar" item.isNavigational = true return item + case .openInEditor: + return makeOpenInEditorItem() default: return NSToolbarItem(itemIdentifier: itemIdentifier) } } + private func makeOpenInEditorItem() -> NSToolbarItem? { + let installed = ExternalEditor.installedEditors() + guard !installed.isEmpty else { return nil } + + let item = NSToolbarItem(itemIdentifier: .openInEditor) + item.label = "Open in Editor" + item.toolTip = "Open in Editor" + + let segmented = EditorSplitButton.make( + editors: installed, + target: targetController + ) + + item.view = segmented + return item + } + private func updateTitleAttributes() { let text = titleTextField.stringValue.isEmpty ? " " : titleTextField.stringValue let baseFont = titleFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) @@ -102,6 +121,87 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { extension NSToolbarItem.Identifier { static let worktrunkTitleText = NSToolbarItem.Identifier("WorktrunkTitleText") + static let openInEditor = NSToolbarItem.Identifier("OpenInEditor") +} + +/// A split button for the "Open in Editor" toolbar item. +/// Left segment: click to open in the preferred editor (shows that editor's app icon). +/// Right segment: dropdown arrow that shows a menu of all installed editors. +enum EditorSplitButton { + private static let iconSize = NSSize(width: 16, height: 16) + private static let fallbackImage = NSImage( + systemSymbolName: "curlybraces", + accessibilityDescription: "Open in Editor" + )! + + static func make(editors: [ExternalEditor], target: TerminalController?) -> NSSegmentedControl { + let segmented = NSSegmentedControl() + segmented.segmentCount = 2 + segmented.trackingMode = .momentary + segmented.segmentStyle = .separated + + // Segment 0: main action (preferred editor icon + "Open" label) + let preferred = WorktrunkPreferences.preferredEditor ?? editors.first! + segmented.setImage(editorIcon(preferred), forSegment: 0) + segmented.setImageScaling(.scaleProportionallyDown, forSegment: 0) + segmented.setLabel("Open", forSegment: 0) + segmented.setWidth(0, forSegment: 0) + segmented.setToolTip("Open in \(preferred.title)", forSegment: 0) + + // Segment 1: dropdown arrow with menu + let menu = buildMenu(editors: editors, target: target) + segmented.setMenu(menu, forSegment: 1) + segmented.setShowsMenuIndicator(true, forSegment: 1) + segmented.setWidth(22, forSegment: 1) + segmented.setToolTip("Choose editor", forSegment: 1) + + segmented.target = target + segmented.action = #selector(TerminalController.openInEditor(_:)) + + return segmented + } + + static func editorIcon(_ editor: ExternalEditor) -> NSImage { + guard let icon = editor.appIcon else { return fallbackImage } + let resized = NSImage(size: iconSize, flipped: false) { rect in + icon.draw(in: rect) + return true + } + return resized + } + + static func updateIcon(_ segmented: NSSegmentedControl, editor: ExternalEditor) { + segmented.setImage(editorIcon(editor), forSegment: 0) + segmented.setToolTip("Open in \(editor.title)", forSegment: 0) + } + + private static func buildMenu(editors: [ExternalEditor], target: TerminalController?) -> NSMenu { + let menu = NSMenu() + let groups = ExternalEditor.installedByCategory() + for (index, group) in groups.enumerated() { + if index > 0 { + menu.addItem(.separator()) + } + for editor in group.editors { + let menuItem = NSMenuItem( + title: editor.title, + action: #selector(TerminalController.openInSpecificEditor(_:)), + keyEquivalent: "" + ) + menuItem.target = target + menuItem.representedObject = editor + if let icon = editor.appIcon { + let resized = NSImage(size: iconSize, flipped: false) { rect in + icon.draw(in: rect) + return true + } + menuItem.image = resized + } + menu.addItem(menuItem) + } + } + return menu + } } private class CenteredDynamicLabel: NSTextField {