forked from ghostty-org/ghostty
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathTerminalController.swift
More file actions
2590 lines (2224 loc) · 101 KB
/
TerminalController.swift
File metadata and controls
2590 lines (2224 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import Cocoa
import SwiftUI
import Combine
import GhosttyKit
final class WorktrunkSidebarState: ObservableObject {
@Published var columnVisibility: NavigationSplitViewVisibility
@Published var expandedRepoIDs: Set<UUID> = []
@Published var expandedWorktreePaths: Set<String> = []
@Published var selection: SidebarSelection?
@Published var isApplyingRemoteUpdate: Bool = false
init(
columnVisibility: NavigationSplitViewVisibility = .all,
expandedRepoIDs: Set<UUID> = [],
expandedWorktreePaths: Set<String> = [],
selection: SidebarSelection? = nil
) {
self.columnVisibility = columnVisibility
self.expandedRepoIDs = expandedRepoIDs
self.expandedWorktreePaths = expandedWorktreePaths
self.selection = selection
}
func applyExpandedRepoIDs(_ next: Set<UUID>, listMode: WorktrunkSidebarListMode) {
guard next != expandedRepoIDs else { return }
expandedRepoIDs = next
pruneSelectionForVisibility(
listMode: listMode,
expandedRepoIDs: next,
expandedWorktreePaths: expandedWorktreePaths
)
}
func applyExpandedWorktreePaths(_ next: Set<String>, listMode: WorktrunkSidebarListMode) {
guard next != expandedWorktreePaths else { return }
expandedWorktreePaths = next
pruneSelectionForVisibility(
listMode: listMode,
expandedRepoIDs: expandedRepoIDs,
expandedWorktreePaths: next
)
}
func didCollapseRepo(id: UUID) {
guard let selection else { return }
switch selection {
case .repo(let repoID):
if repoID == id { return }
case .worktree(let repoID, _):
if repoID == id { self.selection = .repo(id: id) }
case .session(_, let repoID, _):
if repoID == id { self.selection = .repo(id: id) }
}
}
func didCollapseWorktree(repoID: UUID, path: String) {
guard let selection else { return }
switch selection {
case .session(_, let selectedRepoID, let worktreePath):
if selectedRepoID == repoID, worktreePath == path {
self.selection = .worktree(repoID: repoID, path: path)
}
default:
return
}
}
func reconcile(with store: any WorktrunkSidebarReconcilingStore, listMode: WorktrunkSidebarListMode) {
let validRepoIDs = store.sidebarRepoIDs
let validWorktreePaths = store.sidebarWorktreePaths
let nextExpandedRepoIDs = expandedRepoIDs.intersection(validRepoIDs)
if nextExpandedRepoIDs != expandedRepoIDs {
expandedRepoIDs = nextExpandedRepoIDs
}
let nextExpandedWorktreePaths = expandedWorktreePaths.intersection(validWorktreePaths)
if nextExpandedWorktreePaths != expandedWorktreePaths {
expandedWorktreePaths = nextExpandedWorktreePaths
}
guard let selection else { return }
let nextSelection: SidebarSelection?
switch selection {
case .repo(let id):
nextSelection = validRepoIDs.contains(id) ? selection : nil
case .worktree(let repoID, let path):
if !validRepoIDs.contains(repoID) {
nextSelection = nil
} else if validWorktreePaths.contains(path) {
nextSelection = selection
} else {
nextSelection = .repo(id: repoID)
}
case .session(let id, let repoID, let worktreePath):
if !validRepoIDs.contains(repoID) {
nextSelection = nil
} else if !validWorktreePaths.contains(worktreePath) {
nextSelection = .repo(id: repoID)
} else if store.sessions(for: worktreePath).contains(where: { $0.id == id }) {
nextSelection = selection
} else {
nextSelection = .worktree(repoID: repoID, path: worktreePath)
}
}
if nextSelection != selection {
self.selection = nextSelection
}
}
private func pruneSelectionForVisibility(
listMode: WorktrunkSidebarListMode,
expandedRepoIDs: Set<UUID>,
expandedWorktreePaths: Set<String>
) {
guard let selection else { return }
switch selection {
case .repo:
return
case .worktree(let repoID, _):
if listMode == .nestedByRepo, !expandedRepoIDs.contains(repoID) {
self.selection = .repo(id: repoID)
}
case .session(_, let repoID, let worktreePath):
if listMode == .nestedByRepo, !expandedRepoIDs.contains(repoID) {
self.selection = .repo(id: repoID)
return
}
if !expandedWorktreePaths.contains(worktreePath) {
self.selection = .worktree(repoID: repoID, path: worktreePath)
}
}
}
}
protocol WorktrunkSidebarReconcilingStore {
var repositories: [WorktrunkStore.Repository] { get }
var sidebarRepoIDs: Set<UUID> { get }
var sidebarWorktreePaths: Set<String> { get }
func worktrees(for repositoryID: UUID) -> [WorktrunkStore.Worktree]
func sessions(for worktreePath: String) -> [AISession]
}
enum SidebarSelection: Hashable {
case repo(id: UUID)
case worktree(repoID: UUID, path: String)
case session(id: String, repoID: UUID, worktreePath: String)
}
/// A classic, tabbed terminal experience.
class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
private static let worktreeTabControllers = NSMapTable<NSString, TerminalController>(
keyOptions: .copyIn,
valueOptions: .weakMemory
)
override var windowNibName: NSNib.Name? {
let defaultValue = "Terminal"
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
let config = appDelegate.ghostty.config
// If we have no window decorations, there's no reason to do anything but
// the default titlebar (because there will be no titlebar).
if !config.windowDecorations {
return defaultValue
}
let nib = switch config.macosTitlebarStyle {
case "native": "Terminal"
case "hidden": "TerminalHiddenTitlebar"
case "transparent": "TerminalTransparentTitlebar"
case "tabs":
#if compiler(>=6.2)
if #available(macOS 26.0, *) {
"TerminalTabsTitlebarTahoe"
} else {
"TerminalTabsTitlebarVentura"
}
#else
"TerminalTabsTitlebarVentura"
#endif
default: defaultValue
}
return nib
}
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
/// early if we don't care.
private var tabListenForFrame: Bool = false
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
/// changes in the list.
private var tabWindowsHash: Int = 0
/// This is set to false by init if the window managed by this controller should not be restorable.
/// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig
private var lastTitlebarFont: NSFont?
private let worktrunkSidebarState: WorktrunkSidebarState
private let openTabsModel = WorktrunkOpenTabsModel()
private var worktrunkSidebarSyncCancellables: Set<AnyCancellable> = []
private var worktrunkSidebarSyncApplyingRemoteUpdate: Bool = false
private let gitDiffSidebarState = GitDiffSidebarState()
private var lastTabSwitchRefreshAt: Date?
private let tabSwitchRefreshThrottle: TimeInterval = 0.15
private var pendingTabSwitchRefresh: DispatchWorkItem?
private var lastTabSwitchSurfaceID: UUID?
private(set) var worktreeTabRootPath: String? {
didSet { syncWorktreeTabTitle() }
}
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
/// This will be set to the initial frame of the window from the xib on load.
private var initialFrame: NSRect?
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
parent: NSWindow? = nil
) {
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
// time of writing this: it'd just restore to a shell in the same directory
// as the script. We may want to revisit this behavior when we have scrollback
// restoration.
self.restorable = (base?.command ?? "") == ""
if let parent,
let parentController = parent.windowController as? TerminalController {
self.worktrunkSidebarState = WorktrunkSidebarState(
columnVisibility: parentController.worktrunkSidebarState.columnVisibility,
expandedRepoIDs: parentController.worktrunkSidebarState.expandedRepoIDs,
expandedWorktreePaths: parentController.worktrunkSidebarState.expandedWorktreePaths,
selection: parentController.worktrunkSidebarState.selection
)
} else {
self.worktrunkSidebarState = WorktrunkSidebarState(columnVisibility: .all)
}
// Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.config)
var baseWithHooks = base ?? Ghostty.SurfaceConfiguration()
TerminalAgentHooks.apply(to: &baseWithHooks)
super.init(ghostty, baseConfig: baseWithHooks, surfaceTree: tree)
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onToggleFullscreen),
name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil)
center.addObserver(
self,
selector: #selector(onMoveTab),
name: .ghosttyMoveTab,
object: nil)
center.addObserver(
self,
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTab),
name: .ghosttyCloseTab,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseOtherTabs),
name: .ghosttyCloseOtherTabs,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTabsOnTheRight),
name: .ghosttyCloseTabsOnTheRight,
object: nil)
center.addObserver(
self,
selector: #selector(onResetWindowSize),
name: .ghosttyResetWindowSize,
object: nil
)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
center.addObserver(
self,
selector: #selector(onFrameDidChange),
name: NSView.frameDidChangeNotification,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseWindow),
name: .ghosttyCloseWindow,
object: nil
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
if let root = worktreeTabRootPath {
Self.worktreeTabControllers.removeObject(forKey: root as NSString)
}
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
}
private static func standardizedPath(_ path: String) -> String {
URL(fileURLWithPath: path).standardizedFileURL.path
}
private func setWorktreeTabRootPath(_ path: String?) {
let standardized = path.map(Self.standardizedPath)
if let old = worktreeTabRootPath {
Self.worktreeTabControllers.removeObject(forKey: old as NSString)
}
worktreeTabRootPath = standardized
if let standardized {
Self.worktreeTabControllers.setObject(self, forKey: standardized as NSString)
}
openTabsModel.refresh(for: window)
}
private func syncWorktreeTabTitle() {
guard WorktrunkPreferences.worktreeTabsEnabled, let root = worktreeTabRootPath else {
managedTitleOverride = nil
return
}
managedTitleOverride = (root as NSString).abbreviatingWithTildeInPath
}
func applyWorktreeTabPreferences() {
syncWorktreeTabTitle()
}
func restoreWorktreeTabRootPath(_ path: String?) {
let sanitized = RestorablePath.normalizedExistingDirectoryPath(path)
setWorktreeTabRootPath(sanitized)
}
private static func existingWorktreeTabController(forWorktreePath path: String) -> TerminalController? {
let root = standardizedPath(path)
if let existing = worktreeTabControllers.object(forKey: root as NSString) {
return existing
}
if let controller = TerminalController.all.first(where: { $0.worktreeTabRootPath == root }) {
worktreeTabControllers.setObject(controller, forKey: root as NSString)
return controller
}
for controller in TerminalController.all {
for surfaceView in controller.surfaceTree {
guard let pwd = surfaceView.pwd else { continue }
let pwdPath = standardizedPath(pwd)
let rootPrefix = root.hasSuffix("/") ? root : (root + "/")
if pwdPath == root || pwdPath.hasPrefix(rootPrefix) {
controller.setWorktreeTabRootPath(root)
worktreeTabControllers.setObject(controller, forKey: root as NSString)
return controller
}
}
}
return nil
}
private static func ensureWorktreeTabController(
ghostty: Ghostty.App,
parentWindow: NSWindow?,
worktreePath: String,
initialBaseConfig: Ghostty.SurfaceConfiguration
) -> (controller: TerminalController, isNew: Bool) {
if let existing = existingWorktreeTabController(forWorktreePath: worktreePath) {
return (existing, false)
}
let parent = parentWindow ?? preferredParent?.window
let controller: TerminalController = {
if let created = TerminalController.newTab(ghostty, from: parent, withBaseConfig: initialBaseConfig) {
return created
}
return TerminalController.newWindow(ghostty, withBaseConfig: initialBaseConfig, withParent: parent)
}()
controller.setWorktreeTabRootPath(worktreePath)
controller.syncWorktreeTabTitle()
return (controller, true)
}
private func worktreeTabNextSplitPlacement() -> (anchor: Ghostty.SurfaceView, direction: SplitTree<Ghostty.SurfaceView>.NewDirection)? {
guard let root = surfaceTree.root else { return nil }
let allLeaves = root.leaves()
guard let first = allLeaves.first else { return nil }
// Start with two columns.
if allLeaves.count == 1 {
return (first, .right)
}
// After two columns exist, we fill rows in a 2-column grid:
// 3rd -> split left column down
// 4th -> split right column down
// 5th+ -> keep adding rows to the shorter column, bottom-first (ties -> left).
if case .split(let split) = root, split.direction == .horizontal {
let leftLeaves = split.left.leaves()
let rightLeaves = split.right.leaves()
if leftLeaves.count <= rightLeaves.count {
return (leftLeaves.last ?? first, .down)
} else {
return (rightLeaves.last ?? first, .down)
}
}
// If the tree doesn't match the expected layout (e.g. user-made splits),
// fall back to adding a row at the end.
return (allLeaves.last ?? first, .down)
}
func openWorktreeTabNewSession(baseConfig: Ghostty.SurfaceConfiguration) {
guard WorktrunkPreferences.worktreeTabsEnabled, worktreeTabRootPath != nil else { return }
guard let (anchor, direction) = worktreeTabNextSplitPlacement() else { return }
_ = newSplit(at: anchor, direction: direction, baseConfig: baseConfig)
}
private func openWorktreeTabSession(worktreePath: String, baseConfig: Ghostty.SurfaceConfiguration) {
var initialConfig = baseConfig
initialConfig.workingDirectory = initialConfig.workingDirectory ?? worktreePath
TerminalAgentHooks.apply(to: &initialConfig)
let (controller, isNew) = Self.ensureWorktreeTabController(
ghostty: ghostty,
parentWindow: window,
worktreePath: worktreePath,
initialBaseConfig: initialConfig
)
controller.window?.makeKeyAndOrderFront(nil)
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
if controller.worktreeTabRootPath == nil {
controller.setWorktreeTabRootPath(worktreePath)
}
// If this is the first terminal in the tab, the tab creation already created the surface.
// Otherwise, open a new split in the worktree tab.
guard !isNew else { return }
controller.openWorktreeTabNewSession(baseConfig: initialConfig)
}
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
invalidateRestorableState()
// Update our zoom state
if let window = window as? TerminalWindow {
window.surfaceIsZoomed = to.zoomed != nil
}
// If our surface tree is now nil then we close our window.
if to.isEmpty {
self.window?.close()
}
}
override func replaceSurfaceTree(
_ newTree: SplitTree<Ghostty.SurfaceView>,
moveFocusTo newView: Ghostty.SurfaceView? = nil,
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
undoAction: String? = nil
) {
// We have a special case if our tree is empty to close our tab immediately.
// This makes it so that undo is handled properly.
if newTree.isEmpty {
closeTabImmediately()
return
}
super.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: oldView,
undoAction: undoAction)
}
// MARK: Terminal Creation
/// Returns all the available terminal controllers present in the app currently.
static var all: [TerminalController] {
return NSApplication.shared.windows.compactMap {
$0.windowController as? TerminalController
}
}
// Keep track of the last point that our window was launched at so that new
// windows "cascade" over each other and don't just launch directly on top
// of each other.
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
// The preferred parent terminal controller.
static var preferredParent: TerminalController? {
all.first {
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
}
// The last controller to be main. We use this when paired with "preferredParent"
// to find the preferred window to attach new tabs, perform actions, etc. We
// always prefer the main window but if there isn't any (because we're triggered
// by something like an App Intent) then we prefer the most previous main.
static private(set) weak var lastMain: TerminalController?
/// The "new window" action.
static func newWindow(
_ ghostty: Ghostty.App,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
withParent explicitParent: NSWindow? = nil
) -> TerminalController {
// Get our parent. Our parent is the one explicitly given to us,
// otherwise the focused terminal, otherwise an arbitrary one.
let parent: NSWindow? = explicitParent ?? preferredParent?.window
let c = TerminalController.init(ghostty, withBaseConfig: baseConfig, parent: parent)
if let parent, parent.styleMask.contains(.fullScreen) {
// If our previous window was fullscreen then we want our new window to
// be fullscreen. This behavior actually doesn't match the native tabbing
// behavior of macOS apps where new windows create tabs when in native
// fullscreen but this is how we've always done it. This matches iTerm2
// behavior.
c.toggleFullscreen(mode: .native)
} else if let fullscreenMode = ghostty.config.windowFullscreen {
switch fullscreenMode {
case .native:
// Native has to be done immediately so that our stylemask contains
// fullscreen for the logic later in this method.
c.toggleFullscreen(mode: .native)
case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch:
// If we're non-native then we have to do it on a later loop
// so that the content view is setup.
DispatchQueue.main.async {
c.toggleFullscreen(mode: fullscreenMode)
}
}
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
DispatchQueue.main.async {
// Only cascade if we aren't fullscreen.
if let window = c.window {
if !window.styleMask.contains(.fullScreen) {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
}
c.showWindow(self)
// All new_window actions force our app to be active, so that the new
// window is focused and visible.
NSApp.activate(ignoringOtherApps: true)
}
// Setup our undo
if let undoManager = c.undoManager {
undoManager.setActionName("New Window")
undoManager.registerUndo(
withTarget: c,
expiresAfter: c.undoExpiration
) { target in
// Close the window when undoing
undoManager.disableUndoRegistration {
target.closeWindow(nil)
}
// Register redo action
undoManager.registerUndo(
withTarget: ghostty,
expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newWindow(
ghostty,
withBaseConfig: baseConfig,
withParent: explicitParent)
}
}
}
return c
}
/// Create a new window with an existing split tree.
/// The window will be sized to match the tree's current view bounds if available.
/// - Parameters:
/// - ghostty: The Ghostty app instance.
/// - tree: The split tree to use for the new window.
/// - position: Optional screen position (top-left corner) for the new window.
/// If nil, the window will cascade from the last cascade point.
static func newWindow(
_ ghostty: Ghostty.App,
tree: SplitTree<Ghostty.SurfaceView>,
position: NSPoint? = nil,
confirmUndo: Bool = true,
) -> TerminalController {
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
// Calculate the target frame based on the tree's view bounds
let treeSize: CGSize? = tree.root?.viewBounds()
DispatchQueue.main.async {
if let window = c.window {
// If we have a tree size, resize the window's content to match
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
window.setContentSize(treeSize)
window.constrainToScreen()
}
if !window.styleMask.contains(.fullScreen) {
if let position {
window.setFrameTopLeftPoint(position)
window.constrainToScreen()
} else {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
}
}
c.showWindow(self)
}
// Setup our undo
if let undoManager = c.undoManager {
undoManager.setActionName("New Window")
undoManager.registerUndo(
withTarget: c,
expiresAfter: c.undoExpiration
) { target in
undoManager.disableUndoRegistration {
if confirmUndo {
target.closeWindow(nil)
} else {
target.closeWindowImmediately()
}
}
undoManager.registerUndo(
withTarget: ghostty,
expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newWindow(ghostty, tree: tree)
}
}
}
return c
}
static func newTab(
_ ghostty: Ghostty.App,
from parent: NSWindow? = nil,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
) -> TerminalController? {
// Making sure that we're dealing with a TerminalController. If not,
// then we just create a new window.
guard let parent,
let parentController = parent.windowController as? TerminalController else {
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
}
// If our parent is in non-native fullscreen, then new tabs do not work.
// See: https://github.com/mitchellh/ghostty/issues/392
if let fullscreenStyle = parentController.fullscreenStyle,
fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
let alert = NSAlert()
alert.messageText = "Cannot Create New Tab"
alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.beginSheetModal(for: parent)
return nil
}
// Create a new window and add it to the parent
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig, parent: parent)
guard let window = controller.window else { return controller }
// If the parent is miniaturized, then macOS exhibits really strange behaviors
// so we have to bring it back out.
if parent.isMiniaturized { parent.deminiaturize(self) }
// If our parent tab group already has this window, macOS added it and
// we need to remove it so we can set the correct order in the next line.
// If we don't do this, macOS gets really confused and the tabbedWindows
// state becomes incorrect.
//
// At the time of writing this code, the only known case this happens
// is when the "+" button is clicked in the tab bar.
if let tg = parent.tabGroup,
tg.windows.firstIndex(of: window) != nil {
tg.removeWindow(window)
}
// If we don't allow tabs then we create a new window instead.
if window.tabbingMode != .disallowed {
// Add the window to the tab group and show it.
switch ghostty.config.windowNewTabPosition {
case "end":
// If we already have a tab group and we want the new tab to open at the end,
// then we use the last window in the tab group as the parent.
if let last = parent.tabGroup?.windows.last {
last.addTabbedWindow(window, ordered: .above)
} else {
fallthrough
}
case "current": fallthrough
default:
parent.addTabbedWindow(window, ordered: .above)
}
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
DispatchQueue.main.async {
// Only cascade if we aren't fullscreen and are alone in the tab group.
if !window.styleMask.contains(.fullScreen) &&
window.tabGroup?.windows.count ?? 1 == 1 {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
controller.showWindow(self)
window.makeKeyAndOrderFront(self)
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
// It takes an event loop cycle until the macOS tabGroup state becomes
// consistent which causes our tab labeling to be off when the "+" button
// is used in the tab bar. This fixes that. If we can find a more robust
// solution we should do that.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
controller.relabelTabs()
}
// Setup our undo
if let undoManager = parentController.undoManager {
undoManager.setActionName("New Tab")
undoManager.registerUndo(
withTarget: controller,
expiresAfter: controller.undoExpiration
) { target in
// Close the tab when undoing. We do this in a DispatchQueue because
// for some people on macOS Tahoe this caused a crash and the queue
// fixes it.
// https://github.com/ghostty-org/ghostty/pull/9512
DispatchQueue.main.async {
undoManager.disableUndoRegistration {
target.closeTab(nil)
}
}
// Register redo action
undoManager.registerUndo(
withTarget: ghostty,
expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newTab(
ghostty,
from: parent,
withBaseConfig: baseConfig)
}
}
}
return controller
}
// MARK: - Methods
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// If this is an app-level config update then we update some things.
if notification.object == nil {
// Update our derived config
self.derivedConfig = DerivedConfig(config)
// If we have no surfaces in our window (is that possible?) then we update
// our window appearance based on the root config. If we have surfaces, we
// don't call this because focused surface changes will trigger appearance updates.
if surfaceTree.isEmpty {
syncAppearance(.init(config))
}
return
}
/// Surface-level config will be updated in
/// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then
/// ``TerminalController/focusedSurfaceDidChange(to:)``
}
/// Update the accessory view of each tab according to the keyboard
/// shortcut that activates it (if any). This is called when the key window
/// changes, when a window is closed, and when tabs are reordered
/// with the mouse.
func relabelTabs() {
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1
if let windows = window?.tabbedWindows as? [TerminalWindow] {
for (tab, window) in zip(1..., windows) {
// We need to clear any windows beyond this because they have had
// a keyEquivalent set previously.
guard tab <= 9 else {
window.keyEquivalent = ""
continue
}
if let equiv = ghostty.config.keyboardShortcut(for: "goto_tab:\(tab)") {
window.keyEquivalent = "\(equiv)"
} else {
window.keyEquivalent = ""
}
}
}
openTabsModel.refresh(for: window)
}
private func focusNativeTab(windowNumber: Int) {
guard let window else { return }
guard let tabGroup = window.tabGroup else { return }
guard let target = tabGroup.windows.first(where: { $0.windowNumber == windowNumber }) else { return }
target.makeKeyAndOrderFront(nil)
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
}
private func moveNativeTabBefore(movingWindowNumber: Int, targetWindowNumber: Int) {
guard movingWindowNumber != targetWindowNumber else { return }
guard let window else { return }
guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return }
guard let movingWindow = tabGroup.windows.first(where: { $0.windowNumber == movingWindowNumber }) else { return }
guard let targetWindow = tabGroup.windows.first(where: { $0.windowNumber == targetWindowNumber }) else { return }
if #available(macOS 26, *) {
if window is TitlebarTabsTahoeTerminalWindow {
tabGroup.removeWindow(movingWindow)
targetWindow.addTabbedWindow(movingWindow, ordered: .below)
relabelTabs()
return
}
}
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
tabGroup.removeWindow(movingWindow)
targetWindow.addTabbedWindow(movingWindow, ordered: .below)
NSAnimationContext.endGrouping()
relabelTabs()
}
private func moveNativeTabAfter(movingWindowNumber: Int, targetWindowNumber: Int) {
guard movingWindowNumber != targetWindowNumber else { return }
guard let window else { return }
guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { return }
guard let movingWindow = tabGroup.windows.first(where: { $0.windowNumber == movingWindowNumber }) else { return }
guard let targetWindow = tabGroup.windows.first(where: { $0.windowNumber == targetWindowNumber }) else { return }
if #available(macOS 26, *) {
if window is TitlebarTabsTahoeTerminalWindow {
tabGroup.removeWindow(movingWindow)
targetWindow.addTabbedWindow(movingWindow, ordered: .above)
relabelTabs()
return
}
}
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
tabGroup.removeWindow(movingWindow)
targetWindow.addTabbedWindow(movingWindow, ordered: .above)
NSAnimationContext.endGrouping()
relabelTabs()
}
private func fixTabBar() {
// We do this to make sure that the tab bar will always re-composite. If we don't,
// then the it will "drag" pieces of the background with it when a transparent
// window is moved around.
//
// There might be a better way to make the tab bar "un-lazy", but I can't find it.
if let window = window, !window.isOpaque {
window.isOpaque = true
window.isOpaque = false
}
}
@objc private func onFrameDidChange(_ notification: NSNotification) {
// This is a huge hack to set the proper shortcut for tab selection
// on tab reordering using the mouse. There is no event, delegate, etc.
// as far as I can tell for when a tab is manually reordered with the
// mouse in a macOS-native tab group, so the way we detect it is setting
// the accessoryView "postsFrameChangedNotification" to true, listening
// for the view frame to change, comparing the windows list, and
// relabeling the tabs.
guard tabListenForFrame else { return }
guard let v = self.window?.tabbedWindows?.hashValue else { return }
guard tabWindowsHash != v else { return }
tabWindowsHash = v
self.relabelTabs()
}
override func syncAppearance() {
// When our focus changes, we update our window appearance based on the
// currently focused surface.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// Let our window handle its own appearance
guard let window = window as? TerminalWindow else { return }
// Sync our zoom state for splits
window.surfaceIsZoomed = surfaceTree.zoomed != nil
// Set the font for the window and tab titles.
if let titleFontName = surfaceConfig.windowTitleFontFamily {
let font = NSFont(name: titleFontName, size: NSFont.systemFontSize)
window.titlebarFont = font
lastTitlebarFont = font
} else {
window.titlebarFont = nil
lastTitlebarFont = nil
}
// Call this last in case it uses any of the properties above.
window.syncAppearance(surfaceConfig)
}
/// Adjusts the given frame for the configured window position.
func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect {
guard let x = derivedConfig.windowPositionX else { return frame }
guard let y = derivedConfig.windowPositionY else { return frame }
// Convert top-left coordinates to bottom-left origin using our utility extension
let origin = screen.origin(
fromTopLeftOffsetX: CGFloat(x),
offsetY: CGFloat(y),
windowSize: frame.size)
// Clamp the origin to ensure the window stays fully visible on screen
var safeOrigin = origin