Fix Shell Integration

This commit is contained in:
Kaede Akino committed 2026-09-01 10:47:13 +08:00
1 parent 1449bd62ac
commit 2f96b5a75a
17 files changed
+504 -155

No files matched your search

+4 -2
View File
@@ -53,8 +53,10 @@ connection registry and is never persisted onto domain records.
SSH command history is created only after an authenticated shell-integration
start event. A matching completion event enriches that row with exit status and
locally measured duration; both remain optional so interrupted commands and
history migrated from older versions preserve an honest unknown outcome.
the duration measured by the remote shell; both remain optional so interrupted
commands, old protocol events, and history migrated from older versions preserve
an honest unknown outcome. Duration is never inferred from client receive time,
because SSH buffering can deliver start and completion events together.
Feature views receive aggregate-facing Application Models rather than a generic
store API: `HostLibraryModel`, `CredentialLibraryModel`, `SnippetLibraryModel`,
-54
View File
@@ -1,5 +1,4 @@
import AppKit
import SwiftUI
/// A terminal title is authored by the remote host. Keep it on one printable
/// line before allowing it into AppKit's global menu structure.
@@ -150,56 +149,3 @@ final class RemoteWindowTitleMenuCoordinator {
titleMenuItem = nil
}
}
/// A dedicated observation boundary for the nested SSH session. Reading the
/// session title here makes Observation invalidate this small reporter whenever
/// Ghostty publishes another OSC 0/2 value, without rebuilding `RootView`.
struct ActiveRemoteWindowTitleReporter: View {
let sshSession: SSHSessionModel?
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var isEnabled =
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
var body: some View {
RemoteWindowTitleMenuBridge(
title: isEnabled ? sshSession?.menuBarWindowTitle : nil
)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
}
private struct RemoteWindowTitleMenuBridge: NSViewRepresentable {
let title: String?
@MainActor
final class ReportingView: NSView {
let token = UUID()
var reportedTitle: String?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
report()
}
func report() {
RemoteWindowTitleMenuCoordinator.shared.report(
title: reportedTitle, for: window, token: token
)
}
}
func makeNSView(context _: Context) -> ReportingView {
let view = ReportingView(frame: .zero)
view.reportedTitle = title
return view
}
func updateNSView(_ view: ReportingView, context _: Context) {
view.reportedTitle = title
view.report()
}
static func dismantleNSView(_ view: ReportingView, coordinator _: ()) {
RemoteWindowTitleMenuCoordinator.shared.withdraw(token: view.token)
}
}
+73 -11
View File
@@ -15,6 +15,8 @@ struct RootView: View {
@State private var layoutWidth = Metrics.Window.defaultWidth
@State private var automaticQuickOpenReady = false
@AppStorage(SettingsKey.statusBarExpanded) private var isStatusBarExpanded = true
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var showsRemoteWindowTitle =
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
init(window: WindowModel = WindowModel()) {
_window = State(initialValue: window)
@@ -54,13 +56,12 @@ struct RootView: View {
)
}
.navigationSplitViewStyle(.balanced)
// Kitty-style macOS menu-bar title. This tiny child observes only the
// focused SSH session, so OSC 0/2 changes do not invalidate the full
// window hierarchy.
.background {
ActiveRemoteWindowTitleReporter(sshSession: window.focusedSSHSession)
WindowToolbarSearchHost()
WindowActivityReader {
WindowActivityReader(
windowModel: window,
showsRemoteTitle: showsRemoteWindowTitle
) {
app.onboarding.windowBecameActive(window)
considerOnboarding()
}
@@ -386,41 +387,77 @@ private struct FullHeightWindowContent: NSViewRepresentable {
}
}
/// Reports which SwiftUI scene owns AppKit's key window so app-wide onboarding
/// previews and automatic steps never appear in two windows at once.
/// Owns window-scoped AppKit integration: key-window onboarding and the remote
/// title report. Keeping both on this persistent bridge avoids routing title
/// changes through whichever Workspace or pane SwiftUI currently renders.
private struct WindowActivityReader: NSViewRepresentable {
let windowModel: WindowModel
let showsRemoteTitle: Bool
let becameKey: () -> Void
func makeNSView(context _: Context) -> WindowActivityBridgeView {
WindowActivityBridgeView(becameKey: becameKey)
WindowActivityBridgeView(
windowModel: windowModel, showsRemoteTitle: showsRemoteTitle,
becameKey: becameKey
)
}
func updateNSView(_ view: WindowActivityBridgeView, context _: Context) {
view.windowModel = windowModel
view.showsRemoteTitle = showsRemoteTitle
view.becameKey = becameKey
view.observeWindow()
view.reportCurrentRemoteTitle()
}
static func dismantleNSView(_ view: WindowActivityBridgeView, coordinator _: ()) {
if view.windowModel?.remoteWindowTitleSink === view {
view.windowModel?.remoteWindowTitleSink = nil
}
RemoteWindowTitleMenuCoordinator.shared.withdraw(token: view.titleReportToken)
}
}
private final class WindowActivityBridgeView: NSView {
private final class WindowActivityBridgeView: NSView, WindowRemoteTitleSink {
let titleReportToken = UUID()
weak var windowModel: WindowModel? {
didSet {
guard windowModel !== oldValue else { return }
if oldValue?.remoteWindowTitleSink === self {
oldValue?.remoteWindowTitleSink = nil
}
windowModel?.remoteWindowTitleSink = self
}
}
var showsRemoteTitle: Bool
var becameKey: () -> Void
private var observer: (any NSObjectProtocol)?
private weak var observedWindow: NSWindow?
private var pendingRemoteTitleReport: Task<Void, Never>?
init(becameKey: @escaping () -> Void) {
init(
windowModel: WindowModel, showsRemoteTitle: Bool,
becameKey: @escaping () -> Void
) {
self.windowModel = windowModel
self.showsRemoteTitle = showsRemoteTitle
self.becameKey = becameKey
super.init(frame: .zero)
windowModel.remoteWindowTitleSink = self
}
@available(*, unavailable)
required init?(coder _: NSCoder) { nil }
deinit {
pendingRemoteTitleReport?.cancel()
if let observer { NotificationCenter.default.removeObserver(observer) }
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
observeWindow()
reportCurrentRemoteTitle()
}
func observeWindow() {
@@ -436,10 +473,35 @@ private final class WindowActivityBridgeView: NSView {
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.becameKey() }
MainActor.assumeIsolated {
self?.becameKey()
self?.reportCurrentRemoteTitle()
}
}
if window.isKeyWindow { becameKey() }
}
private func reportRemoteTitle(_ title: String?) {
guard let observedWindow else { return }
RemoteWindowTitleMenuCoordinator.shared.report(
title: title, for: observedWindow, token: titleReportToken
)
}
func reportCurrentRemoteTitle() {
reportRemoteTitle(
showsRemoteTitle ? windowModel?.focusedSSHSession?.menuBarWindowTitle : nil
)
}
func windowRemoteTitleDidChange(_ title: String?) {
pendingRemoteTitleReport?.cancel()
pendingRemoteTitleReport = Task { @MainActor [weak self] in
await Task.yield()
guard !Task.isCancelled, let self else { return }
self.reportRemoteTitle(self.showsRemoteTitle ? title : nil)
}
}
}
private final class WindowContentBridgeView: NSView {
@@ -56,13 +56,13 @@ final class CommandHistoryModel {
@discardableResult
func recordCompletion(
for id: CommandHistoryEntry.ID, hostID: Host.ID,
exitStatus: Int32, duration: TimeInterval
exitStatus: Int32, duration: TimeInterval?
) -> CommandHistoryEntry? {
do {
guard var entry = try repository.entries(for: hostID).first(where: { $0.id == id })
else { return nil }
entry.exitStatus = exitStatus
entry.duration = max(0, duration)
entry.duration = duration.map { max(0, $0) }
try repository.update(entry)
storageError = nil
revision &+= 1
+11 -12
View File
@@ -498,10 +498,11 @@ private struct AssistantConversationList: View {
}
private func commitRename(_ conversation: ConversationModel) {
guard renamingID == conversation.id else { return }
let value = renameTitle.trimmingCharacters(in: .whitespacesAndNewlines)
cancelRename()
guard !value.isEmpty else { return }
window.renameAssistantConversation(conversation.id, to: value)
cancelRename()
}
private func cancelRename() {
@@ -1146,7 +1147,6 @@ struct AssistantConversationRow: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.locale) private var locale
@State private var hovering = false
@FocusState private var titleFocused: Bool
@ViewBuilder
var body: some View {
@@ -1186,11 +1186,6 @@ struct AssistantConversationRow: View {
.focusable(false)
.focusEffectDisabled()
.accessibilityAddTraits(isSelected ? .isSelected : [])
.onChange(of: isRenaming, initial: true) {
if isRenaming {
DispatchQueue.main.async { titleFocused = true }
}
}
}
private var contextMenu: [NativeMenuEntry] {
@@ -1247,11 +1242,15 @@ struct AssistantConversationRow: View {
unselected: AnyShapeStyle(.secondary)
))
if isRenaming {
TextField("Conversation Name", text: $renameTitle)
.textFieldStyle(.plain)
.focused($titleFocused)
.onSubmit(onCommitRename)
.onExitCommand(perform: onCancelRename)
SidebarInlineRenameField(
text: $renameTitle,
prompt: String(localized: "Conversation Name"),
isSelected: isSelected,
controlActiveState: controlActiveState,
onSubmit: onCommitRename,
onCancel: onCancelRename
)
.frame(height: 18)
} else {
Text(conversation.derivedTitle).lineLimit(1)
}
+18 -16
View File
@@ -77,6 +77,7 @@ protocol SSHSessionInputRouting: AnyObject {
didChangeWorkingDirectory workingDirectory: String?
)
func sshSessionDidChangeRemoteSystemInfo(_ sshSession: SSHSessionModel)
func sshSessionDidChangeRemoteWindowTitle(_ sshSession: SSHSessionModel)
func closeSSHSessionPane(afterExit sshSession: SSHSessionModel)
}
@@ -131,6 +132,9 @@ final class SSHSessionModel: Identifiable {
var status: ConnectionStatus = .disconnected {
didSet {
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
if status.isLive != oldValue.isLive {
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
}
}
}
var workingDirectory: String? {
@@ -179,7 +183,12 @@ final class SSHSessionModel: Identifiable {
/// Ghostty owns parsing and publishes the value through `TerminalViewState`.
/// Keep a session-scoped copy here so reconnecting can discard a title from
/// the old remote shell even though the long-lived terminal surface survives.
private(set) var remoteWindowTitle: String?
private(set) var remoteWindowTitle: String? {
didSet {
guard remoteWindowTitle != oldValue else { return }
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
}
}
var menuBarWindowTitle: String? {
status.isLive ? remoteWindowTitle : nil
}
@@ -192,7 +201,6 @@ final class SSHSessionModel: Identifiable {
private(set) var isShellPromptReady = false
private struct ActiveShellCommand {
var historyID: CommandHistoryEntry.ID?
var startedAtNanoseconds: UInt64
}
private var activeShellCommand: ActiveShellCommand?
private(set) var commandGeneratorRevision: UInt64 = 0
@@ -1284,11 +1292,10 @@ final class SSHSessionModel: Identifiable {
isShellPromptReady = false
invalidateCommandGeneration()
activeShellCommand = ActiveShellCommand(
historyID: recordExecutedCommand(command),
startedAtNanoseconds: DispatchTime.now().uptimeNanoseconds
historyID: recordExecutedCommand(command)
)
case .commandFinished(let status):
recordCommandCompletion(exitStatus: status)
case .commandFinished(let status, let duration):
recordCommandCompletion(exitStatus: status, reportedDuration: duration)
case .draft(let draft):
completeDraftRequest(with: draft)
}
@@ -1328,27 +1335,22 @@ final class SSHSessionModel: Identifiable {
}
}
private func recordCommandCompletion(exitStatus: Int32) {
private func recordCommandCompletion(
exitStatus: Int32, reportedDuration: TimeInterval?
) {
guard let active = activeShellCommand else { return }
activeShellCommand = nil
guard let historyID = active.historyID else { return }
let now = DispatchTime.now().uptimeNanoseconds
let elapsed =
if now >= active.startedAtNanoseconds {
Double(now - active.startedAtNanoseconds) / 1_000_000_000
} else {
0.0
}
if statusStore?.host(hostID) != nil {
commandHistoryStore?.recordCompletion(
for: historyID, hostID: hostID,
exitStatus: exitStatus, duration: elapsed
exitStatus: exitStatus, duration: reportedDuration
)
} else if let index = transientCommandHistory.firstIndex(where: {
$0.id == historyID
}) {
transientCommandHistory[index].exitStatus = exitStatus
transientCommandHistory[index].duration = elapsed
transientCommandHistory[index].duration = reportedDuration
}
}
+2 -6
View File
@@ -333,13 +333,9 @@ final class Pane: Identifiable {
sftpSession.displayedPath
case .serialSession(let serialSession):
serialSession.subtitle
case _ where customTitle != nil:
// A renamed pane shows its name and nothing under it: the subtitle
// is the derived detail, and pairing it with a name the user chose
// reads as a correction. SFTP is the exception above: its live
// path is navigation state, not an alternate name.
nil
case .sshSession(let sshSession):
// Like SFTP navigation, the shell's live directory remains useful
// context after the user gives the pane a stable name.
sshSession.status.isLive ? sshSession.displayedWorkingDirectory : nil
default:
nil
@@ -5,6 +5,10 @@ enum SessionOpenPlacement: Equatable {
case currentWorkspace
}
protocol WindowRemoteTitleSink: AnyObject {
func windowRemoteTitleDidChange(_ title: String?)
}
/// The state of one window: its workspaces, selection, and shared left sidebar.
///
/// One of these per window rather than one per app, so two windows can hold
@@ -22,11 +26,15 @@ final class WindowModel {
/// Workspaces. This is the single source of truth for workspace selection.
var selectedDestination: WindowDestination = .vault {
didSet {
if selectedDestination != oldValue {
publishRemoteWindowTitle()
}
if selectedDestination != .assistant {
assistantReturnDestination = selectedDestination
}
}
}
@ObservationIgnored weak var remoteWindowTitleSink: (any WindowRemoteTitleSink)?
/// Where Navigator returns after the Assistant temporarily replaces the
/// detail surface. Assistant has no row inside the Navigator that could
/// retain the previous selection for us.
@@ -1365,6 +1373,17 @@ extension WindowModel: WorkspaceLifecycleRouting {
guard selectedWorkspace === workspace else { return }
refreshSessionContext()
}
func workspaceRemoteWindowTitleDidChange(_ workspace: Workspace) {
guard selectedWorkspace === workspace else { return }
publishRemoteWindowTitle()
}
private func publishRemoteWindowTitle() {
remoteWindowTitleSink?.windowRemoteTitleDidChange(
focusedSSHSession?.menuBarWindowTitle
)
}
}
// MARK: - Dragging
@@ -3,6 +3,7 @@ import Foundation
protocol WorkspaceLifecycleRouting: AnyObject {
func workspace(_ workspace: Workspace, closePaneAfterSSHSessionExit paneID: Pane.ID)
func workspaceContextDidChange(_ workspace: Workspace)
func workspaceRemoteWindowTitleDidChange(_ workspace: Workspace)
}
nonisolated enum CommandGeneratorState: Equatable, Sendable {
@@ -40,6 +41,7 @@ final class Workspace: Identifiable {
guard focusedPaneID != oldValue else { return }
cancelCommandGeneration()
lifecycleRouter?.workspaceContextDidChange(self)
lifecycleRouter?.workspaceRemoteWindowTitleDidChange(self)
}
}
/// `nil` means the title tracks the focused pane.
@@ -461,6 +463,11 @@ extension Workspace: SSHSessionInputRouting {
lifecycleRouter?.workspaceContextDidChange(self)
}
func sshSessionDidChangeRemoteWindowTitle(_ sshSession: SSHSessionModel) {
guard focusedPane.sshSession === sshSession else { return }
lifecycleRouter?.workspaceRemoteWindowTitleDidChange(self)
}
/// Shell EOF is a successful completion, so it closes the shell's pane.
/// The window owns the last-pane case because removing it also removes and
/// reselects workspaces. Standalone Workspaces still handle split panes in tests
@@ -310,8 +310,10 @@ struct WorkspaceNavigatorView: View {
}
private func commitWorkspaceRename(_ workspace: Workspace) {
window.rename(workspace.id, to: workspaceRenameTitle)
guard renamingWorkspaceID == workspace.id else { return }
let title = workspaceRenameTitle
cancelWorkspaceRename()
window.rename(workspace.id, to: title)
}
private func cancelWorkspaceRename() {
@@ -363,9 +365,20 @@ private struct WorkspaceNavigatorRow: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.locale) private var locale
@State private var hovering = false
@FocusState private var titleFocused: Bool
@ViewBuilder
var body: some View {
if isRenaming {
row
} else {
row.onDrag {
window.dragging = .workspace(workspace.id)
return NSItemProvider(object: workspace.id.uuidString as NSString)
}
}
}
private var row: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: workspace.symbol)
.foregroundStyle(SidebarSelectionStyle.foreground(
@@ -376,11 +389,15 @@ private struct WorkspaceNavigatorRow: View {
.frame(width: Metrics.Sidebar.rowIcon)
if isRenaming {
TextField("Workspace Name", text: $renameTitle)
.textFieldStyle(.plain)
.focused($titleFocused)
.onSubmit(onCommitRename)
.onExitCommand(perform: onCancelRename)
SidebarInlineRenameField(
text: $renameTitle,
prompt: String(localized: "Workspace Name"),
isSelected: isSelected,
controlActiveState: controlActiveState,
onSubmit: onCommitRename,
onCancel: onCancelRename
)
.frame(height: 18)
} else {
VStack(alignment: .leading, spacing: 1) {
Text(workspace.title(in: app.hostLibrary, locale: locale))
@@ -405,25 +422,21 @@ private struct WorkspaceNavigatorRow: View {
.contentShape(.rect)
.termindSidebarRow(isSelected: isSelected) { contextMenu }
.onTapGesture {
guard !isRenaming else { return }
window.dragging = nil
window.select(workspace.id)
}
.simultaneousGesture(TapGesture(count: 2).onEnded(onBeginRename))
.onHover { hovering = $0 }
.onDrag {
window.dragging = .workspace(workspace.id)
return NSItemProvider(object: workspace.id.uuidString as NSString)
}
// Match Assistant's inline rename treatment: the row itself must not
// draw a second keyboard-focus rectangle around the plain field.
.focusable(false)
.focusEffectDisabled()
.help(Text(workspace.title(in: app.hostLibrary, locale: locale)))
.accessibilityElement(children: .combine)
.accessibilityLabel(
Text(workspace.title(in: app.hostLibrary, locale: locale))
)
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
.onChange(of: isRenaming, initial: true) {
if isRenaming { DispatchQueue.main.async { titleFocused = true } }
}
}
@ViewBuilder
@@ -342,13 +342,13 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
switch shell {
case "bash":
command =
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then __termind_integration_token=\"\(token)\"; __termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; __termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; __termind_report_prompt() { builtin local status=$?; if [[ -n \"${__termind_command_active:-}\" ]]; then builtin printf '\\e]777;TermindCommandFinished=\(token);%s\\a' \"$status\"; builtin unset __termind_command_active; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$status\"; }; __termind_report_command() { builtin local status=$? current=$HISTCMD cmd=\"${1:-}\"; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then if (( current <= ${__termind_last_histcmd:-0} )); then builtin unset __termind_busy; return \"$status\"; fi; cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; __termind_last_histcmd=$current; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; fi; builtin unset __termind_busy; return \"$status\"; }; __termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; }; __termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; }; __termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; }; for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done; builtin unset __termind_map; __termind_last_histcmd=$HISTCMD; if [[ -n \"${bash_preexec_imported:-}\" ]]; then [[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command); [[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\"); else __termind_debug_trap=\"$(__termind_get_trap DEBUG)\"; if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command' DEBUG; else builtin trap '__termind_report_command' DEBUG; fi; fi; if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then [[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_report_prompt \"* ]] || PROMPT_COMMAND=(__termind_report_prompt \"${PROMPT_COMMAND[@]}\"); elif [[ \"${PROMPT_COMMAND:-}\" != __termind_report_prompt* ]]; then PROMPT_COMMAND=\"__termind_report_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"; fi; fi; fi; \(marker); __termind_report_prompt\n"
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then __termind_integration_token=\"\(token)\"; __termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; __termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; __termind_report_prompt() { builtin local status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; __termind_command_ready=1; return \"$status\"; }; __termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; }; __termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; }; __termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; }; __termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; }; for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done; builtin unset __termind_map; if [[ -n \"${bash_preexec_imported:-}\" ]]; then [[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command); [[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\"); else __termind_debug_trap=\"$(__termind_get_trap DEBUG)\"; if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command' DEBUG; else builtin trap '__termind_report_command' DEBUG; fi; fi; if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then [[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_report_prompt \"* ]] || PROMPT_COMMAND=(__termind_report_prompt \"${PROMPT_COMMAND[@]}\"); elif [[ \"${PROMPT_COMMAND:-}\" != __termind_report_prompt* ]]; then PROMPT_COMMAND=\"__termind_report_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"; fi; fi; fi; \(marker); __termind_report_prompt\n"
case "zsh":
command =
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local status=$?; if [[ -n \"${__termind_command_active:-}\" ]]; then builtin printf '\\e]777;TermindCommandFinished=\(token);%s\\a' \"$status\"; unset __termind_command_active; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n"
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n"
case "fish":
command =
" if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s\\a' $command_status; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n"
" if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n"
default:
return nil
}
@@ -364,7 +364,7 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
nonisolated enum Event: Sendable, Equatable {
case commandStarted(String)
case commandFinished(Int32)
case commandFinished(Int32, duration: TimeInterval?)
case promptReady
case draft(String)
}
@@ -372,10 +372,6 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
nonisolated struct Update: Sendable {
var visibleOutput = Data()
var events: [Event] = []
var commands: [String] = []
var commandExitStatuses: [Int32] = []
var promptReady = false
var drafts: [String] = []
}
private let commandPrefix: Data
@@ -420,23 +416,24 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
switch match.kind {
case .command:
if let command = Self.decodePercentEncoded(payload), !command.isEmpty {
update.commands.append(command)
update.events.append(.commandStarted(command))
}
case .commandFinished:
if let text = String(data: payload, encoding: .utf8),
let status = Int32(text), (0...255).contains(status)
let fields = String(decoding: payload, as: UTF8.self)
.split(separator: ";", omittingEmptySubsequences: false)
if let statusText = fields.first, fields.count <= 2,
let status = Int32(statusText), (0...255).contains(status)
{
update.commandExitStatuses.append(status)
update.events.append(.commandFinished(status))
let duration = fields.count == 2
? Self.duration(milliseconds: fields[1])
: nil
update.events.append(.commandFinished(status, duration: duration))
}
case .prompt:
guard payload.isEmpty else { break }
update.promptReady = true
update.events.append(.promptReady)
case .draft:
if let draft = Self.decodePercentEncoded(payload) {
update.drafts.append(draft)
update.events.append(.draft(draft))
}
}
@@ -504,6 +501,13 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
default: nil
}
}
private static func duration(milliseconds text: Substring) -> TimeInterval? {
guard let milliseconds = UInt64(text), milliseconds <= 31_536_000_000 else {
return nil
}
return Double(milliseconds) / 1_000
}
}
nonisolated enum SSHShellIntegrationEvent: Sendable {
@@ -512,7 +516,7 @@ nonisolated enum SSHShellIntegrationEvent: Sendable {
case homeResolutionUnavailable
case promptReady
case commandStarted(String)
case commandFinished(Int32)
case commandFinished(Int32, duration: TimeInterval?)
case draft(String)
}
@@ -1189,8 +1193,8 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
switch event {
case .commandStarted(let command):
shellIntegrationHandler(.commandStarted(command))
case .commandFinished(let status):
shellIntegrationHandler(.commandFinished(status))
case .commandFinished(let status, let duration):
shellIntegrationHandler(.commandFinished(status, duration: duration))
case .promptReady:
shellIntegrationHandler(.promptReady)
case .draft(let draft):
+208
View File
@@ -1,5 +1,213 @@
import AppKit
import SwiftUI
/// A transparent native field for inline renaming inside emphasized sidebar
/// rows. Return commits, Escape cancels, and a pointer exit commits only after
/// the original click reaches its destination.
struct SidebarInlineRenameField: NSViewRepresentable {
@Binding var text: String
let prompt: String
let isSelected: Bool
let controlActiveState: ControlActiveState
let onSubmit: () -> Void
let onCancel: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, onSubmit: onSubmit, onCancel: onCancel)
}
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.delegate = context.coordinator
field.target = context.coordinator
field.action = #selector(Coordinator.submit)
field.stringValue = text
field.isBezeled = false
field.isBordered = false
field.drawsBackground = false
field.focusRingType = .none
field.lineBreakMode = .byTruncatingTail
field.cell?.usesSingleLineMode = true
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
configure(field)
context.coordinator.requestFocus(for: field)
return field
}
func updateNSView(_ field: NSTextField, context _: Context) {
if field.stringValue != text {
field.stringValue = text
}
configure(field)
}
static func dismantleNSView(_: NSTextField, coordinator: Coordinator) {
coordinator.stopObservingPointerExit()
}
private func configure(_ field: NSTextField) {
let font = NSFont.systemFont(
ofSize: NSFont.systemFontSize,
weight: isSelected ? .semibold : .regular
)
field.font = font
field.textColor = textColor
field.placeholderAttributedString = NSAttributedString(
string: prompt,
attributes: [.font: font, .foregroundColor: placeholderColor]
)
field.currentEditor()?.textColor = textColor
field.currentEditor()?.drawsBackground = false
}
private var textColor: NSColor {
guard isSelected else { return .labelColor }
return controlActiveState == .key
? .alternateSelectedControlTextColor
: .controlAccentColor
}
private var placeholderColor: NSColor {
guard isSelected else { return .secondaryLabelColor }
return textColor.withAlphaComponent(0.72)
}
@MainActor
final class Coordinator: NSObject, NSTextFieldDelegate {
private var text: Binding<String>
private let onSubmit: () -> Void
private let onCancel: () -> Void
private var didFinish = false
private var pointerMonitor: Any?
private var windowResignObserver: (any NSObjectProtocol)?
private var commitsAfterMouseUp = false
init(
text: Binding<String>,
onSubmit: @escaping () -> Void,
onCancel: @escaping () -> Void
) {
self.text = text
self.onSubmit = onSubmit
self.onCancel = onCancel
}
func requestFocus(for field: NSTextField) {
DispatchQueue.main.async {
guard !self.didFinish, let window = field.window else { return }
window.makeFirstResponder(field)
field.currentEditor()?.drawsBackground = false
field.currentEditor()?.selectAll(nil)
self.observePointerExit(from: field)
}
}
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
text.wrappedValue = field.stringValue
}
func controlTextDidEndEditing(_: Notification) {
guard !didFinish, !commitsAfterMouseUp else { return }
switch NSApp.currentEvent?.type {
case .leftMouseDown, .leftMouseUp,
.rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp:
break
default:
finish(with: onSubmit)
}
}
func control(
_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
switch commandSelector {
case #selector(NSResponder.insertNewline(_:)):
finish(with: onSubmit)
return true
case #selector(NSResponder.cancelOperation(_:)):
finish(with: onCancel)
return true
default:
return false
}
}
@objc func submit() {
finish(with: onSubmit)
}
private func finish(with action: () -> Void) {
guard !didFinish else { return }
didFinish = true
stopObservingPointerExit()
action()
}
private func observePointerExit(from field: NSTextField) {
guard pointerMonitor == nil else { return }
pointerMonitor = NSEvent.addLocalMonitorForEvents(
matching: [
.leftMouseDown, .leftMouseUp,
.rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp,
]
) { [weak self, weak field] event in
guard let self, let field else { return event }
switch event.type {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
guard event.window === field.window else {
self.commitsAfterMouseUp = true
return event
}
let point = field.convert(event.locationInWindow, from: nil)
self.commitsAfterMouseUp = !field.bounds.contains(point)
case .leftMouseUp, .rightMouseUp, .otherMouseUp:
guard self.commitsAfterMouseUp else { return event }
self.commitsAfterMouseUp = false
self.finishAfterCurrentEvent(with: self.onSubmit)
default:
break
}
return event
}
guard let window = field.window else { return }
windowResignObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.finish(with: self.onSubmit)
}
}
}
func stopObservingPointerExit() {
if let pointerMonitor {
NSEvent.removeMonitor(pointerMonitor)
self.pointerMonitor = nil
}
if let windowResignObserver {
NotificationCenter.default.removeObserver(windowResignObserver)
self.windowResignObserver = nil
}
commitsAfterMouseUp = false
}
private func finishAfterCurrentEvent(with action: @escaping () -> Void) {
guard !didFinish else { return }
didFinish = true
stopObservingPointerExit()
DispatchQueue.main.async { action() }
}
}
}
/// One row in the left column: fixed glyph column, title, optional trailing
/// accessory, and the app's one selection highlight.
///
+13 -16
View File
@@ -179,12 +179,12 @@ struct CommandHistoryTests {
let session = SSHSessionModel(host: Host(name: "Shell", host: "shell.example"))
session.receiveShellIntegrationEvent(.commandStarted("false"))
session.receiveShellIntegrationEvent(.commandFinished(1))
session.receiveShellIntegrationEvent(.commandFinished(1, duration: 5))
let entry = try #require(session.transientCommandHistory.first)
#expect(entry.command == "false")
#expect(entry.exitStatus == 1)
#expect(try #require(entry.duration) >= 0)
#expect(entry.duration == 5)
}
private func makeContainer(_ name: String) throws -> ModelContainer {
@@ -209,9 +209,7 @@ struct RemoteCommandEventFilterTests {
let update = filter.receive(data)
#expect(update.visibleOutput == data)
#expect(update.commands.isEmpty)
#expect(!update.promptReady)
#expect(update.drafts.isEmpty)
#expect(update.events.isEmpty)
}
@Test
@@ -219,12 +217,12 @@ struct RemoteCommandEventFilterTests {
var filter = RemoteCommandEventFilter(token: "TOKEN")
let first = filter.receive(Data("before\u{1B}]777;TermindCom".utf8))
#expect(String(decoding: first.visibleOutput, as: UTF8.self) == "before")
#expect(first.commands.isEmpty)
#expect(first.events.isEmpty)
let second = filter.receive(
Data("mand=TOKEN;echo%20%E4%BD%A0%E5%A5%BD%0Asecond\u{7}after".utf8)
)
#expect(second.commands == ["echo 你好\nsecond"])
#expect(second.events == [.commandStarted("echo 你好\nsecond")])
#expect(String(decoding: second.visibleOutput, as: UTF8.self) == "after")
}
@@ -234,7 +232,7 @@ struct RemoteCommandEventFilterTests {
let data = Data("x\u{1B}]777;TermindCommand=WRONG;rm%20-rf%20/%07y".utf8)
let update = filter.receive(data)
#expect(update.commands.isEmpty)
#expect(update.events.isEmpty)
#expect(update.visibleOutput == data)
}
@@ -243,17 +241,16 @@ struct RemoteCommandEventFilterTests {
var filter = RemoteCommandEventFilter(token: "TOKEN")
let first = filter.receive(Data("before\u{1B}]777;TermindPrompt=TO".utf8))
#expect(first.visibleOutput == Data("before".utf8))
#expect(!first.promptReady)
#expect(first.events.isEmpty)
let second = filter.receive(
Data("KEN\u{7}\u{1B}]777;TermindDraft=TOKEN;echo%20%E4%BD%A0".utf8)
)
#expect(second.visibleOutput.isEmpty)
#expect(second.promptReady)
#expect(second.drafts.isEmpty)
#expect(second.events == [.promptReady])
let third = filter.receive(Data("%E5%A5%BD\u{7}after".utf8))
#expect(third.drafts == ["echo 你好"])
#expect(third.events == [.draft("echo 你好")])
#expect(third.visibleOutput == Data("after".utf8))
}
@@ -264,7 +261,7 @@ struct RemoteCommandEventFilterTests {
Data("\u{1B}]777;TermindDraft=TOKEN;\u{7}".utf8)
)
#expect(update.drafts == [""])
#expect(update.events == [.draft("")])
#expect(update.visibleOutput.isEmpty)
}
@@ -274,7 +271,7 @@ struct RemoteCommandEventFilterTests {
let update = filter.receive(
Data(
("\u{1B}]777;TermindCommand=TOKEN;first\u{7}"
+ "\u{1B}]777;TermindCommandFinished=TOKEN;3\u{7}"
+ "\u{1B}]777;TermindCommandFinished=TOKEN;3;5000\u{7}"
+ "\u{1B}]777;TermindPrompt=TOKEN\u{7}"
+ "\u{1B}]777;TermindCommand=TOKEN;second\u{7}").utf8
)
@@ -282,7 +279,8 @@ struct RemoteCommandEventFilterTests {
#expect(
update.events == [
.commandStarted("first"), .commandFinished(3), .promptReady,
.commandStarted("first"), .commandFinished(3, duration: 5),
.promptReady,
.commandStarted("second"),
]
)
@@ -314,6 +312,5 @@ struct RemoteCommandEventFilterTests {
)
#expect(update.events.isEmpty)
#expect(update.commandExitStatuses.isEmpty)
}
}
+63
View File
@@ -34,6 +34,60 @@ struct RemoteWindowTitleTests {
#expect(RemoteWindowTitle.sanitized("\n\u{7}\u{1B}[31m") == nil)
}
@Test
func titleSinkFollowsWorkspaceSelectionAndFocusedSessionOSC() {
let first = Workspace.sshSession(
host: Host(name: "First", host: "first.example")
)
let second = Workspace.sshSession(
host: Host(name: "Second", host: "second.example")
)
let window = WindowModel(workspaces: [first, second])
let firstSession = first.focusedPane.sshSession!
let secondSession = second.focusedPane.sshSession!
firstSession.status = .connected
secondSession.status = .connected
let sink = RemoteTitleSinkProbe()
window.remoteWindowTitleSink = sink
firstSession.terminalState.terminalDidChangeTitle("first:/srv")
#expect(sink.titles == ["first:/srv"])
secondSession.terminalState.terminalDidChangeTitle("second:/tmp")
#expect(sink.titles == ["first:/srv"])
window.select(second.id)
#expect(sink.titles == ["first:/srv", "second:/tmp"])
#expect(window.focusedSSHSession === secondSession)
#expect(window.focusedSSHSession?.remoteWindowTitle == "second:/tmp")
}
@Test
func titleSinkFollowsFocusedPaneWithinAWorkspace() {
let workspace = Workspace.sshSession(
host: Host(name: "First", host: "first.example")
)
let firstPane = workspace.focusedPane
let secondPane = Pane.sshSession(
host: Host(name: "Second", host: "second.example")
)
workspace.splitFocusedPane(axis: .horizontal, with: secondPane)
let window = WindowModel(workspaces: [workspace])
let firstSession = firstPane.sshSession!
let secondSession = secondPane.sshSession!
firstSession.status = .connected
secondSession.status = .connected
firstSession.terminalState.terminalDidChangeTitle("pane-first:/srv")
secondSession.terminalState.terminalDidChangeTitle("pane-second:/tmp")
let sink = RemoteTitleSinkProbe()
window.remoteWindowTitleSink = sink
workspace.focus(firstPane.id)
workspace.focus(secondPane.id)
#expect(sink.titles == ["pane-first:/srv", "pane-second:/tmp"])
}
@Test
func menuFollowsOnlyTheKeyWindowAndUsesKittyTitleFormat() throws {
let mainMenu = NSMenu()
@@ -117,6 +171,15 @@ struct RemoteWindowTitleTests {
}
}
@MainActor
private final class RemoteTitleSinkProbe: WindowRemoteTitleSink {
var titles: [String?] = []
func windowRemoteTitleDidChange(_ title: String?) {
titles.append(title)
}
}
private nonisolated let ghosttyHasActiveDisplay: Bool = {
var count: UInt32 = 0
guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false }
+5 -2
View File
@@ -11,14 +11,17 @@ struct SFTPSessionTests {
let host = Host(name: "Files", host: "files.example")
let store = HostLibraryModel(hosts: [host])
let workspace = Workspace.sftpSession(host: host, initialPath: "/srv")
let window = WindowModel(workspaces: [workspace])
let session = try #require(workspace.focusedPane.sftpSession)
try await session.resolveInitialPathIfNeeded(using: LifecycleSFTPClient())
#expect(workspace.subtitle(in: store) == "/srv")
window.rename(workspace.id, to: "Logs")
#expect(workspace.title(in: store) == "Logs")
#expect(workspace.subtitle(in: store) == "/srv")
session.navigate(to: "/var/log")
#expect(workspace.subtitle(in: store) == "/var/log")
workspace.focusedPane.customTitle = "Logs"
#expect(workspace.subtitle(in: store) == "/var/log")
}
@Test
@@ -174,6 +174,7 @@ struct ShellIntegrationBootstrapTests {
.appending("builtin printf 'DEBUG_PRESERVED\\n'\n")
.appending("builtin printf '\\nHOOKS=%s;%s\\n' ")
.appending("\"$PROMPT_COMMAND\" \"$__hits_after_integration\"\n")
.appending("__termind_report_prompt\n")
.appending("__termind_report_command 'false'\n")
.appending("false\n__termind_report_prompt\nexit 0\n")
.utf8
@@ -185,7 +186,7 @@ struct ShellIntegrationBootstrapTests {
#expect(result.output.contains("DEBUG_PRESERVED"))
#expect(result.output.contains("HOOKS=__termind_report_prompt; __user_prompt=1;"))
#expect(result.output.contains("TermindCommand=RUNTIME;false\u{7}"))
#expect(result.output.contains("TermindCommandFinished=RUNTIME;1\u{7}"))
#expect(result.output.contains("TermindCommandFinished=RUNTIME;1;0\u{7}"))
}
@Test
+27
View File
@@ -36,6 +36,33 @@ struct WorkingDirectoryTests {
#expect(sshSession.workingDirectory == "/home/deploy/project")
}
@Test
func workspaceSubtitleTracksSSHDirectoryEvenWhenRenamed() async throws {
let host = Host(name: "Host", host: "host.example")
let store = HostLibraryModel(hosts: [host])
let workspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace])
let sshSession = try #require(workspace.focusedPane.sshSession)
sshSession.status = .connected
sshSession.receiveShellIntegrationEvent(
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/deploy")
)
sshSession.terminalState.terminalDidChangeWorkingDirectory("/home/deploy/project")
await Task.yield()
#expect(workspace.subtitle(in: store) == "~/project")
window.rename(workspace.id, to: "Production")
#expect(workspace.title(in: store) == "Production")
#expect(workspace.subtitle(in: store) == "~/project")
sshSession.terminalState.terminalDidChangeWorkingDirectory("/var/log")
await Task.yield()
#expect(workspace.subtitle(in: store) == "/var/log")
sshSession.status = .disconnected
#expect(workspace.subtitle(in: store) == nil)
}
@Test
func disconnectAndReconnectClearStaleDirectory() async {
let host = Host(name: "Host", host: "host.example")