From 17103034422b80598431dbf0a747d3dcd7a94a55 Mon Sep 17 00:00:00 2001 From: CJ Winslow Date: Tue, 14 Apr 2026 14:41:12 -0700 Subject: [PATCH] Respect ghostty split-preserve-zoom config when cycling splits Upstream Ghostty (v1.3.0+) has split-preserve-zoom = navigation to keep zoom state when navigating between splits. Supacode's split management bypasses BaseTerminalController so this config was never read. Now GhosttyRuntime reads the setting and gotoSplit honors it: zoom transfers to the next pane when enabled, unzooms when not. --- .../Models/WorktreeTerminalState.swift | 18 ++++- .../Ghostty/GhosttyRuntime.swift | 11 +++ supacodeTests/SplitTreeTests.swift | 80 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 35f9cd87..08369927 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -21,6 +21,7 @@ final class WorktreeTerminalState { let tabManager: TerminalTabManager private let runtime: GhosttyRuntime + @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool private let worktree: Worktree @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings @@ -63,8 +64,14 @@ final class WorktreeTerminalState { var onCommandPaletteToggle: (() -> Void)? var onSetupScriptConsumed: (() -> Void)? - init(runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false) { + init( + runtime: GhosttyRuntime, + worktree: Worktree, + runSetupScript: Bool = false, + splitPreserveZoomOnNavigation: (() -> Bool)? = nil + ) { self.runtime = runtime + self.splitPreserveZoomOnNavigation = splitPreserveZoomOnNavigation ?? { runtime.splitPreserveZoomOnNavigation() } self.worktree = worktree self.pendingSetupScript = runSetupScript self.tabManager = TerminalTabManager() @@ -574,8 +581,13 @@ final class WorktreeTerminalState { return false } if tree.zoomed != nil { - tree = tree.settingZoomed(nil) - trees[tabId] = tree + if splitPreserveZoomOnNavigation() { + let nextNode = tree.root?.node(view: nextSurface) + tree = tree.settingZoomed(nextNode) + } else { + tree = tree.settingZoomed(nil) + } + updateTree(tree, for: tabId) } focusSurface(nextSurface, in: tabId) syncFocusIfNeeded() diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index 956c22a8..76b3c335 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -557,6 +557,17 @@ final class GhosttyRuntime { return true } + func splitPreserveZoomOnNavigation() -> Bool { + guard let config else { return false } + var value: CUnsignedInt = 0 + let key = "split-preserve-zoom" + guard ghostty_config_get(config, &value, key, UInt(key.count)) else { return false } + // Ghostty's C API bitcasts packed structs into c_uint; the first field maps to bit 0. + // https://github.com/ghostty-org/ghostty/blob/6057f8d/src/config/c_get.zig#L74-L84 + // https://github.com/ghostty-org/ghostty/blob/6057f8d/src/config/c_get.zig#L226-L240 + return value & (1 << 0) != 0 + } + func backgroundOpacity() -> Double { guard let config else { return 1 } var value: Double = 1 diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index 712a64d9..2a84dcb7 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -31,6 +31,27 @@ struct SplitTreeTests { #expect(tree.focusTargetAfterClosing(node) === second) } + @Test func focusTargetNextWrapsAroundFromZoomedNode() throws { + let first = SplitTreeTestView() + let second = SplitTreeTestView() + let third = SplitTreeTestView() + + let tree = try SplitTree(view: first) + .inserting(view: second, at: first, direction: .right) + .inserting(view: third, at: second, direction: .right) + + let zoomedNode = tree.find(id: second.id)! + let zoomed = tree.settingZoomed(zoomedNode) + + let next = zoomed.focusTarget(for: .next, from: zoomedNode) + #expect(next === third) + + let nextNode = zoomed.find(id: third.id)! + let rezoomed = zoomed.settingZoomed(nextNode) + #expect(rezoomed.visibleLeaves().count == 1) + #expect(rezoomed.visibleLeaves().first === third) + } + @Test func visibleLeavesOnlyReturnZoomedPane() throws { let first = SplitTreeTestView() let second = SplitTreeTestView() @@ -44,6 +65,65 @@ struct SplitTreeTests { #expect(visibleLeaves.count == 1) #expect(visibleLeaves.first === second) } + + @Test func gotoSplitPreservesZoomWhenConfigured() throws { + let fixture = makeWorktreeFixture(preserveZoomOnNavigation: true) + let first = fixture.first + let second = try #require(fixture.second) + + #expect(fixture.state.performSplitAction(.toggleSplitZoom, for: first.id)) + #expect(fixture.state.performSplitAction(.gotoSplit(direction: .next), for: first.id)) + + let visibleLeaves = fixture.state.splitTree(for: fixture.tabId).visibleLeaves() + #expect(visibleLeaves.count == 1) + #expect(visibleLeaves.first === second) + } + + @Test func gotoSplitClearsZoomWhenNotConfigured() throws { + let fixture = makeWorktreeFixture(preserveZoomOnNavigation: false) + let first = fixture.first + + #expect(fixture.state.performSplitAction(.toggleSplitZoom, for: first.id)) + #expect(fixture.state.performSplitAction(.gotoSplit(direction: .next), for: first.id)) + + let visibleLeaves = fixture.state.splitTree(for: fixture.tabId).visibleLeaves() + #expect(visibleLeaves.count == 2) + } + + private func makeWorktreeFixture(preserveZoomOnNavigation: Bool) -> WorktreeFixture { + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { preserveZoomOnNavigation } + ) + let tabId = state.createTab()! + let first = state.splitTree(for: tabId).root!.leftmostLeaf() + _ = state.performSplitAction(.newSplit(direction: .right), for: first.id) + let leaves = state.splitTree(for: tabId).leaves() + return WorktreeFixture( + state: state, + tabId: tabId, + first: first, + second: leaves.first { $0.id != first.id } + ) + } + + private func makeWorktree() -> Worktree { + Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } +} + +private struct WorktreeFixture { + let state: WorktreeTerminalState + let tabId: TerminalTabID + let first: GhosttySurfaceView + let second: GhosttySurfaceView? } private final class SplitTreeTestView: NSView, Identifiable {