From 4f110464daa60f6cc69ec9217191749fd86db6a1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 30 Jun 2026 14:25:39 -0300 Subject: [PATCH 1/3] fix: lighten split divider on dark backgrounds so it stays visible (#26) On near-black terminal backgrounds the computed divider was darkened toward black and vanished. Lighten dark backgrounds with an additive-brightness helper instead; light backgrounds still darken. An explicit split-divider-color override always wins. Adds GhosttyConfig divider tests. --- Sources/GhosttyConfig.swift | 24 ++++++++++++++++++- cmuxTests/GhosttyConfigTests.swift | 37 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 5ac6dc8625f..bb45b2a7701 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -53,8 +53,14 @@ struct GhosttyConfig { return splitDividerColor } + // On light backgrounds a subtle darken reads as a divider. On dark/near-black + // backgrounds, darkening pushes toward black and the divider disappears — lighten + // instead so the divider stays visible. Users can still override via + // `split-divider-color`. let isLightBackground = backgroundColor.isLightColor - return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4) + return isLightBackground + ? backgroundColor.darken(by: 0.08) + : backgroundColor.lighten(by: 0.18) } static func load( @@ -589,4 +595,20 @@ extension NSColor { alpha: a ) } + + /// Additively raises brightness so the result stays visible even on pure black + /// (where multiplicative scaling would have no effect). + func lighten(by amount: CGFloat) -> NSColor { + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return NSColor( + hue: h, + saturation: s, + brightness: min(b + amount, 1), + alpha: a + ) + } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index b2135345ffb..f7fbf34f729 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -79,6 +79,43 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark")) } + // #26: on near-black backgrounds the divider must be lightened to stay visible, + // not darkened toward black. + func testSplitDividerLightensOnNearBlackBackground() { + var config = GhosttyConfig() + config.splitDividerColor = nil + config.backgroundColor = NSColor(hex: "#0a0a0a")! + + let divider = config.resolvedSplitDividerColor + XCTAssertGreaterThan( + divider.luminance, + config.backgroundColor.luminance + 0.05, + "Divider on a near-black background should be clearly lighter than the background" + ) + } + + func testSplitDividerDarkensOnLightBackground() { + var config = GhosttyConfig() + config.splitDividerColor = nil + config.backgroundColor = NSColor(hex: "#fafafa")! + + let divider = config.resolvedSplitDividerColor + XCTAssertLessThan( + divider.luminance, + config.backgroundColor.luminance, + "Divider on a light background should be darker than the background" + ) + } + + func testSplitDividerHonorsExplicitOverride() { + var config = GhosttyConfig() + let override = NSColor(hex: "#ff0000")! + config.splitDividerColor = override + config.backgroundColor = NSColor(hex: "#0a0a0a")! + + XCTAssertEqual(config.resolvedSplitDividerColor, override) + } + func testThemeSearchPathsIncludeXDGDataDirsThemes() { let pathA = "/tmp/programa-theme-a" let pathB = "/tmp/programa-theme-b" From eb4f5649f22a0736d33c0dd5488df7b90721a5e6 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 30 Jun 2026 14:25:39 -0300 Subject: [PATCH 2/3] test: guard shellState dedup race in SocketFastPathState (#32) shouldPublishShellActivity must not record the state it reads, or a report that never applies (panel absent) suppresses the next identical report and the activity update is lost. The fix is already on main; this regression guard locks it in. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ ...rminalControllerShellStateDedupTests.swift | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 cmuxTests/TerminalControllerShellStateDedupTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0afd9de7b88..580f883acfb 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -133,6 +133,7 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; + C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; }; /* End PBXBuildFile section */ @@ -332,6 +333,7 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; + C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerShellStateDedupTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -613,6 +615,7 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, ); @@ -897,6 +900,7 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, + C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, ); diff --git a/cmuxTests/TerminalControllerShellStateDedupTests.swift b/cmuxTests/TerminalControllerShellStateDedupTests.swift new file mode 100644 index 00000000000..045732d0b82 --- /dev/null +++ b/cmuxTests/TerminalControllerShellStateDedupTests.swift @@ -0,0 +1,61 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Regression guard for the #6618 shellState dedup race. +/// +/// `SocketFastPathState.shouldPublishShellActivity` must NOT record the state it +/// reads. Recording happens only via `recordShellActivity`, called after the +/// update is confirmed applied on the main thread. The old implementation wrote +/// on read, so a report that was never applied (panel absent) would suppress the +/// next identical report — losing the activity update permanently. +final class TerminalControllerShellStateDedupTests: XCTestCase { + func testShouldPublishDoesNotSuppressUntilActivityRecorded() { + let state = TerminalController.SocketFastPathState() + let workspaceId = UUID() + let panelId = UUID() + let activity: Workspace.PanelShellActivityState = .commandRunning + + // 1. First observation of a state is always worth publishing. + XCTAssertTrue( + state.shouldPublishShellActivity( + workspaceId: workspaceId, + panelId: panelId, + state: activity + ), + "First state report should be publishable" + ) + + // 2. Simulate the apply failing (panel absent): recordShellActivity is NOT called. + + // 3. The identical state must STILL be publishable, because nothing was + // recorded — this is the regression. Write-on-read would return false here. + XCTAssertTrue( + state.shouldPublishShellActivity( + workspaceId: workspaceId, + panelId: panelId, + state: activity + ), + "Identical state must remain publishable until it is recorded as applied" + ) + + // 4. After recording the applied state, the identical report is deduped. + state.recordShellActivity( + workspaceId: workspaceId, + panelId: panelId, + state: activity + ) + XCTAssertFalse( + state.shouldPublishShellActivity( + workspaceId: workspaceId, + panelId: panelId, + state: activity + ), + "Once recorded, an identical state should be deduped" + ) + } +} From 5729483ea37083805d78e470af448a4a6f293581 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 30 Jun 2026 14:25:39 -0300 Subject: [PATCH 3/3] fix: auto-save browser downloads to ~/Downloads, Safari-style (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the NSSavePanel prompt on download completion with a direct move to ~/Downloads, appending " 2", " 3", … on filename collisions like Safari. --- Sources/Panels/BrowserPanel.swift | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 103aaf483d6..2565822fd5e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5853,6 +5853,27 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { return safe.isEmpty ? "download" : safe } + /// Resolves a non-colliding destination in ~/Downloads, appending " 2", " 3", … like Safari. + static func uniqueDownloadsURL(for filename: String) -> URL { + let fileManager = FileManager.default + let downloads = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) + try? fileManager.createDirectory(at: downloads, withIntermediateDirectories: true) + + var candidate = downloads.appendingPathComponent(filename, isDirectory: false) + guard fileManager.fileExists(atPath: candidate.path) else { return candidate } + + let base = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + var counter = 2 + repeat { + let name = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" + candidate = downloads.appendingPathComponent(name, isDirectory: false) + counter += 1 + } while fileManager.fileExists(atPath: candidate.path) + return candidate + } + private func storeState(_ state: DownloadState, for download: WKDownload) { activeDownloadsLock.lock() activeDownloads[ObjectIdentifier(download)] = state @@ -5908,27 +5929,16 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { #endif NSLog("BrowserPanel download finished: %@", info.suggestedFilename) - // Show NSSavePanel on the next runloop iteration (safe context). + // #9: auto-save to ~/Downloads (Safari-style) instead of prompting with a save panel. DispatchQueue.main.async { self.onDownloadReadyToSave?() - let savePanel = NSSavePanel() - savePanel.nameFieldStringValue = info.suggestedFilename - savePanel.canCreateDirectories = true - savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first - - savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { - try? FileManager.default.removeItem(at: info.tempURL) - return - } - do { - try? FileManager.default.removeItem(at: destURL) - try FileManager.default.moveItem(at: info.tempURL, to: destURL) - NSLog("BrowserPanel download saved: %@", destURL.path) - } catch { - NSLog("BrowserPanel download move failed: %@", error.localizedDescription) - try? FileManager.default.removeItem(at: info.tempURL) - } + let destURL = Self.uniqueDownloadsURL(for: info.suggestedFilename) + do { + try FileManager.default.moveItem(at: info.tempURL, to: destURL) + NSLog("BrowserPanel download saved: %@", destURL.path) + } catch { + NSLog("BrowserPanel download move failed: %@", error.localizedDescription) + try? FileManager.default.removeItem(at: info.tempURL) } } }