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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them.

### Changed
- Sidebar resizing now uses native macOS pointer capture, so the resize cursor and drag stay stable when crossing terminal or browser content. Command-palette, review-panel, find-field, and workspace telemetry updates also avoid unnecessary whole-window redraws.
- Hidden terminal panes now release their Metal renderer and IOSurface pool after a short idle period while keeping the shell, scrollback, and terminal state alive. Returning to the pane rebuilds its renderer before it becomes visible, so graphics memory scales with the terminals on screen instead of every workspace opened during the session.
- Terminal output subscriptions now take one bounded snapshot per surface and publish only the changed suffix, reducing main-thread work and memory churn for automation clients watching busy terminals.
- Less background churn under agent load: repeated identical progress and port reports no longer redraw workspaces, moving the mouse across a window no longer re-renders its chrome, and scrolling no longer builds debug strings that get thrown away.
Expand Down
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@
NRTM00000006 /* NotificationSoundStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000005 /* NotificationSoundStaging.swift */; };
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; };
AK110002 /* SearchTextFieldHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = AK110001 /* SearchTextFieldHost.swift */; };
A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; };
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; };
Expand Down Expand Up @@ -321,6 +322,7 @@
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
RPVW0002 /* ReviewPanelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RPVW0001 /* ReviewPanelViewTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
A5001623 /* programa.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* programa.sdef */; };
Expand Down Expand Up @@ -634,6 +636,7 @@
NRTM00000005 /* NotificationSoundStaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundStaging.swift; sourceTree = "<group>"; };
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = "<group>"; };
AK110001 /* SearchTextFieldHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SearchTextFieldHost.swift; sourceTree = "<group>"; };
A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = "<group>"; };
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -738,6 +741,7 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
RPVW0001 /* ReviewPanelViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPanelViewTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
A5001622 /* programa.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = programa.sdef; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1054,6 +1058,7 @@
NRTM00000005 /* NotificationSoundStaging.swift */,
A5001301 /* SurfaceSearchOverlay.swift */,
A5008370 /* BrowserSearchOverlay.swift */,
AK110001 /* SearchTextFieldHost.swift */,
A5008372 /* BrowserFindJavaScript.swift */,
A5001410 /* Panel.swift */,
A5001411 /* TerminalPanel.swift */,
Expand Down Expand Up @@ -1265,6 +1270,7 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
RPVW0001 /* ReviewPanelViewTests.swift */,
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */,
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */,
Expand Down Expand Up @@ -1614,6 +1620,7 @@
NRTM00000006 /* NotificationSoundStaging.swift in Sources */,
A5001303 /* SurfaceSearchOverlay.swift in Sources */,
A5008371 /* BrowserSearchOverlay.swift in Sources */,
AK110002 /* SearchTextFieldHost.swift in Sources */,
A5008373 /* BrowserFindJavaScript.swift in Sources */,
A5001400 /* Panel.swift in Sources */,
A5001401 /* TerminalPanel.swift in Sources */,
Expand Down Expand Up @@ -1743,6 +1750,7 @@
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
RPVW0002 /* ReviewPanelViewTests.swift in Sources */,
E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */,
1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */,
46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion Sources/ContentView+SidebarResizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ extension ContentView {
private func activateSidebarResizerCursor() {
sidebarResizerCursorReleaseWorkItem?.cancel()
sidebarResizerCursorReleaseWorkItem = nil
isSidebarResizerCursorActive = true
if !isSidebarResizerCursorActive {
isSidebarResizerCursorActive = true
}
Self.fixedSidebarResizeCursor.set()
}

Expand Down
201 changes: 13 additions & 188 deletions Sources/Find/BrowserSearchOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@ struct BrowserSearchOverlay: View {

private var searchControls: some View {
HStack(spacing: 4) {
BrowserSearchTextFieldRepresentable(
SearchTextFieldHost(
text: $searchState.needle,
isFocused: $isSearchFieldFocused,
panelId: panelId,
focusRequestGeneration: focusRequestGeneration,
canApplyFocusRequest: canApplyFocusRequest,
accessibilityIdentifier: "BrowserFindSearchTextField",
focusNotificationName: .browserSearchFocus,
shouldApplyFocusNotification: { notification in
guard let notifiedPanelId = notification.object as? UUID else { return false }
return notifiedPanelId == panelId
},
canApplyFocusRequest: {
canApplyFocusRequest(focusRequestGeneration)
},
focusSelection: .caretAtEnd,
debugContext: nil,
onFieldDidFocus: onFieldDidFocus,
onEscape: onClose,
onEscape: { _ in onClose() },
onReturn: { isShift in
if isShift {
onPrevious()
Expand Down Expand Up @@ -209,186 +217,3 @@ struct BrowserSearchOverlay: View {
return point.y < midY ? .topRight : .bottomRight
}
}

private final class BrowserSearchNativeTextField: NSTextField {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isBordered = false
isBezeled = false
drawsBackground = false
focusRingType = .none
usesSingleLineMode = true
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let panelId: UUID
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onFieldDidFocus: () -> Void
let onEscape: () -> Void
let onReturn: (_ isShift: Bool) -> Void

final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: BrowserSearchTextFieldRepresentable
var isProgrammaticMutation = false
weak var parentField: BrowserSearchNativeTextField?
var pendingFocusRequest: Bool?
var searchFocusObserver: NSObjectProtocol?

init(parent: BrowserSearchTextFieldRepresentable) {
self.parent = parent
}

deinit {
if let searchFocusObserver {
NotificationCenter.default.removeObserver(searchFocusObserver)
}
}

func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) {
guard window.makeFirstResponder(field) else { return }
DispatchQueue.main.async { [weak field] in
guard let field,
let editor = field.currentEditor() as? NSTextView else { return }
let end = field.stringValue.utf16.count
editor.setSelectedRange(NSRange(location: end, length: 0))
}
}

func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
parent.text = field.stringValue
}

func controlTextDidBeginEditing(_ obj: Notification) {
parent.onFieldDidFocus()
if !parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = true
}
}
}

func controlTextDidEndEditing(_ obj: Notification) {
if parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = false
}
}
}

func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.cancelOperation(_:)):
if textView.hasMarkedText() { return false }
parent.onEscape()
return true
case #selector(NSResponder.insertNewline(_:)):
if textView.hasMarkedText() { return false }
let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
parent.onReturn(isShift)
return true
default:
return false
}
}
}

func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}

func makeNSView(context: Context) -> BrowserSearchNativeTextField {
let field = BrowserSearchNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search")
field.setAccessibilityIdentifier("BrowserFindSearchTextField")
field.delegate = context.coordinator
field.target = nil
field.action = nil
field.isEditable = true
field.isSelectable = true
field.isEnabled = true
field.stringValue = text
context.coordinator.parentField = field
context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver(
forName: .browserSearchFocus,
object: nil,
queue: .main
) { [weak field, weak coordinator = context.coordinator] notification in
guard let field, let coordinator else { return }
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == coordinator.parent.panelId else { return }
guard coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return }
guard let window = field.window else { return }
let fr = window.firstResponder
let alreadyFocused = fr === field ||
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
guard !alreadyFocused else { return }
coordinator.focusField(field, in: window)
}
return field
}

func updateNSView(_ nsView: BrowserSearchNativeTextField, context: Context) {
context.coordinator.parent = self
context.coordinator.parentField = nsView

if let editor = nsView.currentEditor() as? NSTextView {
if editor.string != text, !editor.hasMarkedText() {
context.coordinator.isProgrammaticMutation = true
editor.string = text
nsView.stringValue = text
context.coordinator.isProgrammaticMutation = false
}
} else if nsView.stringValue != text {
nsView.stringValue = text
}

if let window = nsView.window {
let fr = window.firstResponder
let isFirstResponder =
fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView

if isFocused,
canApplyFocusRequest(focusRequestGeneration),
!isFirstResponder,
context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator,
coordinator.parent.isFocused,
coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return }
guard let nsView, let window = nsView.window else { return }
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
coordinator.focusField(nsView, in: window)
}
}
}
}

static func dismantleNSView(_ nsView: BrowserSearchNativeTextField, coordinator: Coordinator) {
if let observer = coordinator.searchFocusObserver {
NotificationCenter.default.removeObserver(observer)
coordinator.searchFocusObserver = nil
}
nsView.delegate = nil
coordinator.parentField = nil
}
}
Loading
Loading