Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
be84950
fix: propagate unit-test runner failures again
arzafran Jul 10, 2026
3e6b185
fix: unshallow change-detection fetch so merge-base resolves
arzafran Jul 10, 2026
5fae804
ci: run unit tests serially until parallel mode is proven
arzafran Jul 10, 2026
35c4a7d
fix: repair product bugs surfaced by honest test reporting
arzafran Jul 10, 2026
c4578ac
test: update stale expectations after rebrand and behavior changes
arzafran Jul 10, 2026
fb2bd14
fix: relay CLI discovery falls back to programa, not cmux
arzafran Jul 10, 2026
1d1a4bf
test: repair zsh/bash integration harness races and stale payloads
arzafran Jul 10, 2026
32095cb
fix: coalesce rapid devtools toggles and inherit popup process pool
arzafran Jul 10, 2026
730fa09
test: isolate BrowserProfileStore state in remote-store tests
arzafran Jul 10, 2026
d94d79e
fix: PR-probe filtering, poll candidates, workspace ownership, browse…
arzafran Jul 10, 2026
026a361
test: bootstrap install hop wraps in sh -c, not a login shell
arzafran Jul 10, 2026
2ee449c
fix: notification focus clobbered by stale transition, missing clear …
arzafran Jul 10, 2026
ce01e1b
fix: palette typo scoring, pending empty state, branch tokens, teleme…
arzafran Jul 10, 2026
43d2dbd
fix: portal hit-testing, lifecycle sync, and window-scoped drag top-hit
arzafran Jul 10, 2026
19b720c
fix: search overlay focus gating, surface retain, shift-backquote tilde
arzafran Jul 10, 2026
965a2d6
refactor: use NSGlassEffectView directly behind availability guards
arzafran Jul 10, 2026
e811563
fix: shortcut routing, orphan pruning, leaked omnibar focus, overlay …
arzafran Jul 13, 2026
2bd7dca
test: retarget config-inheritance seam, codify multi-panel sidebar PRs
arzafran Jul 13, 2026
f9cb95c
fix: terminal surface registry leak and theme migration skip
arzafran Jul 13, 2026
f28fe0a
test: stop cross-suite state leaks that masked or caused failures
arzafran Jul 13, 2026
5230f3e
test: harden focus/key-window sensitive tests against full-suite state
arzafran Jul 13, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci-macos-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:

BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
git fetch --no-tags --depth=1 origin "${BASE_SHA}" "${HEAD_SHA}"
git fetch --no-tags origin "${BASE_SHA}" "${HEAD_SHA}"
CHANGED_FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")"
RUN_COMPAT_TESTS=false
if [[ -z "${CHANGED_FILES}" ]]; then
Expand Down Expand Up @@ -203,7 +203,7 @@ jobs:
- name: Run unit tests
env:
PROGRAMA_SKIP_ZIG_BUILD: ${{ matrix.skip_zig && '1' || '0' }}
PROGRAMA_UNIT_TEST_SCOPE: split-stateful
PROGRAMA_UNIT_TEST_SCOPE: serial
run: |
./scripts/ci-run-unit-tests.sh

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:

BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
git fetch --no-tags --depth=1 origin "${BASE_SHA}" "${HEAD_SHA}"
git fetch --no-tags origin "${BASE_SHA}" "${HEAD_SHA}"
CHANGED_FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")"
RUN_APP_JOBS=false
RUN_REMOTE_DAEMON_JOBS=false
Expand Down Expand Up @@ -300,7 +300,7 @@ jobs:

- name: Run unit tests
env:
PROGRAMA_UNIT_TEST_SCOPE: split-stateful
PROGRAMA_UNIT_TEST_SCOPE: serial
run: ./scripts/ci-run-unit-tests.sh

- name: Run bundled Ghostty theme picker helper regression
Expand Down
4 changes: 2 additions & 2 deletions Resources/shell-integration/.zshenv
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ fi
&& -z "${ZSH_EXECUTION_STRING:-}" \
&& "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" \
&& -n "${PROGRAMA_SHELL_INTEGRATION_DIR:-}" \
&& -r "${PROGRAMA_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" \
&& -r "${PROGRAMA_SHELL_INTEGRATION_DIR}/programa-zsh-integration.zsh" \
&& "${TERM:-}" == "xterm-256color" \
&& -z "${PROGRAMA_ZSH_RESTORE_TERM:-}" ]]; then
# Keep startup TERM-compatible prompt/theme selection during shell init,
Expand Down Expand Up @@ -60,7 +60,7 @@ fi

# Load cmux integration (unless disabled)
if [[ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" && -n "${PROGRAMA_SHELL_INTEGRATION_DIR:-}" ]]; then
builtin typeset _cmux_integ="$PROGRAMA_SHELL_INTEGRATION_DIR/cmux-zsh-integration.zsh"
builtin typeset _cmux_integ="$PROGRAMA_SHELL_INTEGRATION_DIR/programa-zsh-integration.zsh"
[[ -r "$_cmux_integ" ]] && builtin source -- "$_cmux_integ"
fi
fi
Expand Down
4 changes: 3 additions & 1 deletion Resources/shell-integration/programa-bash-integration.bash
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ _cmux_relay_cli_path() {
printf '%s\n' "${PROGRAMA_BUNDLED_CLI_PATH}"
return 0
fi
command -v cmux 2>/dev/null
# Rebranded CLI binary ships as "programa"; fall back to the pre-rebrand
# "cmux" name for older installs that only symlinked that binary.
command -v programa 2>/dev/null || command -v cmux 2>/dev/null
}

_cmux_socket_uses_remote_relay() {
Expand Down
4 changes: 3 additions & 1 deletion Resources/shell-integration/programa-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ _cmux_relay_cli_path() {
print -r -- "${PROGRAMA_BUNDLED_CLI_PATH}"
return 0
fi
command -v cmux 2>/dev/null
# Rebranded CLI binary ships as "programa"; fall back to the pre-rebrand
# "cmux" name for older installs that only symlinked that binary.
command -v programa 2>/dev/null || command -v cmux 2>/dev/null
}

_cmux_socket_uses_remote_relay() {
Expand Down
82 changes: 77 additions & 5 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
return false
}

/// True only when the XCTest bundle is injected directly into *this* process
/// (unit tests hosted inside the app, e.g. the `programa-unit` scheme). This is
/// distinct from a genuine XCUITest run: there the `.xctest` bundle loads into a
/// separate XCTRunner process, and this app-under-test process is launched fresh
/// with none of `XCTestBundlePath`/`XCInjectBundle`/`XCInjectBundleInto` set.
/// `PROGRAMA_UI_TEST_*` keys (set explicitly by UI tests via `launchEnvironment`)
/// are excluded from this check on purpose so it can't misclassify a real UI test
/// run as an embedded unit-test host.
private func isEmbeddedUnitTestHost(_ env: [String: String]) -> Bool {
env["XCTestBundlePath"] != nil || env["XCInjectBundle"] != nil || env["XCInjectBundleInto"] != nil
}

private func isRunningUnderXCTest(_ env: [String: String]) -> Bool {
// On some macOS/Xcode setups, the app-under-test process doesn't get
// `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests
Expand Down Expand Up @@ -1152,7 +1164,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser

// In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM.
// If there are no windows shortly after launch, force-create one so XCUITest can proceed.
if isRunningUnderXCTest {
// Unit tests (embedded XCTest bundle hosted in this same process) manage their own
// windows explicitly per test and never want a phantom default window lingering in
// `mainWindowContexts` for the lifetime of the test run, so exclude that case here.
if isRunningUnderXCTest, !isEmbeddedUnitTestHost(env) {
if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] {
UserDefaults.standard.set(
rawShow == "1",
Expand Down Expand Up @@ -2528,6 +2543,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
sidebarState: SidebarState,
sidebarSelectionState: SidebarSelectionState
) {
// A window can be deallocated without ever going through the normal
// close/NSWindow.willCloseNotification teardown path (e.g. released directly
// by its owner). That leaves its MainWindowContext behind indefinitely, since
// nothing else proactively sweeps for dead-window contexts. Registering a new
// main window is a natural, frequent checkpoint to clean those up so they don't
// linger and skew context-selection/fallback logic for the lifetime of the app.
discardOrphanedMainWindowContexts()

tabManager.window = window

let key = ObjectIdentifier(window)
Expand Down Expand Up @@ -3687,11 +3710,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
@discardableResult
func closeWindowWithConfirmation(_ window: NSWindow) -> Bool {
guard isMainTerminalWindow(window) else {
window.performClose(nil)
window.close()
return true
}
guard confirmCloseMainWindow(window) else { return true }
window.performClose(nil)
window.close()
return true
}

Expand Down Expand Up @@ -3924,6 +3947,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}
}

/// Prune every registered main window context whose window has already been
/// deallocated. Snapshot into an array first: `discardOrphanedMainWindowContext`
/// mutates `mainWindowContexts`, and mutating a dictionary while iterating its
/// `.values` view directly would trap.
private func discardOrphanedMainWindowContexts() {
let orphans = mainWindowContexts.values.filter { resolvedWindow(for: $0) == nil }
for orphan in orphans {
discardOrphanedMainWindowContext(orphan)
}
}

private func mainWindowId(for window: NSWindow) -> UUID? {
if let context = mainWindowContexts[ObjectIdentifier(window)] {
return context.windowId
Expand Down Expand Up @@ -4540,6 +4574,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
workingDirectory: workingDirectory
)
#endif
// Prune unconditionally, before context selection: a deallocated-window context
// (the owning NSWindow was released without going through the normal
// close/unregister path) must not keep reporting a non-nil
// tabManagerFor(windowId:) just because *some other* live window ends up being
// selected below. Selection succeeding elsewhere is not evidence the orphan was
// ever a candidate -- only that a different, legitimate window was available.
discardOrphanedMainWindowContexts()
guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else {
#if DEBUG
logWorkspaceCreationRouting(
Expand Down Expand Up @@ -5744,10 +5785,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
private func installShortcutDefaultsObserver() {
guard shortcutDefaultsObserver == nil else { return }
refreshConfiguredShortcutChordActions()
// queue: nil (not .main) is required so this runs synchronously on the
// posting thread. All call sites post this notification from the main
// thread (see Sources/KeyboardShortcutSettings.swift setShortcut/resetShortcut/
// resetAll, and Sources/ProgramaSettingsFileStore.swift's file watcher, which
// hops to DispatchQueue.main.async before calling reload()). A .main queue
// would enqueue delivery asynchronously even when posted from the main thread,
// leaving configuredShortcutChordActions stale for one run-loop turn if a key
// event arrives immediately after a shortcut change.
shortcutDefaultsObserver = NotificationCenter.default.addObserver(
forName: KeyboardShortcutSettings.didChangeNotification,
object: nil,
queue: .main
queue: nil
) { [weak self] _ in
MainActor.assumeIsolated {
self?.refreshConfiguredShortcutChordActions()
Expand Down Expand Up @@ -6675,7 +6724,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}

if matchConfiguredShortcut(event: event, action: .closeWindow) {
guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else {
// `event.window` is often nil for synthetic/XCUITest key events that only carry a
// windowNumber; fall back through the same windowNumber-aware resolver the rest of
// the shortcut-routing code uses so Cmd+Ctrl+W targets the event's actual window
// instead of silently no-op'ing when NSApp.keyWindow/mainWindow are stale.
guard let targetWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else {
NSSound.beep()
return true
}
Expand Down Expand Up @@ -8465,6 +8518,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}
}

// `browserAddressBarFocusedPanelId` is a single app-wide pointer, not scoped to
// any particular window, and nothing else proactively invalidates it when its
// owning panel goes away. A stale non-nil value here makes
// `commandOmnibarSelectionDelta` report `hasFocusedAddressBar = true` for every
// window afterward, silently hijacking Cmd+N/Cmd+P/Ctrl+N/Ctrl+P in *other*
// windows as omnibar-suggestion navigation instead of their normal shortcut
// action. Check across all *remaining* live windows -- rather than just this
// window's (possibly already-torn-down) tab list -- since teardown order
// between this window's own workspace/panel cleanup and this notification
// handler is not guaranteed, and checking only `removed.tabManager` can miss a
// panel that was already removed from it by the time this runs.
if let focusedPanelId = browserAddressBarFocusedPanelId,
!mainWindowContexts.values.contains(where: { context in
context.tabManager.tabs.contains(where: { $0.panels[focusedPanelId] != nil })
}) {
browserAddressBarFocusedPanelId = nil
stopBrowserOmnibarSelectionRepeat()
}

if tabManager === removed.tabManager {
// Repoint "active" pointers to any remaining main terminal window.
let nextContext: MainWindowContext? = {
Expand Down
Loading
Loading