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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -332,6 +333,7 @@
1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = "<group>"; };
EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = "<group>"; };
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = "<group>"; };
C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerShellStateDedupTests.swift; sourceTree = "<group>"; };
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -613,6 +615,7 @@
1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */,
EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */,
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */,
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */,
);
Expand Down Expand Up @@ -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 */,
);
Expand Down
24 changes: 23 additions & 1 deletion Sources/GhosttyConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
}
}
48 changes: 29 additions & 19 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions cmuxTests/TerminalControllerShellStateDedupTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
Loading