diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 2221efc943f..d77933ec95a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -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) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index a25ef20420b..6b1d7f23972 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -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" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index fdce3688427..2f904212b31 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -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, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index b7d0c754bfa..98a7d26591e 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -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 diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCommandExitPolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCommandExitPolicy.swift new file mode 100644 index 00000000000..a4b0c828fa2 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCommandExitPolicy.swift @@ -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 + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalSurfaceCommandExitPolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalSurfaceCommandExitPolicyTests.swift new file mode 100644 index 00000000000..95053abf308 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalSurfaceCommandExitPolicyTests.swift @@ -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) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/ResolvedTerminalEditor.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/ResolvedTerminalEditor.swift new file mode 100644 index 00000000000..bab59d3879d --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/ResolvedTerminalEditor.swift @@ -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] + ) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/TerminalEditorCommand.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/TerminalEditorCommand.swift new file mode 100644 index 00000000000..6c808317281 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/TerminalEditorCommand.swift @@ -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" + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorCommandTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorCommandTests.swift new file mode 100644 index 00000000000..b5c22590095 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorCommandTests.swift @@ -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") + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorProbeTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorProbeTests.swift new file mode 100644 index 00000000000..5e7e0657c50 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/TerminalEditorProbeTests.swift @@ -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")) + } +} diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index 3b34a4b7870..cf18008d71b 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -45,6 +45,7 @@ extension KeyboardShortcutSettings.Action { .fileExplorerOpenSelection, .fileExplorerOpenSelectionFinderAlias, .saveFilePreview, + .openInTerminalEditor, .browserBack, .browserForward, .browserReload, .browserHardReload, .browserZoomIn, .browserZoomOut, .browserZoomReset, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8eb1d32b23c..4049e774c45 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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: [ @@ -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)) } diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 724c65e9f28..4b087d00880 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -520,6 +520,7 @@ extension CmuxSettingsFileStore { "canvas.paneGap", "canvas.snappingEnabled", "fileEditor.wordWrap", + "fileEditor.terminalEditorPlacement", "fileExplorer.doubleClickAction", "shortcuts.bindings", ] diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 958afcc930f..5dcd47f9e3f 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -191,6 +191,18 @@ extension AppDelegate { return tabManager?.focusedMarkdownPanel } + /// The focused file surface that can hand its file to a terminal editor, + /// in either preview or raw-text mode. + func shortcutEventTerminalEditorPanel(_ event: NSEvent) -> (any TerminalEditorOpenablePanel)? { + let window = shortcutResolvedEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + guard let workspace = shortcutContextTabManager(in: window)?.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] as? any TerminalEditorOpenablePanel else { + return nil + } + return panel + } + private func shortcutFocusedFilePreviewTextEditor(in window: NSWindow?) -> Bool { guard let focusedFilePreviewPanel = shortcutContextTabManager(in: window)?.focusedTextFilePreviewPanel, let textView = shortcutFocusedSavingTextView(in: window), diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 86c3121f9eb..8243e7832a0 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -172,6 +172,7 @@ enum KeyboardShortcutSettings { // Panels case saveFilePreview + case openInTerminalEditor case openBrowser case focusBrowserAddressBar case browserBack @@ -329,6 +330,7 @@ enum KeyboardShortcutSettings { case .fileExplorerOpenSelection: return String(localized: "shortcut.fileExplorerOpenSelection.label", defaultValue: "File Explorer: Open Selection") case .fileExplorerOpenSelectionFinderAlias: return String(localized: "shortcut.fileExplorerOpenSelectionFinderAlias.label", defaultValue: "File Explorer: Open Selection (Finder Alias)") case .saveFilePreview: return String(localized: "shortcut.saveFilePreview.label", defaultValue: "Save File Preview") + case .openInTerminalEditor: return String(localized: "shortcut.openInTerminalEditor.label", defaultValue: "Open in Terminal Editor") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") @@ -592,6 +594,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false) case .saveFilePreview: return StoredShortcut(key: "s", command: true, shift: false, option: false, control: false) + case .openInTerminalEditor: + return StoredShortcut(key: "e", command: true, shift: false, option: false, control: true) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .focusBrowserAddressBar: diff --git a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift index 4fe6fc2e0be..68bd0c568f3 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift @@ -13,6 +13,17 @@ extension CmuxSettingsFileStore { } else if section.keys.contains("wordWrap") { logInvalid("fileEditor.wordWrap", sourcePath: sourcePath) } + + if let raw = jsonString(section["terminalEditorPlacement"]) { + if let placement = TerminalEditorPlacement(rawValue: raw) { + snapshot.managedUserDefaults[TerminalEditorPlacementSettings.key] = + .string(placement.rawValue) + } else { + logInvalid("fileEditor.terminalEditorPlacement", sourcePath: sourcePath) + } + } else if section.keys.contains("terminalEditorPlacement") { + logInvalid("fileEditor.terminalEditorPlacement", sourcePath: sourcePath) + } } func parseFileExplorerSection( diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 844a4527719..fb1508ccbc8 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -230,6 +230,7 @@ extension CmuxSettingsFileStore { [ "fileEditor": [ "wordWrap": FilePreviewWordWrapSettings.defaultEnabled, + "terminalEditorPlacement": TerminalEditorPlacementSettings.defaultValue.rawValue, ], ], [ diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 14e369dac19..f536becba8d 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -999,6 +999,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan var fileChangeReloadTask: Task? /// The one container currently projecting this panel's tab metadata. weak var tabMetadataHost: (any FilePreviewTabMetadataHost)? + weak var terminalEditorHost: (any TerminalEditorOpeningHost)? var lastObservedFileState: FilePreviewFileState? var isClosed = false weak var textView: NSTextView? @@ -1439,6 +1440,18 @@ struct FilePreviewPanelView: View { action: { panel.reloadFromDisk() } ) + if panel.canOpenInTerminalEditor { + PanelHeaderIconButton( + systemName: "terminal", + label: String( + localized: "filePreview.openInTerminalEditor", + defaultValue: "Open in Terminal Editor" + ), + isDisabled: panel.isFileUnavailable, + action: { panel.openInTerminalEditor() } + ) + } + FileExternalOpenMenu(fileURL: panel.fileURL, isDisabled: panel.isFileUnavailable) } } diff --git a/Sources/Panels/FilePreviewTabMetadata.swift b/Sources/Panels/FilePreviewTabMetadata.swift index ffaa0b0e2bb..a4bbcd2a0dd 100644 --- a/Sources/Panels/FilePreviewTabMetadata.swift +++ b/Sources/Panels/FilePreviewTabMetadata.swift @@ -14,12 +14,14 @@ extension FilePreviewPanel { /// Replaces the current container binding and immediately projects current state. func bindTabMetadata(to host: any FilePreviewTabMetadataHost) { tabMetadataHost = host + terminalEditorHost = host as? any TerminalEditorOpeningHost host.applyFilePreviewTabMetadata(currentTabMetadata, panelId: id) } /// Clears the current container binding during transfer or teardown. func unbindTabMetadata() { tabMetadataHost = nil + terminalEditorHost = nil } /// Projects the consolidated snapshot through the panel's single active host. diff --git a/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift b/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift index 1eada6ab4dd..caad591a63c 100644 --- a/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift +++ b/Sources/Panels/FilePreviewWorkspaceOpenSupport.swift @@ -1,6 +1,111 @@ +import AppKit import Bonsplit +import CmuxTerminalCore +import CmuxWorkspaces import Foundation +/// A container that can open one of its file surfaces in a terminal editor. +/// +/// Only containers that own a terminal-capable surface tree conform, so a panel +/// hosted elsewhere — the Dock — leaves its binding `nil` and hides the action. +@MainActor +protocol TerminalEditorOpeningHost: AnyObject { + @discardableResult + func openTerminalEditorSurface(forPanelId panelId: UUID) -> TerminalPanel? +} + +extension Workspace: TerminalEditorOpeningHost { + @discardableResult + func openTerminalEditorSurface(forPanelId panelId: UUID) -> TerminalPanel? { + let resolvedEditor = TerminalEditorResolutionStore.cached() + if resolvedEditor == nil { + TerminalEditorResolutionStore.refreshInBackground() + } + return openTerminalEditorSurface( + forPanelId: panelId, + userShell: WorkspaceInitialCommandLoginShell.resolvedUserShell(), + resolvedEditor: resolvedEditor, + placement: TerminalEditorPlacementSettings.resolvedPlacement() + ) + } + + /// Opens the file shown by `panelId` in a terminal surface running the + /// user's terminal editor. + /// + /// - Parameters: + /// - panelId: A file-preview or markdown panel. + /// - userShell: The shell that runs the editor. + /// - resolvedEditor: A previously resolved editor, which avoids paying + /// interactive shell startup on every open. When `nil`, the editor is + /// resolved by an interactive shell instead. + /// - placement: Where the terminal surface goes. + /// - Returns: The created terminal panel, or `nil` when the panel is not a + /// file surface or is no longer in the tree. + @discardableResult + func openTerminalEditorSurface( + forPanelId panelId: UUID, + userShell: String?, + resolvedEditor: ResolvedTerminalEditor?, + placement: TerminalEditorPlacement + ) -> TerminalPanel? { + guard let filePath = fileSurfacePath(forPanelId: panelId), + let paneId = paneId(forPanelId: panelId) else { + return nil + } + + let fileURL = URL(fileURLWithPath: filePath) + let startupCommand: String + var startupEnvironment: [String: String] = [:] + if let resolvedEditor { + // No shell in between: the editor is already an absolute path, and + // carrying the shell's PATH keeps the tools it spawns reachable. + startupCommand = TerminalEditorCommand.command( + forOpening: fileURL, + resolvedEditor: resolvedEditor + ) + startupEnvironment["PATH"] = resolvedEditor.pathEnvironment + } else { + startupCommand = WorkspaceInitialCommandLoginShell.wrapInteractive( + TerminalEditorCommand.command(forOpening: fileURL, userShell: userShell), + userShell: userShell + ) + } + + let sourceIndex = indexInPane(forPanelId: panelId) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: fileURL.deletingLastPathComponent().path, + initialCommand: startupCommand, + startupEnvironment: startupEnvironment, + commandExitPolicy: .closeOnExit + ) else { + return nil + } + + // The file surface deliberately stays open. It is what the user returns + // to when the editor exits, and it keeps the editor from being the + // workspace's last panel — a child exit there collapses the workspace. + if placement == .afterSource, + let sourceIndex, + let tabId = surfaceIdFromPanelId(terminalPanel.id) { + _ = bonsplitController.reorderTab(tabId, toIndex: sourceIndex + 1) + } + return terminalPanel + } + + private func fileSurfacePath(forPanelId panelId: UUID) -> String? { + switch panels[panelId] { + case let panel as FilePreviewPanel: + return panel.filePath + case let panel as MarkdownPanel: + return panel.filePath + default: + return nil + } + } +} + extension Workspace { @discardableResult func openFileSurfaces( @@ -105,3 +210,162 @@ extension Workspace { return openedPanels } } + +/// Where the terminal editor opens relative to the file surface it came from. +enum TerminalEditorPlacement: String, CaseIterable, Sendable { + /// Immediately to the right of the file surface. + case afterSource + /// At the end of the pane's tab strip. + case endOfTabStrip +} + +enum TerminalEditorPlacementSettings { + static let key = "terminalEditorPlacement" + static let defaultValue: TerminalEditorPlacement = .afterSource + + /// Parses a raw config value, falling back to ``defaultValue`` for `nil` or + /// unrecognized input. + static func placement(forRawValue raw: String?) -> TerminalEditorPlacement { + guard let raw, let placement = TerminalEditorPlacement(rawValue: raw) else { + return defaultValue + } + return placement + } + + static func resolvedPlacement(defaults: UserDefaults = .standard) -> TerminalEditorPlacement { + placement(forRawValue: defaults.string(forKey: key)) + } +} + +/// Caches the editor resolved from the user's interactive shell. +/// +/// Interactive shell startup dominates the cost of opening the editor, so it is +/// paid in the background rather than on the user's keystroke. +@MainActor +enum TerminalEditorResolutionStore { + static let defaultsKey = "terminalEditorResolvedEditor" + + private static var inMemoryValue: ResolvedTerminalEditor? + + static func cached(defaults: UserDefaults = .standard) -> ResolvedTerminalEditor? { + if let inMemoryValue { return inMemoryValue } + guard let data = defaults.data(forKey: defaultsKey), + let decoded = try? JSONDecoder().decode(ResolvedTerminalEditor.self, from: data) else { + return nil + } + inMemoryValue = decoded + return decoded + } + + static func store(_ resolved: ResolvedTerminalEditor, defaults: UserDefaults = .standard) { + inMemoryValue = resolved + guard let data = try? JSONEncoder().encode(resolved) else { return } + defaults.set(data, forKey: defaultsKey) + } + + /// Re-resolves the editor off the main thread, adopting the result when the + /// shell reports an absolute path. + static func refreshInBackground( + userShell: String = WorkspaceInitialCommandLoginShell.resolvedUserShell() + ) { + Task.detached(priority: .utility) { + guard let resolved = probe(userShell: userShell) else { return } + await MainActor.run { store(resolved) } + } + } + + nonisolated static func probe(userShell: String) -> ResolvedTerminalEditor? { + let process = Process() + process.executableURL = URL(fileURLWithPath: userShell) + process.arguments = ["-ilc", TerminalEditorProbe.payload] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, + let text = String(data: data, encoding: .utf8) else { + return nil + } + return TerminalEditorProbe.parse(text) + } +} + +/// A file surface that can hand its file off to a terminal editor. +@MainActor +protocol TerminalEditorOpenablePanel: AnyObject { + var id: UUID { get } + var isDirty: Bool { get } + var terminalEditorHost: (any TerminalEditorOpeningHost)? { get } + + @discardableResult + func saveTextContent() -> Task? +} + +extension FilePreviewPanel: TerminalEditorOpenablePanel {} + +extension MarkdownPanel: TerminalEditorOpenablePanel {} + +extension TerminalEditorOpenablePanel { + /// Whether the terminal-editor action is available for this panel. + var canOpenInTerminalEditor: Bool { + terminalEditorHost != nil + } + + /// Hands this panel's file to a terminal editor, saving first when the + /// buffer is dirty and the user confirms. + /// + /// - Parameter confirmSaveBeforeOpen: Consulted only when the buffer is + /// dirty. Returning `false` cancels the handoff. + func openInTerminalEditor( + confirmSaveBeforeOpen: () -> Bool = TerminalEditorSavePrompt.run + ) { + guard let host = terminalEditorHost else { return } + + guard isDirty else { + host.openTerminalEditorSurface(forPanelId: id) + return + } + + guard confirmSaveBeforeOpen() else { return } + let saveTask = saveTextContent() + let panelId = id + Task { @MainActor [weak host] in + await saveTask?.value + host?.openTerminalEditorSurface(forPanelId: panelId) + } + } +} + +/// Asks whether to save a dirty buffer before handing the file to an editor. +@MainActor +enum TerminalEditorSavePrompt { + static func run() -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "filePreview.openInTerminalEditor.unsaved.title", + defaultValue: "Save before opening in the editor?" + ) + alert.informativeText = String( + localized: "filePreview.openInTerminalEditor.unsaved.message", + defaultValue: "This file has unsaved changes, and the editor opens the version on disk." + ) + alert.addButton(withTitle: String( + localized: "filePreview.openInTerminalEditor.unsaved.save", + defaultValue: "Save & Open" + )) + alert.addButton(withTitle: String( + localized: "filePreview.openInTerminalEditor.unsaved.cancel", + defaultValue: "Cancel" + )) + return alert.runModal() == .alertFirstButtonReturn + } +} diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 56ad9588ffb..1b972d3651d 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -24,6 +24,9 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// The workspace this panel belongs to. private(set) var workspaceId: UUID + /// The container that opens this file in a terminal editor, when it can. + weak var terminalEditorHost: (any TerminalEditorOpeningHost)? + /// Current markdown content read from the file. @Published private(set) var content: String = "" diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 592312a84da..12c0cbd9f94 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -140,6 +140,17 @@ struct MarkdownPanelView: View { onCopyMarkdown: { copyAsMarkdown() }, onCopyHTML: { copyAsHTML() } ) + if panel.canOpenInTerminalEditor { + PanelHeaderIconButton( + systemName: "terminal", + label: String( + localized: "filePreview.openInTerminalEditor", + defaultValue: "Open in Terminal Editor" + ), + isDisabled: panel.isFileUnavailable, + action: { panel.openInTerminalEditor() } + ) + } FileExternalOpenMenu( fileURL: URL(fileURLWithPath: panel.filePath), isDisabled: panel.isFileUnavailable diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b29ab2e64b8..547432f7751 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4265,6 +4265,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { + markdownPanel.terminalEditorHost = self let subscription = Publishers.CombineLatest( markdownPanel.$displayTitle.removeDuplicates(), markdownPanel.$isDirty.removeDuplicates() @@ -7863,6 +7864,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos suppressWorkspaceRemoteStartupCommand: Bool = false, restoredSurfaceId: UUID? = nil, terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, + commandExitPolicy: TerminalSurfaceCommandExitPolicy = .waitAfterCommand, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, allowTextBoxFocusDefault: Bool = true @@ -7883,6 +7885,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, restoredSurfaceId: restoredSurfaceId, terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, + commandExitPolicy: commandExitPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, allowTextBoxFocusDefault: allowTextBoxFocusDefault @@ -7908,6 +7911,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos suppressWorkspaceRemoteStartupCommand: Bool = false, restoredSurfaceId: UUID? = nil, terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, + commandExitPolicy: TerminalSurfaceCommandExitPolicy = .waitAfterCommand, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, allowTextBoxFocusDefault: Bool = true @@ -7958,6 +7962,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, restoredSurfaceId: restoredSurfaceId, terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, + commandExitPolicy: commandExitPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, allowTextBoxFocusDefault: allowTextBoxFocusDefault @@ -7981,6 +7986,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos suppressWorkspaceRemoteStartupCommand: Bool, restoredSurfaceId: UUID?, terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy, + commandExitPolicy: TerminalSurfaceCommandExitPolicy, inheritWorkingDirectoryFallback: Bool, workingDirectoryFallbackSourcePanelId: UUID?, allowTextBoxFocusDefault: Bool @@ -8015,9 +8021,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos // command exits so the user sees the error rather than a silently-respawned // local login shell. if startupCommand != nil { - var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - inheritedConfig = template + inheritedConfig = commandExitPolicy.applying(to: inheritedConfig) } let fallbackSourcePanelId = workingDirectoryFallbackSourcePanelId ?? bonsplitController.selectedTab(inPane: paneId).map(\.id).flatMap(panelIdFromSurfaceId) diff --git a/Sources/WorkspaceInitialCommandLoginShell.swift b/Sources/WorkspaceInitialCommandLoginShell.swift index bf32baa24ed..ccb8805fee6 100644 --- a/Sources/WorkspaceInitialCommandLoginShell.swift +++ b/Sources/WorkspaceInitialCommandLoginShell.swift @@ -8,6 +8,12 @@ enum WorkspaceInitialCommandLoginShell { /// so the user's profile cannot contribute tools such as Homebrew-installed agents. /// Ghostty prepends `exec -l`, so the returned command starts with the quoted shell path. static func wrap(_ command: String) -> String { + wrap(command, userShell: resolvedUserShell()) + } + + /// The user's login shell, from the account record with the environment and + /// zsh as fallbacks. + static func resolvedUserShell() -> String { let databaseShell: String? if let record = getpwuid(getuid()), let shell = record.pointee.pw_shell { @@ -17,10 +23,17 @@ enum WorkspaceInitialCommandLoginShell { databaseShell = nil } - let userShell = databaseShell + return databaseShell ?? ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - return wrap(command, userShell: userShell) + } + + /// Wraps a command in an *interactive* login shell. + /// + /// Values such as `$EDITOR` are commonly exported from interactive-only + /// configuration (`.zshrc`), which a plain login shell never reads. + static func wrapInteractive(_ command: String, userShell: String?) -> String { + wrap(command, userShell: userShell, interactive: true) } /// Wraps a command in a supported login shell while preserving the command verbatim. @@ -32,6 +45,14 @@ enum WorkspaceInitialCommandLoginShell { /// unconditionally after profiles run; a duplicate PATH entry is harmless and /// matches what interactive shell integration already produces. static func wrap(_ command: String, userShell: String?) -> String { + wrap(command, userShell: userShell, interactive: false) + } + + private static func wrap( + _ command: String, + userShell: String?, + interactive: Bool + ) -> String { var shellPath: String if let userShell, userShell.hasPrefix("/") { shellPath = userShell @@ -59,7 +80,8 @@ enum WorkspaceInitialCommandLoginShell { """ } - return "\(shellSingleQuoted(shellPath)) -lc \(shellSingleQuoted(payload))" + let flags = interactive ? "-ilc" : "-lc" + return "\(shellSingleQuoted(shellPath)) \(flags) \(shellSingleQuoted(payload))" } private static func shellSingleQuoted(_ value: String) -> String { diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index dbe7e8191fa..5c6370b8f59 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -1,5 +1,6 @@ import XCTest import CmuxTerminal +import CmuxWorkspaces import Bonsplit import AppKit import SwiftUI @@ -348,4 +349,266 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertNil(panelSnapshot.terminal?.tmuxStartCommand) XCTAssertNil(Workspace.restorableTmuxStartCommand(genericCommand)) } + + // MARK: - Terminal editor handoff + + private func makeScratchFile(contents: String = "") throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-terminal-editor-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directoryURL) } + let fileURL = directoryURL.appendingPathComponent("notes.txt") + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + private func terminalPanels(in workspace: Workspace) -> [TerminalPanel] { + workspace.panels.values.compactMap { $0 as? TerminalPanel } + } + + func testTerminalEditorSurfaceRunsEditorInTheFileDirectoryAndClosesOnExit() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile() + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + + let editorPanel = try XCTUnwrap(workspace.openTerminalEditorSurface( + forPanelId: previewPanel.id, + userShell: "/bin/zsh", + resolvedEditor: nil, + placement: .endOfTabStrip + )) + + let startupCommand = try XCTUnwrap(editorPanel.surface.debugInitialCommand()) + XCTAssertTrue( + startupCommand.hasPrefix("'/bin/zsh' -ilc "), + "The editor must resolve in an interactive login shell, since a GUI app " + + "process has none of the user's shell configuration: \(startupCommand)" + ) + XCTAssertTrue(startupCommand.contains("exec ${VISUAL:-${EDITOR:-vi}}"), startupCommand) + XCTAssertTrue(startupCommand.contains(fileURL.lastPathComponent), startupCommand) + XCTAssertEqual( + editorPanel.requestedWorkingDirectory, + fileURL.deletingLastPathComponent().path + ) + XCTAssertFalse( + editorPanel.surface.debugWaitAfterCommand(), + "The editor surface must close when the editor exits, not linger on a spent PTY" + ) + XCTAssertEqual(workspace.paneId(forPanelId: editorPanel.id)?.id, paneId.id) + } + + func testTerminalEditorSurfaceOpensForMarkdownSurfaces() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile(contents: "# Notes\n") + let markdownPanel = try XCTUnwrap(workspace.newMarkdownSurface( + inPane: paneId, + filePath: fileURL.path + )) + + let editorPanel = try XCTUnwrap(workspace.openTerminalEditorSurface( + forPanelId: markdownPanel.id, + userShell: "/opt/homebrew/bin/fish", + resolvedEditor: nil, + placement: .endOfTabStrip + )) + + let startupCommand = try XCTUnwrap(editorPanel.surface.debugInitialCommand()) + XCTAssertTrue(startupCommand.hasPrefix("'/opt/homebrew/bin/fish' -ilc "), startupCommand) + XCTAssertTrue(startupCommand.contains("set -l cmux_editor $VISUAL"), startupCommand) + } + + func testEditorOpensImmediatelyToTheRightOfTheFileSurface() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile() + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + let previewIndex = try XCTUnwrap(workspace.indexInPane(forPanelId: previewPanel.id)) + + let editorPanel = try XCTUnwrap(workspace.openTerminalEditorSurface( + forPanelId: previewPanel.id, + userShell: "/bin/zsh", + resolvedEditor: nil, + placement: .afterSource + )) + + XCTAssertEqual( + workspace.indexInPane(forPanelId: editorPanel.id), + previewIndex + 1, + "The editor opens to the right of the file surface" + ) + XCTAssertEqual(workspace.indexInPane(forPanelId: previewPanel.id), previewIndex) + XCTAssertNotNil( + workspace.panels[previewPanel.id], + "The preview must survive so quitting the editor returns to it, and so the " + + "editor is never the workspace's last panel" + ) + } + + func testAResolvedEditorLaunchesWithNoShellAtAll() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile() + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + + let editorPanel = try XCTUnwrap(workspace.openTerminalEditorSurface( + forPanelId: previewPanel.id, + userShell: "/bin/zsh", + resolvedEditor: ResolvedTerminalEditor( + executablePath: "/opt/homebrew/bin/nvim", + arguments: [], + pathEnvironment: "/opt/homebrew/bin:/usr/bin" + ), + placement: .afterSource + )) + + let startupCommand = try XCTUnwrap(editorPanel.surface.debugInitialCommand()) + XCTAssertEqual(startupCommand, "'/opt/homebrew/bin/nvim' '\(fileURL.path)'") + XCTAssertFalse( + startupCommand.hasPrefix("exec "), + "Ghostty prepends `exec -l`; a leading exec makes it run a program named exec" + ) + XCTAssertFalse(startupCommand.contains("zsh"), startupCommand) + XCTAssertEqual( + editorPanel.surface.startupEnvironmentValue("PATH"), + "/opt/homebrew/bin:/usr/bin", + "The editor needs the shell's PATH to find language servers it spawns" + ) + } + + func testEndOfTabStripPlacementKeepsThePreviewOpen() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile() + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + let tabCountBefore = workspace.bonsplitController.tabs(inPane: paneId).count + + _ = try XCTUnwrap(workspace.openTerminalEditorSurface( + forPanelId: previewPanel.id, + userShell: "/bin/zsh", + resolvedEditor: nil, + placement: .endOfTabStrip + )) + + XCTAssertNotNil(workspace.panels[previewPanel.id]) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, tabCountBefore + 1) + } + + func testPlacementSettingDefaultsToAfterSource() { + XCTAssertEqual(TerminalEditorPlacementSettings.defaultValue, .afterSource) + XCTAssertEqual(TerminalEditorPlacementSettings.placement(forRawValue: nil), .afterSource) + XCTAssertEqual(TerminalEditorPlacementSettings.placement(forRawValue: "nonsense"), .afterSource) + XCTAssertEqual(TerminalEditorPlacementSettings.placement(forRawValue: "endOfTabStrip"), .endOfTabStrip) + } + + func testStartupCommandSurfacesStillWaitAfterCommandByDefault() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + + let panel = try XCTUnwrap(workspace.newTerminalSurface( + inPane: paneId, + focus: false, + initialCommand: "sleep 600" + )) + + XCTAssertTrue( + panel.surface.debugWaitAfterCommand(), + "A failing startup command must stay readable instead of respawning a login shell" + ) + } + + func testDirtyPreviewCancelsTheHandoffWhenTheSavePromptIsDeclined() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile(contents: "on disk") + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + previewPanel.updateTextContent("unsaved edit") + XCTAssertTrue(previewPanel.isDirty) + let terminalCountBefore = terminalPanels(in: workspace).count + + previewPanel.openInTerminalEditor(confirmSaveBeforeOpen: { false }) + + XCTAssertEqual(terminalPanels(in: workspace).count, terminalCountBefore) + XCTAssertEqual(try String(contentsOf: fileURL, encoding: .utf8), "on disk") + } + + func testDirtyPreviewSavesBeforeOpeningWhenThePromptIsAccepted() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let fileURL = try makeScratchFile(contents: "on disk") + let previewPanel = try XCTUnwrap(workspace.newFilePreviewSurface( + inPane: paneId, + filePath: fileURL.path + )) + previewPanel.updateTextContent("unsaved edit") + let terminalCountBefore = terminalPanels(in: workspace).count + + previewPanel.openInTerminalEditor(confirmSaveBeforeOpen: { true }) + + let deadline = Date.now.addingTimeInterval(5.0) + while terminalPanels(in: workspace).count == terminalCountBefore, Date.now < deadline { + _ = RunLoop.current.run( + mode: .default, + before: min(Date.now.addingTimeInterval(0.01), deadline) + ) + } + + XCTAssertEqual(terminalPanels(in: workspace).count, terminalCountBefore + 1) + XCTAssertEqual( + try String(contentsOf: fileURL, encoding: .utf8), + "unsaved edit", + "The editor reads from disk, so the confirmed save must land first" + ) + } + + func testTerminalEditorShortcutResolvesThroughTheRealLookup() throws { + let shortcut = KeyboardShortcutSettings.shortcut(for: .openInTerminalEditor) + + XCTAssertFalse( + shortcut.isUnbound, + "Defaults resolve through CmuxSettings.ShortcutAction; an action missing there " + + "is unbound no matter what Action.defaultShortcut returns" + ) + XCTAssertEqual(shortcut.key, "e") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.control) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + } + + func testDockHostedPreviewsHideTheTerminalEditorAction() throws { + let fileURL = try makeScratchFile() + let workspace = Workspace() + let dock = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) + let panel = FilePreviewPanel( + workspaceId: workspace.id, + filePath: fileURL.path, + startFileWatcher: false + ) + + panel.bindTabMetadata(to: dock) + XCTAssertFalse( + panel.canOpenInTerminalEditor, + "The Dock owns no terminal-capable surface tree" + ) + + panel.bindTabMetadata(to: workspace) + XCTAssertTrue(panel.canOpenInTerminalEditor) + } } diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 6ac2c410a5e..06ec44f0fab 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1538,6 +1538,13 @@ "default": false, "description": "Wrap long lines at the editor's right edge instead of scrolling horizontally.", "descriptionKey": "schemaDescriptions.fileEditor.wordWrap" + }, + "terminalEditorPlacement": { + "type": "string", + "enum": ["afterSource", "endOfTabStrip"], + "default": "afterSource", + "description": "Where \"Open in Terminal Editor\" puts the editor. `afterSource` opens the editor immediately to the right of the file surface, which stays open so quitting the editor lands back on it. `endOfTabStrip` opens the editor at the end of the pane's tab strip.", + "descriptionKey": "schemaDescriptions.fileEditor.terminalEditorPlacement" } } }, diff --git a/web/messages/en.json b/web/messages/en.json index 550c604a24f..9661eb78460 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1867,7 +1867,8 @@ "snappingEnabled": "Snap pane drags and resizes to neighbor edges and the pane gap. Hold Command to suspend snapping for one gesture." }, "fileEditor": { - "wordWrap": "Wrap long lines at the editor's right edge instead of scrolling horizontally." + "wordWrap": "Wrap long lines at the editor's right edge instead of scrolling horizontally.", + "terminalEditorPlacement": "Where \"Open in Terminal Editor\" puts the editor. `afterSource` opens the editor immediately to the right of the file surface, which stays open so quitting the editor lands back on it. `endOfTabStrip` opens the editor at the end of the pane's tab strip." }, "sidebar": { "notificationMessageLineLimit": "Maximum lines shown for the latest notification below each workspace title.",