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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ jobs:
sudo xcode-select -s "$XCODE_DIR" || true
xcodebuild -version

- name: Verify native tab-strip hit testing
run: swift test --package-path vendor/bonsplit --filter BonsplitTests/testTabBarDragZoneUsesSuperviewCoordinatesAndYieldsToControls

- name: Validate Release reload artifact discovery
run: ./tests/test_reloadp_programa_artifact.sh

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ on:
default: "macos-15"
type: choice
options:
- macos-26
- macos-15
- macos-14

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Each ship now deletes promoted release candidates older than the two most recent, so the releases page stops accumulating 110 MB prereleases.

### Fixed
- Closing a window keeps its sessions available for reopening from the Dock or New Window. Explicitly closing a workspace still ends its sessions.
- Failed session saves cancel quitting, and replacement instances wait for the previous instance to finish saving before restoring sessions.
- Provider usage uses a compact layout with consistent remaining-capacity bars. Empty window chrome and tab strips support native dragging and the system titlebar double-click action.
- Autosave acknowledges completed disk writes, retries failures, and preserves prompt-save requests during an ongoing write. Saved sessions no longer expire solely because they remained unclaimed for an hour; fresh-shell recovery is labeled explicitly.
- Scrollback restoration tracks effective terminal colors without growing style history. Fully hidden windows can release all idle terminal graphics while retaining their sessions.
- Worktrees keep their requested workspace parent across session restore, and selected Solid Fill rows retain their workspace color rail.
- Idle CPU with an open window dropped from roughly 12 to 20 percent of a core to about 1 percent. The workspace pane overlay kept a display-rate animation timeline running for the life of every window; it now mounts one only while an attention flash is animating.
- The dock icon no longer costs 32 MB of resident memory. The light and dark icon assets are declared as 512pt @2x, so AppKit decodes them at 1024 pixels instead of rasterizing a 2048 pixel copy.
- Closing a workspace no longer leaves its Workspace object alive through the sidebar row's hover closure.
Expand Down
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
H1AP0002 /* AppLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1AP0001 /* AppLifecycleCoordinator.swift */; };
H1SH0002 /* AppDelegate+StartupHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1SH0001 /* AppDelegate+StartupHandoff.swift */; };
A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0004 /* TerminalController+BrowserAutomation.swift */; };
H1BR0002 /* BrowserRPCDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1BR0001 /* BrowserRPCDispatcher.swift */; };
NRTC0011 /* TerminalController+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTC0001 /* TerminalController+System.swift */; };
Expand Down Expand Up @@ -514,6 +515,7 @@
CESH000003 /* session_escrow_shim.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = session_escrow_shim.h; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
H1AP0001 /* AppLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleCoordinator.swift; sourceTree = "<group>"; };
H1SH0001 /* AppDelegate+StartupHandoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+StartupHandoff.swift"; sourceTree = "<group>"; };
A5FF0004 /* TerminalController+BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomation.swift"; sourceTree = "<group>"; };
H1BR0001 /* BrowserRPCDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserRPCDispatcher.swift; sourceTree = "<group>"; };
NRTC0001 /* TerminalController+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+System.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -985,6 +987,7 @@
D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */,
A5001090 /* AppDelegate.swift */,
H1AP0001 /* AppLifecycleCoordinator.swift */,
H1SH0001 /* AppDelegate+StartupHandoff.swift */,
C5E68FD3C9918B5717603304 /* SessionAutosaveCoordinator.swift */,
DDE5A5F043A04829779CF356 /* ShortcutRouting.swift */,
NRAD0001 /* MainWindowHostingView.swift */,
Expand Down Expand Up @@ -1507,6 +1510,7 @@
A5FF0008 /* HostedViewPortalRegistry.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
H1AP0002 /* AppLifecycleCoordinator.swift in Sources */,
H1SH0002 /* AppDelegate+StartupHandoff.swift in Sources */,
A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */,
H1BR0002 /* BrowserRPCDispatcher.swift in Sources */,
NRTC0011 /* TerminalController+System.swift in Sources */,
Expand Down
63 changes: 57 additions & 6 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -7008,13 +7008,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state."
"value": "The existing Programa instance did not quit. Force closing it may lose unsaved terminal or session state."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "既存のProgramaインスタンスが応答していません。強制終了すると、保存されていないターミナルまたはセッションの状態が失われる可能性があります。"
"value": "既存のProgramaインスタンスは終了しませんでした。強制終了すると、保存されていないターミナルやセッションの状態が失われる可能性があります。"
}
}
}
Expand All @@ -7025,13 +7025,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Programa Isn’t Responding"
"value": "Existing Programa Is Still Open"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Programaが応答していません"
"value": "既存の Programa が開いたままです"
}
}
}
Expand Down Expand Up @@ -13051,13 +13051,64 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Programa quit unexpectedly last time. Your sessions and running processes were restored."
"value": "Programa quit unexpectedly last time. Saved workspaces are being restored and available processes reconnected. Terminals that cannot reconnect start a new shell."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "前回、Programa は予期せず終了しました。セッションと実行中のプロセスは復元されています。"
"value": "前回、Programa は予期せず終了しました。保存されたワークスペースを復元し、利用可能なプロセスに再接続しています。再接続できないターミナルでは新しいシェルを起動します。"
}
}
}
},
"session_recovery.freshShell.notice": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A new shell was started because the previous process could not be reconnected."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "以前のプロセスに再接続できなかったため、新しいシェルを起動しました。"
}
}
}
},
"dialog.quitSaveFailed.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn’t Save Sessions"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "セッションを保存できませんでした"
}
}
}
},
"dialog.quitSaveFailed.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Programa stayed open because it couldn’t save your sessions. Check available disk space and try quitting again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "セッションを保存できなかったため、Programa は終了せずに開いたままです。ディスクの空き容量を確認して、もう一度終了してください。"
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions Sources/AppDelegate+StartupHandoff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import AppKit
import Darwin

@MainActor
final class StartupSessionHandoff {
private let olderProcess: @MainActor () -> ProgramaSingleInstanceProcessKey?
private let isLive: (ProgramaSingleInstanceProcessKey) -> Bool
private let onReady: @MainActor () -> Void
private(set) var hasCompletedInitialArbitration: Bool
private var checked = false
private var target: ProgramaSingleInstanceProcessKey?
var isWaiting: Bool { target != nil }

init(
hasCompletedInitialArbitration: Bool = SessionMachineryGate.isUnitTesting,
olderProcess: @escaping @MainActor () -> ProgramaSingleInstanceProcessKey?,
isLive: @escaping (ProgramaSingleInstanceProcessKey) -> Bool,
onReady: @escaping @MainActor () -> Void
) {
self.hasCompletedInitialArbitration = hasCompletedInitialArbitration
self.olderProcess = olderProcess
self.isLive = isLive
self.onReady = onReady
}

func shouldDefer() -> Bool {
guard hasCompletedInitialArbitration else { return true }
guard !checked else { return isWaiting }
checked = true
target = olderProcess()
if isWaiting { schedulePoll() }
return isWaiting
}

func initialArbitrationCompleted() {
guard !hasCompletedInitialArbitration else { return }
hasCompletedInitialArbitration = true
if !shouldDefer() { onReady() }
}

func poll() {
guard isWaiting else { return }
if target.map(isLive) == true { schedulePoll(); return }
target = olderProcess()
if isWaiting { schedulePoll() } else { onReady() }
}

private func schedulePoll() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
Task { @MainActor [weak self] in self?.poll() }
}
}

static func authenticatedOlderProcess() -> ProgramaSingleInstanceProcessKey? {
guard !SessionMachineryGate.isUnitTesting, let bundleIdentifier = Bundle.main.bundleIdentifier,
let currentKey = AppDelegate.singleInstanceProcessKey(for: getpid()) else { return nil }
let embeddedCLIURL = Bundle.main.bundleURL
.appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false)
.standardizedFileURL.resolvingSymlinksInPath()
for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) {
guard AppDelegate.shouldConsiderDuplicateApplication(
candidateBundleIdentifier: app.bundleIdentifier,
candidateProcessIdentifier: app.processIdentifier,
candidateExecutableURL: app.executableURL,
expectedBundleIdentifier: bundleIdentifier,
currentProcessIdentifier: currentKey.processIdentifier,
embeddedCLIURL: embeddedCLIURL
), let otherKey = AppDelegate.singleInstanceProcessKey(for: app.processIdentifier),
AppDelegate.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey),
AppDelegate.isAuthenticatedProgramaApplication(expectedProcessKey: otherKey) else { continue }
return otherKey
}
return nil
}
}
Loading
Loading