Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ extension ShortcutAction {
case .fileExplorerOpenSelectionFinderAlias: return ShortcutStroke(key: "↓", command: true)
case .openDiffViewer: return ShortcutStroke(key: "d", command: true, shift: true, control: true)
case .saveFilePreview: return ShortcutStroke(key: "s", command: true)
case .openInTerminalEditor: return ShortcutStroke(key: "e", command: true, control: true)
case .openBrowser: return ShortcutStroke(key: "l", command: true, shift: true)
case .focusBrowserAddressBar: return ShortcutStroke(key: "l", command: true)
case .browserBack: return ShortcutStroke(key: "[", command: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ extension ShortcutAction {
return String(localized: "shortcut.canvasDistributeVertically.label", defaultValue: "Canvas: Distribute Vertically")
case .openDiffViewer: return "Open Diff Viewer"
case .saveFilePreview: return "Save File Preview"
case .openInTerminalEditor: return "Open in Terminal Editor"
case .openBrowser: return "Open Browser"
case .focusBrowserAddressBar: return "Focus Address Bar"
case .browserBack: return "Back"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ extension ShortcutAction {
.simulatorHome, .simulatorRotateLeft, .simulatorRotateRight,
.simulatorToggleAppearance, .simulatorToggleSoftwareKeyboard:
return .panes
case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar,
case .openDiffViewer, .saveFilePreview, .openInTerminalEditor,
.openBrowser, .focusBrowserAddressBar,
.browserBack, .browserForward, .browserReload, .browserHardReload,
.browserZoomIn, .browserZoomOut, .browserZoomReset,
.markdownZoomIn, .markdownZoomOut, .markdownZoomReset,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
// MARK: Browser & Find
case openDiffViewer
case saveFilePreview
case openInTerminalEditor
case openBrowser
case focusBrowserAddressBar
case browserBack
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
public import Foundation

/// Selects whether a terminal surface created with a startup command outlives
/// that command.
public enum TerminalSurfaceCommandExitPolicy: Equatable, Sendable {
/// Holds the PTY open once the startup command exits.
case waitAfterCommand

/// Closes the surface as soon as the startup command exits.
case closeOnExit

/// Applies the policy while preserving unrelated inherited configuration.
///
/// - Parameter inheritedConfig: The configuration inherited from the
/// selected terminal, or `nil` when none is available.
/// - Returns: The configuration for the new terminal.
public func applying(
to inheritedConfig: CmuxSurfaceConfigTemplate?
) -> CmuxSurfaceConfigTemplate {
var template = inheritedConfig ?? CmuxSurfaceConfigTemplate()
template.waitAfterCommand = self == .waitAfterCommand
return template
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import CmuxTerminalCore
import Foundation
import Testing

@Suite struct TerminalSurfaceCommandExitPolicyTests {
@Test func waitAfterCommandHoldsTheSurfaceOpen() {
let applied = TerminalSurfaceCommandExitPolicy.waitAfterCommand
.applying(to: CmuxSurfaceConfigTemplate())

#expect(applied.waitAfterCommand)
}

@Test func waitAfterCommandAppliesWithoutInheritedConfiguration() {
let applied = TerminalSurfaceCommandExitPolicy.waitAfterCommand.applying(to: nil)

#expect(applied.waitAfterCommand)
}

@Test func closeOnExitClearsAnInheritedWait() {
var inherited = CmuxSurfaceConfigTemplate()
inherited.waitAfterCommand = true

let applied = TerminalSurfaceCommandExitPolicy.closeOnExit.applying(to: inherited)

#expect(!applied.waitAfterCommand)
}

@Test func policyPreservesUnrelatedConfiguration() {
var inherited = CmuxSurfaceConfigTemplate()
inherited.setFontSize(13, isExplicitOverride: true)
inherited.workingDirectory = "/tmp/inherited"
inherited.command = "echo inherited"
inherited.environmentVariables = ["CMUX_TEST": "inherited"]
inherited.initialInput = "pwd\n"

let applied = TerminalSurfaceCommandExitPolicy.closeOnExit.applying(to: inherited)

#expect(applied.fontSizeLineage == inherited.fontSizeLineage)
#expect(applied.workingDirectory == inherited.workingDirectory)
#expect(applied.command == inherited.command)
#expect(applied.environmentVariables == inherited.environmentVariables)
#expect(applied.initialInput == inherited.initialInput)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
public import Foundation

/// A terminal editor resolved to an absolute executable path, its arguments,
/// and the `PATH` its shell would have given it.
///
/// Resolving once lets the editor launch with no shell in between, which on a
/// configured machine is the difference between tens and hundreds of
/// milliseconds per open.
public struct ResolvedTerminalEditor: Codable, Equatable, Sendable {
public let executablePath: String
public let arguments: [String]

/// The interactive shell's `PATH`, carried so the editor still finds the
/// tools it shells out to (language servers, formatters) without paying for
/// shell startup.
public let pathEnvironment: String

public init(executablePath: String, arguments: [String], pathEnvironment: String) {
self.executablePath = executablePath
self.arguments = arguments
self.pathEnvironment = pathEnvironment
}
}

/// Asks the user's interactive shell which editor it would use.
public enum TerminalEditorProbe {
/// Shell payload printing the configured editor, its absolute path, and `PATH`.
///
/// POSIX-family syntax only; fish keeps the interactive-shell path, which
/// needs no probe.
public static let payload = """
cmux_editor="${VISUAL:-${EDITOR:-vi}}"
set -- $cmux_editor
cmux_path=$(command -v "$1" 2>/dev/null) || cmux_path=""
printf '%s\\n%s\\n%s\\n' "$cmux_editor" "$cmux_path" "$PATH"
"""

/// Parses ``payload`` output, returning `nil` when the editor could not be
/// resolved to an absolute path.
public static func parse(_ output: String) -> ResolvedTerminalEditor? {
let lines = output
.split(separator: "\n", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
guard lines.count >= 3 else { return nil }

let words = lines[0].split(whereSeparator: \.isWhitespace).map(String.init)
let executablePath = lines[1]
// An empty PATH would leave the editor unable to find the tools it
// spawns, so an incomplete probe falls back to the shell path.
guard !words.isEmpty, executablePath.hasPrefix("/"), !lines[2].isEmpty else {
return nil
}

return ResolvedTerminalEditor(
executablePath: executablePath,
arguments: Array(words.dropFirst()),
pathEnvironment: lines[2]
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
public import Foundation

/// Builds the command that opens a file in the user's terminal editor.
///
/// The editor is chosen by the shell that runs the command, not by this
/// process: a GUI app launched by LaunchServices has none of the user's shell
/// configuration, so `$VISUAL` and `$EDITOR` are only visible once their shell
/// has loaded its own configuration.
public enum TerminalEditorCommand {
/// The editor used when neither `$VISUAL` nor `$EDITOR` is set.
public static let fallbackEditor = "vi"

/// Builds the payload that runs the user's editor on `url`.
///
/// `exec` replaces the shell, so the surface's child exits with the editor.
///
/// - Parameters:
/// - url: The file to open.
/// - userShell: Path to the shell that will run the payload, which
/// selects the syntax used to read `$VISUAL` and `$EDITOR`.
public static func command(forOpening url: URL, userShell: String?) -> String {
let quotedPath = url.path.posixShellSingleQuoted
guard isFishShell(userShell) else {
return "exec ${VISUAL:-${EDITOR:-\(fallbackEditor)}} \(quotedPath)"
}
return """
set -l cmux_editor $VISUAL
test -n "$cmux_editor"; or set cmux_editor $EDITOR
test -n "$cmux_editor"; or set cmux_editor \(fallbackEditor)
exec $cmux_editor \(quotedPath)
"""
}

/// Builds the startup command for an editor already resolved to an absolute
/// path, so no shell has to look it up again.
///
/// The result starts with the executable, never `exec`: Ghostty prepends
/// `exec -l` to startup commands, and `exec -l exec …` makes the shell look
/// for a program named `exec`.
public static func command(
forOpening url: URL,
resolvedEditor: ResolvedTerminalEditor
) -> String {
let words = [resolvedEditor.executablePath.posixShellSingleQuoted]
+ resolvedEditor.arguments.map(\.posixShellSingleQuoted)
+ [url.path.posixShellSingleQuoted]
return words.joined(separator: " ")
}

private static func isFishShell(_ userShell: String?) -> Bool {
guard let userShell else { return false }
return (userShell as NSString).lastPathComponent == "fish"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import CmuxWorkspaces

@Suite("TerminalEditorCommand")
struct TerminalEditorCommandTests {
@Test func posixShellsResolveTheEditorAtRuntime() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/notes.md"),
userShell: "/bin/zsh"
)

#expect(command == "exec ${VISUAL:-${EDITOR:-vi}} '/tmp/notes.md'")
}

@Test func anUnknownShellUsesPosixSyntax() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/notes.md"),
userShell: nil
)

#expect(command.hasPrefix("exec ${VISUAL:-${EDITOR:-vi}}"))
}

@Test func commandQuotesThePath() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/some notes.md"),
userShell: "/bin/bash"
)

#expect(command.hasSuffix("'/tmp/some notes.md'"))
}

@Test func commandEscapesEmbeddedSingleQuotes() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/it's here.md"),
userShell: "/bin/zsh"
)

#expect(command.hasSuffix("'/tmp/it'\\''s here.md'"))
}

@Test func execReplacesTheShellSoTheSurfaceClosesWithTheEditor() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/notes.md"),
userShell: "/bin/zsh"
)

#expect(command.hasPrefix("exec "))
}

@Test func fishUsesItsOwnConditionalSyntax() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/notes.md"),
userShell: "/opt/homebrew/bin/fish"
)

#expect(command.contains("set -l cmux_editor $VISUAL"))
#expect(command.contains("or set cmux_editor $EDITOR"))
#expect(command.contains("or set cmux_editor vi"))
#expect(command.contains("exec $cmux_editor '/tmp/notes.md'"))
#expect(!command.contains("${VISUAL"))
}

@Test func theFallbackEditorIsPosixGuaranteed() {
#expect(TerminalEditorCommand.fallbackEditor == "vi")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation
import Testing
@testable import CmuxWorkspaces

@Suite("TerminalEditorProbe")
struct TerminalEditorProbeTests {
@Test func parsesAnEditorWithoutArguments() throws {
let resolved = try #require(
TerminalEditorProbe.parse("nvim\n/opt/homebrew/bin/nvim\n/opt/homebrew/bin:/usr/bin\n")
)

#expect(resolved.executablePath == "/opt/homebrew/bin/nvim")
#expect(resolved.arguments.isEmpty)
}

@Test func keepsEditorArgumentsSeparateFromTheExecutable() throws {
let resolved = try #require(
TerminalEditorProbe.parse("nvim -p\n/opt/homebrew/bin/nvim\n/opt/homebrew/bin:/usr/bin\n")
)

#expect(resolved.executablePath == "/opt/homebrew/bin/nvim")
#expect(resolved.arguments == ["-p"])
#expect(resolved.pathEnvironment == "/opt/homebrew/bin:/usr/bin")
}

@Test func unresolvableEditorsAreRejected() {
#expect(TerminalEditorProbe.parse("nvim\n\n/usr/bin\n") == nil)
#expect(TerminalEditorProbe.parse("nvim\nnot-absolute\n/usr/bin\n") == nil)
#expect(TerminalEditorProbe.parse("nvim\n/opt/homebrew/bin/nvim\n") == nil)
#expect(TerminalEditorProbe.parse("") == nil)
}

@Test func resolvedEditorSurvivesARoundTrip() throws {
let resolved = ResolvedTerminalEditor(
executablePath: "/opt/homebrew/bin/nvim",
arguments: ["-p"],
pathEnvironment: "/opt/homebrew/bin:/usr/bin"
)

let data = try JSONEncoder().encode(resolved)
let decoded = try JSONDecoder().decode(ResolvedTerminalEditor.self, from: data)

#expect(decoded == resolved)
}

@Test func aResolvedEditorNeedsNoShellLookup() {
let command = TerminalEditorCommand.command(
forOpening: URL(fileURLWithPath: "/tmp/some notes.md"),
resolvedEditor: ResolvedTerminalEditor(
executablePath: "/opt/homebrew/bin/nvim",
arguments: ["-p"],
pathEnvironment: "/opt/homebrew/bin:/usr/bin"
)
)

#expect(command == "'/opt/homebrew/bin/nvim' '-p' '/tmp/some notes.md'")
#expect(
!command.hasPrefix("exec "),
"Ghostty prepends `exec -l`, so a leading exec would run a program named exec"
)
#expect(!command.contains("$VISUAL"))
#expect(!command.contains("$EDITOR"))
}
}
1 change: 1 addition & 0 deletions Sources/AppDelegate+DockShortcutRouting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ extension KeyboardShortcutSettings.Action {
.fileExplorerOpenSelection,
.fileExplorerOpenSelectionFinderAlias,
.saveFilePreview,
.openInTerminalEditor,
.browserBack, .browserForward,
.browserReload, .browserHardReload,
.browserZoomIn, .browserZoomOut, .browserZoomReset,
Expand Down
12 changes: 12 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
telemetryEnabled: telemetryEnabled
)
let isRunningUnderXCTest = sentryStartupPolicy.isRunningUnderXCTest
if !isRunningUnderXCTest {
TerminalEditorResolutionStore.refreshInBackground()
}
StartupBreadcrumbLog.append(
"appDelegate.didFinish.begin",
fields: [
Expand Down Expand Up @@ -14983,6 +14986,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return shortcutEventMarkdownPanel(event)?.resetZoom() ?? false
}

if matchConfiguredShortcut(event: event, action: .openInTerminalEditor) {
guard let panel = shortcutEventTerminalEditorPanel(event),
panel.canOpenInTerminalEditor else {
return false
}
panel.openInTerminalEditor()
return true
}

if matchConfiguredShortcut(event: event, action: .findInDirectory) {
return focusFileSearchInActiveMainWindow(preferredWindow: resolvedShortcutEventWindow(event))
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ extension CmuxSettingsFileStore {
"canvas.paneGap",
"canvas.snappingEnabled",
"fileEditor.wordWrap",
"fileEditor.terminalEditorPlacement",
"fileExplorer.doubleClickAction",
"shortcuts.bindings",
]
Expand Down
Loading