diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f928ed5a5..d992ba034 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -47,7 +47,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 82970a065..c8c0fbf66 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index df73198d1..fb6aef87d 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -33,7 +33,7 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -166,7 +166,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20f674bab..18af9d909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,7 +84,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -127,7 +127,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -160,7 +160,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -194,7 +194,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -237,7 +237,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -273,7 +273,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -302,7 +302,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -335,7 +335,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -381,7 +381,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -600,7 +600,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -642,7 +642,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -690,7 +690,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -725,7 +725,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -789,7 +789,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -816,7 +816,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -844,7 +844,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -871,7 +871,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -898,7 +898,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -925,7 +925,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -952,7 +952,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -986,7 +986,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1013,7 +1013,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1050,7 +1050,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1138,7 +1138,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index bceb8aef1..dc3ebb2b6 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/include/ghostty.h b/include/ghostty.h index cb8646560..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -584,6 +584,12 @@ typedef struct { const char* title; } ghostty_action_set_title_s; +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + // apprt.action.Pwd.C typedef struct { const char* pwd; @@ -831,7 +837,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, -} ghostty_action_tag_e; + } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; @@ -847,6 +853,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 31e812f0c..eb5d706c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ Features/Terminal/ErrorView.swift, Features/Terminal/TerminalController.swift, Features/Terminal/TerminalRestorable.swift, + Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 192135c15..8baee3d89 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -68,6 +68,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? + @IBOutlet private var menuChangeTabTitle: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -541,7 +542,7 @@ class AppDelegate: NSObject, self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") @@ -609,6 +610,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 3e1084cd7..d009b9c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -16,6 +16,7 @@ + @@ -315,7 +316,13 @@ - + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1c8e258f7..6336f0f55 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -81,6 +81,15 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// An override title for the tab/window set by the user via prompt_tab_title. + /// When set, this takes precedence over the computed title from the terminal. + var titleOverride: String? = nil { + didSet { applyTitleToWindow() } + } + + /// The last computed title from the focused surface (without the override). + private var lastComputedTitle: String = "👻" + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -325,6 +334,37 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Prompt the user to change the tab/window title. + func promptTabTitle() { + guard let window else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = titleOverride ?? window.title + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + guard response == .alertFirstButtonReturn else { return } + + let newTitle = textField.stringValue + if newTitle.isEmpty { + self.titleOverride = nil + } else { + self.titleOverride = newTitle + } + } + } + /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, @@ -718,10 +758,13 @@ class BaseTerminalController: NSWindowController, } private func titleDidChange(to: String) { + lastComputedTitle = to + applyTitleToWindow() + } + + private func applyTitleToWindow() { guard let window else { return } - - // Set the main window title - window.title = to + window.title = titleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { @@ -1017,6 +1060,10 @@ class BaseTerminalController: NSWindowController, window.performClose(sender) } + @IBAction func changeTabTitle(_ sender: Any) { + promptTabTitle() + } + @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a275c3f39..a980723ba 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,6 +54,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -148,7 +149,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { 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() @@ -195,7 +196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr $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 @@ -517,13 +518,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr fromTopLeftOffsetX: CGFloat(x), offsetY: CGFloat(y), windowSize: frame.size) - + // Clamp the origin to ensure the window stays fully visible on screen var safeOrigin = origin let vf = screen.visibleFrame safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - + // Return our new origin var result = frame result.origin = safeOrigin @@ -558,7 +559,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindowImmediately() return } - + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -579,15 +580,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + window.close() } - + private func closeOtherTabsImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } guard tabGroup.windows.count > 1 else { return } - + // Start an undo grouping if let undoManager { undoManager.beginUndoGrouping() @@ -595,7 +596,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr defer { undoManager?.endUndoGrouping() } - + // Iterate through all tabs except the current one. for window in tabGroup.windows where window != self.window { // We ignore any non-terminal tabs. They don't currently exist and we can't @@ -607,10 +608,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.closeTabImmediately(registerRedo: false) } } - + if let undoManager { undoManager.setActionName("Close Other Tabs") - + // We need to register an undo that refocuses this window. Otherwise, the // undo operation above for each tab will steal focus. undoManager.registerUndo( @@ -620,7 +621,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { target.window?.makeKeyAndOrderFront(nil) } - + // Register redo action undoManager.registerUndo( withTarget: target, @@ -746,7 +747,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case (nil, nil): return true } } - + // Find the index of the key window in our sorted states. This is a bit verbose // but we only need this for this style of undo so we don't want to add it to // UndoState. @@ -772,12 +773,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let controllers = undoStates.map { undoState in TerminalController(ghostty, with: undoState) } - + // The first controller becomes the parent window for all tabs. // If we don't have a first controller (shouldn't be possible?) // then we can't restore tabs. guard let firstController = controllers.first else { return } - + // Add all subsequent controllers as tabs to the first window for controller in controllers.dropFirst() { controller.showWindow(nil) @@ -786,7 +787,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr firstWindow.addTabbedWindow(newWindow, ordered: .above) } } - + // Make the appropriate window key. If we had a key window, restore it. // Otherwise, make the last window key. if let keyWindowIndex, keyWindowIndex < controllers.count { @@ -852,6 +853,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? + let tabColor: TerminalTabColor } convenience init(_ ghostty: Ghostty.App, @@ -863,6 +865,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr showWindow(nil) if let window { window.setFrame(undoState.frame, display: true) + if let terminalWindow = window as? TerminalWindow { + terminalWindow.tabColor = undoState.tabColor + } // If we have a tab group and index, restore the tab to its original position if let tabGroup = undoState.tabGroup, @@ -898,7 +903,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr surfaceTree: surfaceTree, focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), - tabGroup: window.tabGroup) + tabGroup: window.tabGroup, + tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } //MARK: - NSWindowController @@ -939,14 +945,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr viewModel: self, delegate: self, )) - + // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { case .frame: // Frames can be applied immediately defaultSize.apply(to: window) - + case .contentIntrinsicSize: // Content intrinsic size requires a short delay so that AppKit // can layout our SwiftUI views. @@ -956,13 +962,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + // Store our initial frame so we can know our default later. This MUST // be after the defaultSize call above so that we don't re-apply our frame. // Note: we probably want to set this on the first frame change or something // so it respects cascade. initialFrame = window.frame - + // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1073,7 +1079,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if let window { LastWindowPosition.shared.save(window) } - + // Remember our last main Self.lastMain = self } @@ -1120,7 +1126,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func closeOtherTabs(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } - + // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } @@ -1219,7 +1225,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } //MARK: - TerminalViewDelegate - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1283,7 +1289,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our target window let targetWindow = tabbedWindows[finalIndex] - + // Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs. // I believe this is due to messed up constraints for our hacky tab bar. I'd like to // find a better workaround. For now, this improves things dramatically. @@ -1296,7 +1302,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { selectedWindow.makeKey() } - + return } } @@ -1451,24 +1457,24 @@ extension TerminalController { guard let window, let tabGroup = window.tabGroup else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } - + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // If our window is already the default size or we don't have a // default size, then disable. return defaultSize?.isChanged(for: window) ?? false - + default: return super.validateMenuItem(item) } @@ -1484,10 +1490,10 @@ extension TerminalController { enum DefaultSize { /// A frame, set with `window.setFrame` case frame(NSRect) - + /// A content size, set with `window.setContentSize` case contentIntrinsicSize - + func isChanged(for window: NSWindow) -> Bool { switch self { case .frame(let rect): @@ -1496,11 +1502,11 @@ extension TerminalController { guard let view = window.contentView else { return false } - + return view.frame.size != view.intrinsicContentSize } } - + func apply(to window: NSWindow) { switch self { case .frame(let rect): @@ -1509,13 +1515,13 @@ extension TerminalController { guard let size = window.contentView?.intrinsicContentSize else { return } - + window.setContentSize(size) window.constrainToScreen() } } } - + private var defaultSize: DefaultSize? { if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { // Maximize takes priority, we take up the full screen we're on. diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 71e54b612..425f7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,16 +4,20 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 5 + static let version: Int = 7 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? + let tabColor: TerminalTabColor + let titleOverride: String? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode + self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none + self.titleOverride = controller.titleOverride } init?(coder aDecoder: NSCoder) { @@ -31,6 +35,8 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode + self.tabColor = v.value.tabColor + self.titleOverride = v.value.titleOverride } func encode(with coder: NSCoder) { @@ -94,6 +100,12 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + // Restore our tab color + (window as? TerminalWindow)?.tabColor = state.tabColor + + // Restore the tab title override + c.titleOverride = state.titleOverride + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift new file mode 100644 index 000000000..08d89324c --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -0,0 +1,185 @@ +import AppKit +import SwiftUI + +enum TerminalTabColor: Int, CaseIterable, Codable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + var localizedName: String { + switch self { + case .none: + return "None" + case .blue: + return "Blue" + case .purple: + return "Purple" + case .pink: + return "Pink" + case .red: + return "Red" + case .orange: + return "Orange" + case .yellow: + return "Yellow" + case .green: + return "Green" + case .teal: + return "Teal" + case .graphite: + return "Graphite" + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } +} + +// MARK: - Menu View + +/// A SwiftUI view displaying a color palette for tab color selection. +/// Used as a custom view inside an NSMenuItem in the tab context menu. +struct TabColorMenuView: View { + @State private var currentSelection: TerminalTabColor + let onSelect: (TerminalTabColor) -> Void + + init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) { + self._currentSelection = State(initialValue: selectedColor) + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text("Tab Color") + .padding(.bottom, 2) + + ForEach(Self.paletteRows, id: \.self) { row in + HStack(spacing: 2) { + ForEach(row, id: \.self) { color in + TabColorSwatch( + color: color, + isSelected: color == currentSelection + ) { + currentSelection = color + onSelect(color) + } + } + } + } + } + .padding(.leading, Self.leadingPadding) + .padding(.trailing, 12) + .padding(.top, 4) + .padding(.bottom, 4) + } + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + /// Leading padding to align with the menu's icon gutter. + /// macOS 26 introduced icons in menus, requiring additional padding. + private static var leadingPadding: CGFloat { + if #available(macOS 26.0, *) { + return 40 + } else { + return 12 + } + } +} + +/// A single color swatch button in the tab color palette. +private struct TabColorSwatch: View { + let color: TerminalTabColor + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if color == .none { + Image(systemName: isSelected ? "circle.slash" : "circle") + .foregroundStyle(.secondary) + } else if let displayColor = color.displayColor { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill") + .foregroundStyle(Color(nsColor: displayColor)) + } + } + .font(.system(size: 16)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .help(color.localizedName) + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 77ee98cb4..d04d7001c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -7,10 +7,10 @@ import GhosttyKit class TerminalWindow: NSWindow { /// Posted when a terminal window awakes from nib. static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") - + /// Posted when a terminal window will close static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") - + /// This is the key in UserDefaults to use for the default `level` value. This is /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" @@ -20,15 +20,23 @@ class TerminalWindow: NSWindow { /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() - + /// Update notification UI in titlebar private let updateAccessory = NSTitlebarAccessoryViewController() + /// Visual indicator that mirrors the selected tab color. + private lazy var tabColorIndicator: NSHostingView = { + let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor)) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() - - private var tabMenuObserver: NSObjectProtocol? = nil + /// Sets up our tab context menu + private var tabMenuObserver: NSObjectProtocol? = nil + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -40,7 +48,17 @@ class TerminalWindow: NSWindow { var terminalController: TerminalController? { windowController as? TerminalController } - + + /// The color assigned to this window's tab. Setting this updates the tab color indicator + /// and marks the window's restorable state as dirty. + var tabColor: TerminalTabColor = .none { + didSet { + guard tabColor != oldValue else { return } + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) + invalidateRestorableState() + } + } + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -66,7 +84,7 @@ class TerminalWindow: NSWindow { guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } - + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -74,14 +92,14 @@ class TerminalWindow: NSWindow { DispatchQueue.main.async { self.tabbingMode = .automatic } - + // All new windows are based on the app config at the time of creation. guard let appDelegate = NSApp.delegate as? AppDelegate else { return } let config = appDelegate.ghostty.config // Setup our initial config derivedConfig = .init(config) - + // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this // if necessary but this ensures our window loads with the proper @@ -116,7 +134,7 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false - + // Create update notification accessory if supportsUpdateAccessory { updateAccessory.layoutAttribute = .right @@ -132,9 +150,16 @@ class TerminalWindow: NSWindow { // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) + + let stackView = NSStackView() + stackView.orientation = .horizontal stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 + stackView.spacing = 4 + stackView.alignment = .centerY + stackView.addArrangedSubview(tabColorIndicator) + stackView.addArrangedSubview(keyEquivalentLabel) + stackView.addArrangedSubview(resetZoomTabButton) tab.accessoryView = stackView // Get our saved level @@ -145,7 +170,7 @@ class TerminalWindow: NSWindow { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } - + override func close() { NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() @@ -215,8 +240,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") - func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -279,7 +302,7 @@ class TerminalWindow: NSWindow { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { removeTitlebarAccessoryViewController(at: idx) } - + // We don't need to do this with the update accessory. I don't know why but // everything works fine. } @@ -292,52 +315,6 @@ class TerminalWindow: NSWindow { } } - private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { - guard isTabContextMenu(menu) else { return } - - // Get the target from an existing menu item. The native tab context menu items - // target the specific window/controller that was right-clicked, not the focused one. - // We need to use that same target so validation and action use the correct tab. - let targetController = menu.items - .first { $0.action == NSSelectorFromString("performClose:") } - .flatMap { $0.target as? NSWindow } - .flatMap { $0.windowController as? TerminalController } - - // Close tabs to the right - let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = targetController - item.setImageIfDesired(systemSymbolName: "xmark") - if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && - !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { - menu.addItem(item) - } - - // Other close items should have the xmark to match Safari on macOS 26 - for menuItem in menu.items { - if menuItem.action == NSSelectorFromString("performClose:") || - menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { - menuItem.setImageIfDesired(systemSymbolName: "xmark") - } - } - } - - private func isTabContextMenu(_ menu: NSMenu) -> Bool { - guard NSApp.keyWindow === self else { return false } - - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ - "performClose:", - "performCloseOtherTabs:", - "moveTabToNewWindow:", - "toggleTabOverview:" - ] - - let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) - } - - // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -549,7 +526,7 @@ class TerminalWindow: NSWindow { private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { + guard x != nil, y != nil else { if (!LastWindowPosition.shared.restore(self)) { center() } @@ -568,7 +545,7 @@ class TerminalWindow: NSWindow { center() return } - + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) setFrameOrigin(frame.origin) } @@ -584,7 +561,7 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - + // MARK: Config struct DerivedConfig { @@ -651,12 +628,12 @@ extension TerminalWindow { } } } - + /// A pill-shaped button that displays update status and provides access to update actions. struct UpdateAccessoryView: View { @ObservedObject var viewModel: ViewModel @ObservedObject var model: UpdateViewModel - + var body: some View { // We use the same top/trailing padding so that it hugs the same. UpdatePill(model: model) @@ -666,3 +643,120 @@ extension TerminalWindow { } } + +/// A small circle indicator displayed in the tab accessory view that shows +/// the user-assigned tab color. When no color is set, the view is hidden. +private struct TabColorIndicatorView: View { + /// The tab color to display. + let tabColor: TerminalTabColor + + var body: some View { + if let color = tabColor.displayColor { + Circle() + .fill(Color(color)) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(Color.clear) + .frame(width: 6, height: 6) + .hidden() + } + } +} + +// MARK: - Tab Context Menu + +extension TerminalWindow { + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") + + func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } + + // Close tabs to the right + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = targetController + item.setImageIfDesired(systemSymbolName: "xmark") + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { + menu.addItem(item) + } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } + + appendTabModifierSection(to: menu, target: targetController) + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) + } + + private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ + Self.tabColorSeparatorIdentifier, + Self.changeTitleMenuItemIdentifier, + Self.tabColorPaletteIdentifier + ]) + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.addItem(separator) + + // Change Title... + let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier + changeTitleItem.target = target + changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") + menu.addItem(changeTitleItem) + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + paletteItem.view = makeTabColorPaletteView( + selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none + ) { [weak target] color in + (target?.window as? TerminalWindow)?.tabColor = color + } + menu.addItem(paletteItem) + } +} + +private func makeTabColorPaletteView( + selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void +) -> NSView { + let hostingView = NSHostingView(rootView: TabColorMenuView( + selectedColor: selectedColor, + onSelect: selectionHandler + )) + hostingView.frame.size = hostingView.intrinsicContentSize + return hostingView +} diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 8fce2199d..9eb7a8e46 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -127,6 +127,20 @@ extension Ghostty.Action { } } } + + enum PromptTitle { + case surface + case tab + + init(_ c: ghostty_action_prompt_title_e) { + switch c { + case GHOSTTY_PROMPT_TITLE_TAB: + self = .tab + default: + self = .surface + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f6452e54e..aff3edbc7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -523,7 +523,7 @@ extension Ghostty { setTitle(app, target: target, v: action.action.set_title) case GHOSTTY_ACTION_PROMPT_TITLE: - return promptTitle(app, target: target) + return promptTitle(app, target: target, v: action.action.prompt_title) case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1350,22 +1350,50 @@ extension Ghostty { private static func promptTitle( _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("set title prompt does nothing with an app target") - return false + target: ghostty_target_s, + v: ghostty_action_prompt_title_e) -> Bool { + let promptTitle = Action.PromptTitle(v) + switch promptTitle { + case .surface: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title prompt does nothing with an app target") + return false - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - surfaceView.promptTitle() + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + surfaceView.promptTitle() + return true - default: - assertionFailure() + default: + assertionFailure() + return false + } + + case .tab: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + default: + assertionFailure() + return false + } } - - return true } private static func pwdChanged( diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e86df4454..130df6f44 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1417,8 +1417,9 @@ extension Ghostty { item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") + item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") return menu } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 7ddfa419f..82c0a3a41 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -10,20 +10,33 @@ extension NSMenu { /// - item: The menu item to insert. /// - action: The action selector to search for. The new item will be inserted after the first /// item with this action. - /// - Returns: `true` if the item was inserted after the specified action, `false` if the action - /// was not found and the item was not inserted. + /// - Returns: The index where the item was inserted, or `nil` if the action was not found + /// and the item was not inserted. @discardableResult - func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? { if let identifier = item.identifier, let existing = items.first(where: { $0.identifier == identifier }) { removeItem(existing) } guard let idx = items.firstIndex(where: { $0.action == action }) else { - return false + return nil } - insertItem(item, at: idx + 1) - return true + let insertionIndex = idx + 1 + insertItem(item, at: insertionIndex) + return UInt(insertionIndex) + } + + /// Removes all menu items whose identifier is in the given set. + /// + /// - Parameter identifiers: The set of identifiers to match for removal. + func removeItems(withIdentifiers identifiers: Set) { + for (index, item) in items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + removeItem(at: index) + } + } } } diff --git a/po/README_TRANSLATORS.md b/po/README_TRANSLATORS.md index 582d5037c..25b7cab5b 100644 --- a/po/README_TRANSLATORS.md +++ b/po/README_TRANSLATORS.md @@ -44,9 +44,9 @@ intended to be regenerated by code contributors. If there is a problem with the template file, please reach out to a code contributor. Instead, only edit the translation file corresponding to your language/locale, -identified via the its _locale name_: for example, `de_DE.UTF-8.po` would be -the translation file for German (language code `de`) as spoken in Germany -(country code `DE`). The GNU `gettext` manual contains +identified via its _locale name_: for example, `de_DE.UTF-8.po` would be the +translation file for German (language code `de`) as spoken in Germany (country +code `DE`). The GNU `gettext` manual contains [further information about locale names](https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Names-1), including a list of language and country codes. diff --git a/src/Surface.zig b/src/Surface.zig index 9e7ad0b97..8cd8d253b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5183,7 +5183,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .prompt_surface_title => return try self.rt_app.performAction( .{ .surface = self }, .prompt_title, - {}, + .surface, + ), + + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .tab, ), .clear_screen => { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 365f525f8..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -189,8 +189,9 @@ pub const Action = union(Key) { set_title: SetTitle, /// Set the title of the target to a prompted value. It is up to - /// the apprt to prompt. - prompt_title, + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -536,6 +537,12 @@ pub const MouseVisibility = enum(c_int) { hidden, }; +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, +}; + pub const MouseOverLink = struct { url: [:0]const u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 52a9f1a35..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -693,7 +693,7 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), - .prompt_title => return Action.promptTitle(target), + .prompt_title => return Action.promptTitle(target, value), .quit => self.quit(), @@ -2250,12 +2250,18 @@ const Action = struct { }; } - pub fn promptTitle(target: apprt.Target) bool { - switch (target) { - .app => return false, - .surface => |v| { - v.rt_surface.surface.promptTitle(); - return true; + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + // GTK does not yet support tab title prompting + return false; }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66fe03651..e1c636ab7 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -519,6 +519,11 @@ pub const Action = union(enum) { /// version can be found by running `ghostty +version`. prompt_surface_title, + /// Change the title of the current tab/window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1191,6 +1196,7 @@ pub const Action = union(enum) { .reset_font_size, .set_font_size, .prompt_surface_title, + .prompt_tab_title, .clear_screen, .select_all, .scroll_to_top, diff --git a/src/input/command.zig b/src/input/command.zig index b3f9e86b6..639fc6e39 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -413,10 +413,16 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Title...", + .title = "Change Terminal Title...", .description = "Prompt for a new title for the current terminal.", }}, + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title...", + .description = "Prompt for a new title for the current tab.", + }}, + .new_split => comptime &.{ .{ .action = .{ .new_split = .left }, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 29f414e03..9e14e2a75 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1220,7 +1220,7 @@ const ReflowCursor = struct { // with graphemes then we increase capacity. if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { try self.adjustCapacity(list, .{ - .hyperlink_bytes = cap.grapheme_bytes * 2, + .grapheme_bytes = cap.grapheme_bytes * 2, }); } @@ -10758,3 +10758,86 @@ test "PageList clears history" { .x = 0, }, s.getTopLeft(.active)); } + +test "PageList resize reflow grapheme map capacity exceeded" { + // This test verifies that when reflowing content with many graphemes, + // the grapheme map capacity is correctly increased when needed. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Get the grapheme capacity from the page. We need more than this many + // graphemes in a single destination page to trigger capacity increase + // during reflow. Since each source page can only hold this many graphemes, + // we create two source pages with graphemes that will merge into one + // destination page. + const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + // Use slightly more than half the capacity per page, so combined they + // exceed the capacity of a single destination page. + const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; + + // Grow to the capacity of the first page and add more rows + // so that we have two pages total. + { + const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + page.pauseIntegrityChecks(false); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(graphemes_per_page); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + + // We now have two pages. + try testing.expect(s.pages.first.? != s.pages.last.?); + try testing.expectEqual(s.pages.last.?, s.pages.first.?.next); + } + + // Add graphemes to both pages. We add graphemes to rows at the END of the + // first page, and graphemes to rows at the START of the second page. + // When reflowing to 2 columns, these rows will wrap and stay together + // on the same destination page, requiring capacity increase. + + // Add graphemes to the end of the first page (last rows) + { + const page = &s.pages.first.?.data; + const start_row = page.size.rows - graphemes_per_page; + for (0..graphemes_per_page) |i| { + const y = start_row + i; + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301))); + } + } + + // Add graphemes to the beginning of the second page + { + const page = &s.pages.last.?.data; + const count = @min(graphemes_per_page, page.size.rows); + for (0..count) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302))); + } + } + + // Resize to fewer columns to trigger reflow. + // The graphemes from both pages will be copied to destination pages. + // They will all end up in a contiguous region of the destination. + // If the bug exists (hyperlink_bytes increased instead of grapheme_bytes), + // this will fail with GraphemeMapOutOfMemory when we exceed capacity. + try s.resize(.{ .cols = 2, .reflow = true }); + + // Verify the resize succeeded + try testing.expectEqual(@as(usize, 2), s.cols); +} diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index e06050605..96dfcfdf3 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -855,13 +855,17 @@ fn HashMapUnmanaged( pub fn layoutForCapacity(new_capacity: Size) Layout { assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); + // Cast to usize to prevent overflow in size calculations. + // See: https://github.com/ziglang/zig/pull/19048 + const cap: usize = new_capacity; + // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); - const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const meta_end = @sizeOf(Header) + cap * @sizeOf(Metadata); const keys_start = std.mem.alignForward(usize, meta_end, key_align); - const keys_end = keys_start + new_capacity * @sizeOf(K); + const keys_end = keys_start + cap * @sizeOf(K); const vals_start = std.mem.alignForward(usize, keys_end, val_align); - const vals_end = vals_start + new_capacity * @sizeOf(V); + const vals_end = vals_start + cap * @sizeOf(V); // Our total memory size required is the end of our values // aligned to the base required alignment. @@ -1511,3 +1515,26 @@ test "OffsetHashMap remake map" { try expectEqual(5, map.get(5).?); } } + +test "layoutForCapacity no overflow for large capacity" { + // Test that layoutForCapacity correctly handles large capacities without overflow. + // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, + // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. + // See: https://github.com/ghostty-org/ghostty/issues/9862 + const Map = AutoHashMapUnmanaged(u64, u64); + + // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 + // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 + const large_cap: Map.Size = 1 << 30; + const layout = Map.layoutForCapacity(large_cap); + + // With the fix, total_size should be at least cap * (sizeof(K) + sizeof(V)) + // = 2^30 * 16 = 2^34 bytes = 16 GiB + // Without the fix, this would wrap and produce a much smaller value. + const min_expected: usize = @as(usize, large_cap) * (@sizeOf(u64) + @sizeOf(u64)); + try expect(layout.total_size >= min_expected); + + // Also verify the individual offsets don't wrap + try expect(layout.keys_start > 0); + try expect(layout.vals_start > layout.keys_start); +} diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index c2a637b80..a79e38639 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,7 +259,7 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3); + var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); defer args.deinit(alloc); // Iterator that yields each argument in the original command line. @@ -273,11 +273,6 @@ fn setupBash( } else return null; try args.append(alloc, "--posix"); - // On macOS, we request a login shell to match that platform's norms. - if (comptime builtin.target.os.tag.isDarwin()) { - try args.append(alloc, "--login"); - } - // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile // We always include at least "1" so the script can differentiate between @@ -357,9 +352,8 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Since we built up a command line, we don't need to wrap it in - // ANOTHER shell anymore and can do a direct command. - return .{ .direct = try args.toOwnedSlice(alloc) }; + // Join the accumulated arguments to form the final command string. + return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; } test "bash" { @@ -373,12 +367,7 @@ test "bash" { const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } @@ -421,12 +410,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -437,12 +421,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } } @@ -459,24 +438,14 @@ test "bash: rcfile" { // bash --rcfile { const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } // bash --init-file { const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } } @@ -538,35 +507,13 @@ test "bash: additional arguments" { // "-" argument separator { const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("-", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("--", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } }