diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 20e57d72..90b71745 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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`, diff --git a/Termind/App/RemoteWindowTitleMenu.swift b/Termind/App/RemoteWindowTitleMenu.swift index 7a161c00..a93d1de3 100644 --- a/Termind/App/RemoteWindowTitleMenu.swift +++ b/Termind/App/RemoteWindowTitleMenu.swift @@ -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) - } -} diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index ec0071c6..8c57244c 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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? - 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 { diff --git a/Termind/Application/FeatureModels/CommandHistoryModel.swift b/Termind/Application/FeatureModels/CommandHistoryModel.swift index 51784c22..98ab3b85 100644 --- a/Termind/Application/FeatureModels/CommandHistoryModel.swift +++ b/Termind/Application/FeatureModels/CommandHistoryModel.swift @@ -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 diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index f9bde125..db26a56c 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -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) } diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 294be1d5..2e7bf5d3 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -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 } } diff --git a/Termind/Features/Workspaces/Pane.swift b/Termind/Features/Workspaces/Pane.swift index 11e5029c..666f0aa0 100644 --- a/Termind/Features/Workspaces/Pane.swift +++ b/Termind/Features/Workspaces/Pane.swift @@ -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 diff --git a/Termind/Features/Workspaces/WindowModel.swift b/Termind/Features/Workspaces/WindowModel.swift index 4f682882..31ebafca 100644 --- a/Termind/Features/Workspaces/WindowModel.swift +++ b/Termind/Features/Workspaces/WindowModel.swift @@ -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 diff --git a/Termind/Features/Workspaces/Workspace.swift b/Termind/Features/Workspaces/Workspace.swift index c59fa4b7..033232bd 100644 --- a/Termind/Features/Workspaces/Workspace.swift +++ b/Termind/Features/Workspaces/Workspace.swift @@ -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 diff --git a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift index b3c1eab0..3a020b2b 100644 --- a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift @@ -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 diff --git a/Termind/Infrastructure/SSH/SSHSessionActor.swift b/Termind/Infrastructure/SSH/SSHSessionActor.swift index 3e377028..2a34e1a9 100644 --- a/Termind/Infrastructure/SSH/SSHSessionActor.swift +++ b/Termind/Infrastructure/SSH/SSHSessionActor.swift @@ -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): diff --git a/Termind/UI/Components/SidebarRow.swift b/Termind/UI/Components/SidebarRow.swift index 4a3041ee..41ac4d39 100644 --- a/Termind/UI/Components/SidebarRow.swift +++ b/Termind/UI/Components/SidebarRow.swift @@ -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 + 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, + 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. /// diff --git a/TermindTests/CommandHistoryTests.swift b/TermindTests/CommandHistoryTests.swift index 93b23ca2..e627b35d 100644 --- a/TermindTests/CommandHistoryTests.swift +++ b/TermindTests/CommandHistoryTests.swift @@ -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) } } diff --git a/TermindTests/RemoteWindowTitleTests.swift b/TermindTests/RemoteWindowTitleTests.swift index b2b0c669..c5b0c0af 100644 --- a/TermindTests/RemoteWindowTitleTests.swift +++ b/TermindTests/RemoteWindowTitleTests.swift @@ -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 } diff --git a/TermindTests/SFTPSessionTests.swift b/TermindTests/SFTPSessionTests.swift index e6efc74a..d740ad82 100644 --- a/TermindTests/SFTPSessionTests.swift +++ b/TermindTests/SFTPSessionTests.swift @@ -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 diff --git a/TermindTests/ShellIntegrationBootstrapTests.swift b/TermindTests/ShellIntegrationBootstrapTests.swift index 21091a15..82884809 100644 --- a/TermindTests/ShellIntegrationBootstrapTests.swift +++ b/TermindTests/ShellIntegrationBootstrapTests.swift @@ -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 diff --git a/TermindTests/WorkingDirectoryTests.swift b/TermindTests/WorkingDirectoryTests.swift index 614c52bc..3b0646b1 100644 --- a/TermindTests/WorkingDirectoryTests.swift +++ b/TermindTests/WorkingDirectoryTests.swift @@ -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")