Fix Shell Integration
This commit is contained in:
1 parent
1449bd62ac
commit
2f96b5a75a
17 files changed
+504
-155
No files matched your search
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user