Skip to content

Commit a2f3d7b

Browse files
authored
Merge pull request #10 from sidequery/nicosuave/toolbar-editor-dropdown
macos: add split-button toolbar item for opening in external editors
2 parents db07658 + 4f5447b commit a2f3d7b

4 files changed

Lines changed: 339 additions & 5 deletions

File tree

macos/Sources/Features/Terminal/TerminalController.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1859,6 +1859,64 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
18591859
Task { await gitDiffSidebarState.setVisible(false, cwd: nil) }
18601860
}
18611861

1862+
@objc func openInEditor(_ sender: Any?) {
1863+
// If the dropdown segment (1) was clicked, show the editor menu
1864+
if let segmented = sender as? NSSegmentedControl, segmented.selectedSegment == 1 {
1865+
if let menu = segmented.menu(forSegment: 1) {
1866+
let screenRect = segmented.window?.convertToScreen(
1867+
segmented.convert(segmented.bounds, to: nil)
1868+
) ?? .zero
1869+
let origin = NSPoint(x: screenRect.minX, y: screenRect.minY)
1870+
menu.popUp(positioning: nil, at: origin, in: nil)
1871+
}
1872+
return
1873+
}
1874+
1875+
guard let editor = WorktrunkPreferences.preferredEditor else { return }
1876+
openIn(editor: editor)
1877+
}
1878+
1879+
@objc func openInSpecificEditor(_ sender: Any?) {
1880+
guard let menuItem = sender as? NSMenuItem,
1881+
let editor = menuItem.representedObject as? ExternalEditor else { return }
1882+
openIn(editor: editor)
1883+
}
1884+
1885+
private func openIn(editor: ExternalEditor) {
1886+
guard let appURL = editor.appURL else { return }
1887+
let cwd = currentEditorPath()
1888+
guard let cwd else { return }
1889+
1890+
WorktrunkPreferences.lastEditor = editor
1891+
refreshEditorToolbarIcon(for: editor)
1892+
1893+
let url = URL(fileURLWithPath: cwd)
1894+
let config = NSWorkspace.OpenConfiguration()
1895+
NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: config)
1896+
}
1897+
1898+
private func refreshEditorToolbarIcon(for editor: ExternalEditor) {
1899+
guard let toolbar = window?.toolbar else { return }
1900+
for item in toolbar.items {
1901+
guard item.itemIdentifier == .openInEditor,
1902+
let segmented = item.view as? NSSegmentedControl else { continue }
1903+
EditorSplitButton.updateIcon(segmented, editor: editor)
1904+
break
1905+
}
1906+
}
1907+
1908+
private func currentEditorPath() -> String? {
1909+
// Prefer selected worktree path from sidebar, fall back to focused surface pwd
1910+
switch worktrunkSidebarState.selection {
1911+
case .worktree(_, let path):
1912+
return path
1913+
case .session(_, _, let worktreePath):
1914+
return worktreePath
1915+
default:
1916+
return focusedSurface?.pwd
1917+
}
1918+
}
1919+
18621920
private func resumeAISession(_ session: AISession) {
18631921
var base = Ghostty.SurfaceConfiguration()
18641922
base.workingDirectory = session.cwd

macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,23 +286,24 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
286286
// MARK: NSToolbarDelegate
287287

288288
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
289-
var items: [NSToolbarItem.Identifier] = [
289+
[
290290
.toggleSidebar,
291291
.sidebarTrackingSeparator,
292292
.title,
293293
.flexibleSpace,
294294
.space,
295+
.openInEditor,
295296
]
296-
return items
297297
}
298298

299299
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
300-
return [
300+
[
301301
.toggleSidebar,
302302
.sidebarTrackingSeparator,
303303
.flexibleSpace,
304304
.title,
305305
.flexibleSpace,
306+
.openInEditor,
306307
]
307308
}
308309

@@ -335,11 +336,32 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
335336
item.label = "Toggle Sidebar"
336337
item.isNavigational = true
337338
return item
339+
case .openInEditor:
340+
return makeOpenInEditorItem()
338341
default:
339342
return NSToolbarItem(itemIdentifier: itemIdentifier)
340343
}
341344
}
342345

346+
private func makeOpenInEditorItem() -> NSToolbarItem? {
347+
let installed = ExternalEditor.installedEditors()
348+
guard !installed.isEmpty else { return nil }
349+
350+
let controller = windowController as? TerminalController
351+
352+
let item = NSToolbarItem(itemIdentifier: .openInEditor)
353+
item.label = "Open in Editor"
354+
item.toolTip = "Open in Editor"
355+
356+
let segmented = EditorSplitButton.make(
357+
editors: installed,
358+
target: controller
359+
)
360+
361+
item.view = segmented
362+
return item
363+
}
364+
343365
// MARK: SwiftUI
344366

345367
class ViewModel: ObservableObject {

macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AppKit
12
import Foundation
23

34
enum WorktrunkAgent: String, CaseIterable, Identifiable {
@@ -132,12 +133,147 @@ enum WorktrunkOpenBehavior: String, CaseIterable, Identifiable {
132133
}
133134
}
134135

136+
enum ExternalEditorCategory: String {
137+
case editors
138+
case git
139+
case finder
140+
}
141+
142+
enum ExternalEditor: String, CaseIterable, Identifiable {
143+
// Editors
144+
case cursor
145+
case vscode
146+
case vscodium
147+
case zed
148+
case sublime
149+
case nova
150+
case textmate
151+
case xcode
152+
// JetBrains
153+
case intellij
154+
case webstorm
155+
case pycharm
156+
case goland
157+
case rubymine
158+
case clion
159+
case rider
160+
case phpstorm
161+
case fleet
162+
// Git clients
163+
case tower
164+
case fork
165+
case gitkraken
166+
case sourcetree
167+
case githubDesktop
168+
// Finder
169+
case finder
170+
171+
var id: String { rawValue }
172+
173+
var category: ExternalEditorCategory {
174+
switch self {
175+
case .tower, .fork, .gitkraken, .sourcetree, .githubDesktop:
176+
return .git
177+
case .finder:
178+
return .finder
179+
default:
180+
return .editors
181+
}
182+
}
183+
184+
var title: String {
185+
switch self {
186+
case .cursor: return "Cursor"
187+
case .vscode: return "VS Code"
188+
case .vscodium: return "VSCodium"
189+
case .zed: return "Zed"
190+
case .sublime: return "Sublime Text"
191+
case .nova: return "Nova"
192+
case .xcode: return "Xcode"
193+
case .textmate: return "TextMate"
194+
case .intellij: return "IntelliJ IDEA"
195+
case .webstorm: return "WebStorm"
196+
case .pycharm: return "PyCharm"
197+
case .goland: return "GoLand"
198+
case .rubymine: return "RubyMine"
199+
case .clion: return "CLion"
200+
case .rider: return "Rider"
201+
case .phpstorm: return "PhpStorm"
202+
case .fleet: return "Fleet"
203+
case .tower: return "Tower"
204+
case .fork: return "Fork"
205+
case .gitkraken: return "GitKraken"
206+
case .sourcetree: return "Sourcetree"
207+
case .githubDesktop: return "GitHub Desktop"
208+
case .finder: return "Finder"
209+
}
210+
}
211+
212+
var bundleIdentifier: String {
213+
switch self {
214+
case .cursor: return "com.todesktop.230313mzl4w4u92"
215+
case .vscode: return "com.microsoft.VSCode"
216+
case .vscodium: return "com.vscodium"
217+
case .zed: return "dev.zed.Zed"
218+
case .sublime: return "com.sublimetext.4"
219+
case .nova: return "com.panic.Nova"
220+
case .xcode: return "com.apple.dt.Xcode"
221+
case .textmate: return "com.macromates.TextMate"
222+
case .intellij: return "com.jetbrains.intellij"
223+
case .webstorm: return "com.jetbrains.WebStorm"
224+
case .pycharm: return "com.jetbrains.pycharm"
225+
case .goland: return "com.jetbrains.goland"
226+
case .rubymine: return "com.jetbrains.rubymine"
227+
case .clion: return "com.jetbrains.CLion"
228+
case .rider: return "com.jetbrains.rider"
229+
case .phpstorm: return "com.jetbrains.PhpStorm"
230+
case .fleet: return "fleet.app"
231+
case .tower: return "com.fournova.Tower3"
232+
case .fork: return "com.DanPristupov.Fork"
233+
case .gitkraken: return "com.axosoft.gitkraken"
234+
case .sourcetree: return "com.torusknot.SourceTreeNotMAS"
235+
case .githubDesktop: return "com.github.GitHubClient"
236+
case .finder: return "com.apple.finder"
237+
}
238+
}
239+
240+
var appURL: URL? {
241+
NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier)
242+
}
243+
244+
var isInstalled: Bool {
245+
appURL != nil
246+
}
247+
248+
var appIcon: NSImage? {
249+
guard let url = appURL else { return nil }
250+
return NSWorkspace.shared.icon(forFile: url.path)
251+
}
252+
253+
static func installedEditors() -> [ExternalEditor] {
254+
allCases.filter { $0.isInstalled }
255+
}
256+
257+
static func installedByCategory() -> [(category: ExternalEditorCategory, editors: [ExternalEditor])] {
258+
let installed = installedEditors()
259+
var result: [(category: ExternalEditorCategory, editors: [ExternalEditor])] = []
260+
for cat in [ExternalEditorCategory.editors, .git, .finder] {
261+
let group = installed.filter { $0.category == cat }
262+
if !group.isEmpty {
263+
result.append((category: cat, editors: group))
264+
}
265+
}
266+
return result
267+
}
268+
}
269+
135270
enum WorktrunkPreferences {
136271
static let openBehaviorKey = "GhosttyWorktrunkOpenBehavior.v1"
137272
static let worktreeTabsKey = "GhosttyWorktreeTabs.v1"
138273
static let sidebarTabsKey = "GhostreeWorktrunkSidebarTabs.v1"
139274
static let defaultAgentKey = "GhosttyWorktrunkDefaultAgent.v1"
140275
static let githubIntegrationKey = "GhostreeGitHubIntegration.v1"
276+
static let lastEditorKey = "GhostreeLastEditor.v1"
141277

142278
static var worktreeTabsEnabled: Bool {
143279
UserDefaults.standard.bool(forKey: worktreeTabsKey)
@@ -157,4 +293,22 @@ enum WorktrunkPreferences {
157293
}
158294
return UserDefaults.standard.bool(forKey: githubIntegrationKey)
159295
}
296+
297+
static var lastEditor: ExternalEditor? {
298+
get {
299+
guard let raw = UserDefaults.standard.string(forKey: lastEditorKey) else { return nil }
300+
return ExternalEditor(rawValue: raw)
301+
}
302+
set {
303+
UserDefaults.standard.set(newValue?.rawValue, forKey: lastEditorKey)
304+
}
305+
}
306+
307+
static var preferredEditor: ExternalEditor? {
308+
let installed = ExternalEditor.installedEditors()
309+
if let last = lastEditor, installed.contains(last) {
310+
return last
311+
}
312+
return installed.first
313+
}
160314
}

0 commit comments

Comments
 (0)