Merge remote-tracking branch 'origin/main' into pr-ssh-stack-main
# Conflicts: # .github/workflows/ci.yml # CLI/cmux.swift # Sources/GhosttyTerminalView.swift # Sources/SocketControlSettings.swift # Sources/TabManager.swift # Sources/TerminalController.swift # Sources/Workspace.swift # ghostty # scripts/reload.sh
This commit is contained in:
commit
2eae782739
59 changed files with 3285 additions and 468 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -31,6 +31,9 @@ jobs:
|
|||
- name: Validate release asset guard
|
||||
run: node scripts/release_asset_guard.test.js
|
||||
|
||||
- name: Validate current GhosttyKit checksum pin
|
||||
run: ./tests/test_ci_ghosttykit_checksum_present.sh
|
||||
|
||||
remote-daemon-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
3
.github/workflows/nightly.yml
vendored
3
.github/workflows/nightly.yml
vendored
|
|
@ -510,16 +510,13 @@ jobs:
|
|||
cmux-nightly-macos-${{ github.run_id }}*.dmg
|
||||
cmux-nightly-macos.dmg
|
||||
appcast.xml
|
||||
<<<<<<< HEAD
|
||||
remote-daemon-assets/cmuxd-remote-darwin-arm64
|
||||
remote-daemon-assets/cmuxd-remote-darwin-amd64
|
||||
remote-daemon-assets/cmuxd-remote-linux-arm64
|
||||
remote-daemon-assets/cmuxd-remote-linux-amd64
|
||||
remote-daemon-assets/cmuxd-remote-checksums.txt
|
||||
remote-daemon-assets/cmuxd-remote-manifest.json
|
||||
=======
|
||||
appcast-universal.xml
|
||||
>>>>>>> origin/main
|
||||
overwrite_files: true
|
||||
|
||||
- name: Cleanup keychain
|
||||
|
|
|
|||
869
CLI/cmux.swift
869
CLI/cmux.swift
|
|
@ -123,12 +123,12 @@ private final class CLISocketSentryTelemetry {
|
|||
context["socket_errno_description"] = String(cString: strerror(code))
|
||||
}
|
||||
|
||||
let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10)
|
||||
let tmpSockets = Self.discoverSockets(in: "/tmp", limit: 10)
|
||||
if !tmpSockets.isEmpty {
|
||||
context["tmp_cmux_sockets"] = tmpSockets
|
||||
}
|
||||
let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" }
|
||||
if socketPath == "/tmp/cmux.sock",
|
||||
let taggedSockets = tmpSockets.filter { $0 != CLISocketPathResolver.legacyDefaultSocketPath }
|
||||
if CLISocketPathResolver.isImplicitDefaultPath(socketPath),
|
||||
(envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true),
|
||||
!taggedSockets.isEmpty {
|
||||
context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist"
|
||||
|
|
@ -152,14 +152,16 @@ private final class CLISocketSentryTelemetry {
|
|||
}
|
||||
}
|
||||
|
||||
private static func discoverTmpCmuxSockets(limit: Int) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
|
||||
private static func discoverSockets(in directory: String, limit: Int) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
|
||||
return []
|
||||
}
|
||||
var sockets: [String] = []
|
||||
for name in entries.sorted() {
|
||||
guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue }
|
||||
let fullPath = "/tmp/\(name)"
|
||||
let fullPath = URL(fileURLWithPath: directory)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.path
|
||||
var st = stat()
|
||||
guard lstat(fullPath, &st) == 0 else { continue }
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
|
||||
|
|
@ -547,10 +549,24 @@ private enum CLISocketPathSource {
|
|||
}
|
||||
|
||||
private enum CLISocketPathResolver {
|
||||
static let defaultSocketPath = "/tmp/cmux.sock"
|
||||
private static let appSupportDirectoryName = "cmux"
|
||||
private static let stableSocketFileName = "cmux.sock"
|
||||
private static let lastSocketPathFileName = "last-socket-path"
|
||||
static let legacyDefaultSocketPath = "/tmp/cmux.sock"
|
||||
private static let fallbackSocketPath = "/tmp/cmux-debug.sock"
|
||||
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
|
||||
private static let lastSocketPathFile = "/tmp/cmux-last-socket-path"
|
||||
private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
|
||||
|
||||
static var defaultSocketPath: String {
|
||||
let stablePath: String? = stableSocketDirectoryURL()?
|
||||
.appendingPathComponent(stableSocketFileName, isDirectory: false)
|
||||
.path
|
||||
return stablePath ?? legacyDefaultSocketPath
|
||||
}
|
||||
|
||||
static func isImplicitDefaultPath(_ path: String) -> Bool {
|
||||
path == defaultSocketPath || path == legacyDefaultSocketPath
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
requestedPath: String,
|
||||
|
|
@ -586,6 +602,8 @@ private enum CLISocketPathResolver {
|
|||
}
|
||||
|
||||
candidates.append(requestedPath)
|
||||
candidates.append(defaultSocketPath)
|
||||
candidates.append(legacyDefaultSocketPath)
|
||||
candidates.append(fallbackSocketPath)
|
||||
candidates.append(stagingSocketPath)
|
||||
candidates.append(contentsOf: discoverTaggedSockets(limit: 12))
|
||||
|
|
@ -596,33 +614,46 @@ private enum CLISocketPathResolver {
|
|||
}
|
||||
|
||||
private static func readLastSocketPath() -> String? {
|
||||
guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else {
|
||||
return nil
|
||||
let primaryCandidate: String? = stableSocketDirectoryURL()?
|
||||
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
|
||||
.path
|
||||
let candidates = [primaryCandidate, legacyLastSocketPathFile].compactMap { $0 }
|
||||
|
||||
for candidate in candidates {
|
||||
guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else {
|
||||
continue
|
||||
}
|
||||
if let value = normalized(data) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return normalized(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func discoverTaggedSockets(limit: Int) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
|
||||
return []
|
||||
}
|
||||
|
||||
var discovered: [(path: String, mtime: TimeInterval)] = []
|
||||
discovered.reserveCapacity(min(limit, entries.count))
|
||||
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
|
||||
let path = "/tmp/\(name)"
|
||||
var st = stat()
|
||||
guard lstat(path, &st) == 0 else { continue }
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
|
||||
if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
|
||||
for directory in socketDiscoveryDirectories() {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
|
||||
continue
|
||||
}
|
||||
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
|
||||
discovered.append((path: path, mtime: modified))
|
||||
discovered.reserveCapacity(min(limit, discovered.count + entries.count))
|
||||
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
|
||||
let path = URL(fileURLWithPath: directory)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.path
|
||||
var st = stat()
|
||||
guard lstat(path, &st) == 0 else { continue }
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
|
||||
if path == defaultSocketPath || path == legacyDefaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
|
||||
continue
|
||||
}
|
||||
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
|
||||
discovered.append((path: path, mtime: modified))
|
||||
}
|
||||
}
|
||||
|
||||
discovered.sort { $0.mtime > $1.mtime }
|
||||
return discovered.prefix(limit).map(\.path)
|
||||
return dedupe(discovered.prefix(limit).map(\.path))
|
||||
}
|
||||
|
||||
private static func isSocketFile(_ path: String) -> Bool {
|
||||
|
|
@ -669,6 +700,21 @@ private enum CLISocketPathResolver {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func stableSocketDirectoryURL() -> URL? {
|
||||
guard let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
return appSupportDirectory.appendingPathComponent(appSupportDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func socketDiscoveryDirectories() -> [String] {
|
||||
let appSupportSocketDirectory: String = stableSocketDirectoryURL()?.path ?? ""
|
||||
return dedupe([
|
||||
"/tmp",
|
||||
appSupportSocketDirectory,
|
||||
])
|
||||
}
|
||||
|
||||
private static func dedupe(_ paths: [String]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
|
|
@ -946,7 +992,7 @@ struct CMUXCLI {
|
|||
var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
|
||||
var socketPathSource: CLISocketPathSource
|
||||
if let envSocketPath {
|
||||
socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
|
||||
socketPathSource = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment
|
||||
} else {
|
||||
socketPathSource = .implicitDefault
|
||||
}
|
||||
|
|
@ -1079,6 +1125,14 @@ struct CMUXCLI {
|
|||
return
|
||||
}
|
||||
|
||||
if command == "themes" {
|
||||
try runThemes(
|
||||
commandArgs: commandArgs,
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if command == "claude-teams" {
|
||||
try runClaudeTeams(
|
||||
commandArgs: commandArgs,
|
||||
|
|
@ -5283,6 +5337,35 @@ struct CMUXCLI {
|
|||
Double check with the end user before sending anything. Review the message and attachments for secrets,
|
||||
private code, credentials, tokens, and other sensitive information first.
|
||||
"""
|
||||
case "themes":
|
||||
return """
|
||||
Usage: cmux themes
|
||||
cmux themes list
|
||||
cmux themes set <theme>
|
||||
cmux themes set --light <theme> [--dark <theme>]
|
||||
cmux themes set --dark <theme> [--light <theme>]
|
||||
cmux themes clear
|
||||
|
||||
When run in a TTY, `cmux themes` opens an interactive theme picker with
|
||||
live app preview. Use `cmux themes list` for a plain listing.
|
||||
|
||||
The picker previews the selected theme across the running cmux app and
|
||||
lets you apply it to the light theme, dark theme, or both defaults.
|
||||
|
||||
Commands:
|
||||
list List available themes and mark the current light/dark defaults
|
||||
set <theme> Set the same theme for both light and dark appearance
|
||||
set --light <theme> Set the light appearance theme
|
||||
set --dark <theme> Set the dark appearance theme
|
||||
clear Remove the cmux theme override and fall back to other config
|
||||
|
||||
Examples:
|
||||
cmux themes
|
||||
cmux themes list
|
||||
cmux themes set "Catppuccin Mocha"
|
||||
cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha"
|
||||
cmux themes clear
|
||||
"""
|
||||
case "claude-teams":
|
||||
return String(localized: "cli.claude-teams.usage", defaultValue: """
|
||||
Usage: cmux claude-teams [claude-args...]
|
||||
|
|
@ -6381,6 +6464,735 @@ struct CMUXCLI {
|
|||
return true
|
||||
}
|
||||
|
||||
private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app"
|
||||
private static let cmuxThemesBlockStart = "# cmux themes start"
|
||||
private static let cmuxThemesBlockEnd = "# cmux themes end"
|
||||
private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config"
|
||||
|
||||
private struct ThemeSelection {
|
||||
let rawValue: String?
|
||||
let light: String?
|
||||
let dark: String?
|
||||
let sourcePath: String?
|
||||
}
|
||||
|
||||
private struct ThemeReloadStatus {
|
||||
let requested: Bool
|
||||
let targetBundleIdentifier: String
|
||||
}
|
||||
|
||||
private enum ThemePickerTargetMode: String {
|
||||
case both
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool {
|
||||
guard !jsonOutput else { return false }
|
||||
return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
|
||||
}
|
||||
|
||||
private func runInteractiveThemes() throws {
|
||||
guard let helperURL = bundledHelperURL(named: "ghostty") else {
|
||||
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
|
||||
}
|
||||
|
||||
let selection = currentThemeSelection()
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path
|
||||
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
|
||||
environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
|
||||
environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light"
|
||||
if let light = selection.light {
|
||||
environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light
|
||||
}
|
||||
if let dark = selection.dark {
|
||||
environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark
|
||||
}
|
||||
if let resourcesURL = bundledGhosttyResourcesURL() {
|
||||
environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path
|
||||
}
|
||||
|
||||
try execInteractiveHelper(
|
||||
executablePath: helperURL.path,
|
||||
arguments: ["+list-themes"],
|
||||
environment: environment
|
||||
)
|
||||
}
|
||||
|
||||
private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
|
||||
if let light = current.light,
|
||||
let dark = current.dark,
|
||||
light.caseInsensitiveCompare(dark) == .orderedSame {
|
||||
return .both
|
||||
}
|
||||
return defaultAppearancePrefersDarkThemes() ? .dark : .light
|
||||
}
|
||||
|
||||
private func defaultAppearancePrefersDarkThemes() -> Bool {
|
||||
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
|
||||
let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame
|
||||
}
|
||||
|
||||
private func bundledHelperURL(named helperName: String) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
guard let executableURL = resolvedExecutableURL() else { return nil }
|
||||
|
||||
var candidates: [URL] = [
|
||||
executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false)
|
||||
]
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Contents" {
|
||||
candidates.append(
|
||||
current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent(helperName, isDirectory: false)
|
||||
)
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoHelper = current
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("zig-out", isDirectory: true)
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent(helperName, isDirectory: false)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.isExecutableFile(atPath: repoHelper.path) {
|
||||
candidates.append(repoHelper)
|
||||
break
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
|
||||
return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) })
|
||||
}
|
||||
|
||||
private func execInteractiveHelper(
|
||||
executablePath: String,
|
||||
arguments: [String],
|
||||
environment: [String: String]
|
||||
) throws -> Never {
|
||||
var argv = ([executablePath] + arguments).map { strdup($0) }
|
||||
defer {
|
||||
for item in argv {
|
||||
free(item)
|
||||
}
|
||||
}
|
||||
argv.append(nil)
|
||||
|
||||
var envp = environment
|
||||
.map { key, value in strdup("\(key)=\(value)") }
|
||||
defer {
|
||||
for item in envp {
|
||||
free(item)
|
||||
}
|
||||
}
|
||||
envp.append(nil)
|
||||
|
||||
execve(executablePath, &argv, &envp)
|
||||
let code = errno
|
||||
throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))")
|
||||
}
|
||||
|
||||
private func bundledGhosttyResourcesURL() -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
guard let executableURL = resolvedExecutableURL() else { return nil }
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Contents" {
|
||||
let candidate = current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoResources = current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoResources.path) {
|
||||
return repoResources
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
|
||||
return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true)
|
||||
}
|
||||
|
||||
private func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
|
||||
if commandArgs.isEmpty {
|
||||
if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) {
|
||||
try runInteractiveThemes()
|
||||
return
|
||||
}
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
guard let subcommand = commandArgs.first else {
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
if commandArgs.count > 1 {
|
||||
throw CLIError(message: "themes list does not take any positional arguments")
|
||||
}
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
case "set":
|
||||
try runThemesSet(
|
||||
args: Array(commandArgs.dropFirst()),
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
case "clear":
|
||||
if commandArgs.count > 1 {
|
||||
throw CLIError(message: "themes clear does not take any positional arguments")
|
||||
}
|
||||
try runThemesClear(jsonOutput: jsonOutput)
|
||||
default:
|
||||
if subcommand.hasPrefix("-") {
|
||||
throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.")
|
||||
}
|
||||
|
||||
try runThemesSet(
|
||||
args: commandArgs,
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func printThemesList(jsonOutput: Bool) throws {
|
||||
let themes = availableThemeNames()
|
||||
let current = currentThemeSelection()
|
||||
let configPath = try cmuxThemeOverrideConfigURL().path
|
||||
|
||||
if jsonOutput {
|
||||
let currentPayload: [String: Any] = [
|
||||
"raw_value": current.rawValue ?? NSNull(),
|
||||
"light": current.light ?? NSNull(),
|
||||
"dark": current.dark ?? NSNull(),
|
||||
"source_path": current.sourcePath ?? NSNull()
|
||||
]
|
||||
let payload: [String: Any] = [
|
||||
"themes": themes.map { theme in
|
||||
[
|
||||
"name": theme,
|
||||
"current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame,
|
||||
"current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame
|
||||
]
|
||||
},
|
||||
"current": currentPayload,
|
||||
"config_path": configPath
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print("Current light: \(current.light ?? "inherit")")
|
||||
print("Current dark: \(current.dark ?? "inherit")")
|
||||
print("Config: \(configPath)")
|
||||
if let sourcePath = current.sourcePath {
|
||||
print("Source: \(sourcePath)")
|
||||
}
|
||||
print("")
|
||||
|
||||
guard !themes.isEmpty else {
|
||||
print("No themes found.")
|
||||
return
|
||||
}
|
||||
|
||||
for theme in themes {
|
||||
var badges: [String] = []
|
||||
if current.light?.caseInsensitiveCompare(theme) == .orderedSame {
|
||||
badges.append("light")
|
||||
}
|
||||
if current.dark?.caseInsensitiveCompare(theme) == .orderedSame {
|
||||
badges.append("dark")
|
||||
}
|
||||
let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]"
|
||||
print("\(theme)\(badgeText)")
|
||||
}
|
||||
}
|
||||
|
||||
private func runThemesSet(args: [String], jsonOutput: Bool) throws {
|
||||
let (lightOpt, rem0) = parseOption(args, name: "--light")
|
||||
let (darkOpt, rem1) = parseOption(rem0, name: "--dark")
|
||||
|
||||
if let unknown = rem1.first(where: { $0.hasPrefix("--") }) {
|
||||
throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light <theme>, --dark <theme>")
|
||||
}
|
||||
|
||||
let availableThemes = availableThemeNames()
|
||||
let current = currentThemeSelection()
|
||||
|
||||
let lightTheme: String?
|
||||
let darkTheme: String?
|
||||
|
||||
if lightOpt == nil && darkOpt == nil {
|
||||
let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !joinedTheme.isEmpty else {
|
||||
throw CLIError(message: "themes set requires a theme name or --light/--dark flags")
|
||||
}
|
||||
let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes)
|
||||
lightTheme = resolved
|
||||
darkTheme = resolved
|
||||
} else {
|
||||
if !rem1.isEmpty {
|
||||
throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'")
|
||||
}
|
||||
lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light
|
||||
darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark
|
||||
}
|
||||
|
||||
guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else {
|
||||
throw CLIError(message: "themes set requires at least one theme")
|
||||
}
|
||||
|
||||
let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue)
|
||||
let reloadStatus = reloadThemesIfPossible()
|
||||
|
||||
if jsonOutput {
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"light": lightTheme ?? NSNull(),
|
||||
"dark": darkTheme ?? NSNull(),
|
||||
"raw_value": rawThemeValue,
|
||||
"config_path": configURL.path,
|
||||
"reload_requested": reloadStatus.requested,
|
||||
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print(
|
||||
"OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested"
|
||||
)
|
||||
}
|
||||
|
||||
private func runThemesClear(jsonOutput: Bool) throws {
|
||||
let configURL = try clearManagedThemeOverride()
|
||||
let reloadStatus = reloadThemesIfPossible()
|
||||
|
||||
if jsonOutput {
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"cleared": true,
|
||||
"config_path": configURL.path,
|
||||
"reload_requested": reloadStatus.requested,
|
||||
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print("OK cleared config=\(configURL.path) reload=requested")
|
||||
}
|
||||
|
||||
private func currentThemeSelection() -> ThemeSelection {
|
||||
var rawValue: String?
|
||||
var sourcePath: String?
|
||||
|
||||
for url in themeConfigSearchURLs() {
|
||||
guard let contents = try? String(contentsOf: url, encoding: .utf8),
|
||||
let nextValue = lastThemeDirective(in: contents) else {
|
||||
continue
|
||||
}
|
||||
rawValue = nextValue
|
||||
sourcePath = url.path
|
||||
}
|
||||
|
||||
return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection {
|
||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else {
|
||||
return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
var fallbackTheme: String?
|
||||
var lightTheme: String?
|
||||
var darkTheme: String?
|
||||
|
||||
for token in rawValue.split(separator: ",").map(String.init) {
|
||||
let entry = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !entry.isEmpty else { continue }
|
||||
|
||||
let parts = entry.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
if parts.count != 2 {
|
||||
if fallbackTheme == nil {
|
||||
fallbackTheme = entry
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { continue }
|
||||
|
||||
switch key {
|
||||
case "light":
|
||||
if lightTheme == nil {
|
||||
lightTheme = value
|
||||
}
|
||||
case "dark":
|
||||
if darkTheme == nil {
|
||||
darkTheme = value
|
||||
}
|
||||
default:
|
||||
if fallbackTheme == nil {
|
||||
fallbackTheme = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme
|
||||
let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme
|
||||
return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
private func encodedThemeValue(light: String?, dark: String?) -> String? {
|
||||
let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) {
|
||||
case let (lightTheme?, darkTheme?):
|
||||
return "light:\(lightTheme),dark:\(darkTheme)"
|
||||
case let (lightTheme?, nil):
|
||||
return "light:\(lightTheme)"
|
||||
case let (nil, darkTheme?):
|
||||
return "dark:\(darkTheme)"
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func availableThemeNames() -> [String] {
|
||||
let fileManager = FileManager.default
|
||||
var seen: Set<String> = []
|
||||
var themes: [String] = []
|
||||
|
||||
for directoryURL in themeDirectoryURLs() {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
|
||||
guard values?.isDirectory != true else { continue }
|
||||
guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue }
|
||||
let name = entry.lastPathComponent
|
||||
let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
if seen.insert(folded).inserted {
|
||||
themes.append(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func themeDirectoryURLs() -> [URL] {
|
||||
let fileManager = FileManager.default
|
||||
let processEnv = ProcessInfo.processInfo.environment
|
||||
var urls: [URL] = []
|
||||
var seen: Set<String> = []
|
||||
|
||||
func appendIfExisting(_ url: URL?) {
|
||||
guard let url else { return }
|
||||
let standardized = url.standardizedFileURL
|
||||
guard fileManager.fileExists(atPath: standardized.path) else { return }
|
||||
if seen.insert(standardized.path).inserted {
|
||||
urls.append(standardized)
|
||||
}
|
||||
}
|
||||
|
||||
if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!resourcesDir.isEmpty {
|
||||
appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true))
|
||||
}
|
||||
|
||||
appendIfExisting(
|
||||
Bundle.main.resourceURL?
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
|
||||
if let executableURL = resolvedExecutableURL() {
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Resources" {
|
||||
appendIfExisting(
|
||||
current
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
if current.lastPathComponent == "Contents" {
|
||||
appendIfExisting(
|
||||
current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoThemes.path) {
|
||||
appendIfExisting(repoThemes)
|
||||
break
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] {
|
||||
for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) {
|
||||
appendIfExisting(
|
||||
URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true)
|
||||
.appendingPathComponent("ghostty/themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true))
|
||||
appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true))
|
||||
appendIfExisting(
|
||||
URL(
|
||||
fileURLWithPath: NSString(
|
||||
string: "~/Library/Application Support/com.mitchellh.ghostty/themes"
|
||||
).expandingTildeInPath,
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw CLIError(message: "Theme name cannot be empty")
|
||||
}
|
||||
if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
|
||||
return matched
|
||||
}
|
||||
if availableThemes.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.")
|
||||
}
|
||||
|
||||
private func themeConfigSearchURLs() -> [URL] {
|
||||
let rawPaths = [
|
||||
"~/.config/ghostty/config",
|
||||
"~/.config/ghostty/config.ghostty",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config",
|
||||
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty",
|
||||
]
|
||||
|
||||
return rawPaths.map {
|
||||
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func lastThemeDirective(in contents: String) -> String? {
|
||||
var lastValue: String?
|
||||
|
||||
for line in contents.components(separatedBy: .newlines) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
continue
|
||||
}
|
||||
|
||||
let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else { continue }
|
||||
guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue }
|
||||
|
||||
let value = parts[1]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
if !value.isEmpty {
|
||||
lastValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return lastValue
|
||||
}
|
||||
|
||||
private func cmuxThemeOverrideConfigURL() throws -> URL {
|
||||
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
throw CLIError(message: "Unable to resolve Application Support directory")
|
||||
}
|
||||
return appSupport
|
||||
.appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true)
|
||||
.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
}
|
||||
|
||||
private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let configURL = try cmuxThemeOverrideConfigURL()
|
||||
let directoryURL = configURL.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let existingContents = try readOptionalThemeOverrideContents(at: configURL) ?? ""
|
||||
let strippedContents = removingManagedThemeOverride(from: existingContents)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let block = """
|
||||
\(Self.cmuxThemesBlockStart)
|
||||
theme = \(rawThemeValue)
|
||||
\(Self.cmuxThemesBlockEnd)
|
||||
"""
|
||||
|
||||
let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n"
|
||||
try nextContents.write(to: configURL, atomically: true, encoding: .utf8)
|
||||
return configURL
|
||||
}
|
||||
|
||||
private func clearManagedThemeOverride() throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let configURL = try cmuxThemeOverrideConfigURL()
|
||||
guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else {
|
||||
return configURL
|
||||
}
|
||||
|
||||
let strippedContents = removingManagedThemeOverride(from: existingContents)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if strippedContents.isEmpty {
|
||||
do {
|
||||
try fileManager.removeItem(at: configURL)
|
||||
} catch {
|
||||
guard !isThemeOverrideFileNotFoundError(error) else {
|
||||
return configURL
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
return configURL
|
||||
}
|
||||
|
||||
private func readOptionalThemeOverrideContents(at url: URL) throws -> String? {
|
||||
do {
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
} catch {
|
||||
guard isThemeOverrideFileNotFoundError(error) else {
|
||||
throw error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain {
|
||||
return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError
|
||||
}
|
||||
if nsError.domain == NSPOSIXErrorDomain {
|
||||
return nsError.code == ENOENT
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func removingManagedThemeOverride(from contents: String) -> String {
|
||||
let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
||||
return contents
|
||||
}
|
||||
let fullRange = NSRange(contents.startIndex..<contents.endIndex, in: contents)
|
||||
return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "")
|
||||
}
|
||||
|
||||
private func reloadThemesIfPossible() -> ThemeReloadStatus {
|
||||
let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
|
||||
DistributedNotificationCenter.default().post(
|
||||
name: Notification.Name(Self.cmuxThemesReloadNotificationName),
|
||||
object: nil,
|
||||
userInfo: ["bundleIdentifier": bundleIdentifier]
|
||||
)
|
||||
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
private func currentCmuxAppBundleIdentifier() -> String? {
|
||||
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
guard let executableURL = resolvedExecutableURL() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.pathExtension == "app",
|
||||
let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
if current.lastPathComponent == "Contents" {
|
||||
let appURL = current.deletingLastPathComponent().standardizedFileURL
|
||||
if appURL.pathExtension == "app",
|
||||
let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Escape and quote a string for safe embedding in a v1 socket command.
|
||||
/// The socket tokenizer treats `\` and `"` as special inside quoted strings,
|
||||
/// so both must be escaped before wrapping in double quotes. Newlines and
|
||||
/// carriage returns must also be escaped since the socket protocol uses
|
||||
/// newline as the message terminator.
|
||||
private func socketQuote(_ s: String) -> String {
|
||||
let escaped = s
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "\\r")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
|
||||
var remaining: [String] = []
|
||||
var value: String?
|
||||
|
|
@ -7647,7 +8459,7 @@ struct CMUXCLI {
|
|||
let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
|
||||
let source: CLISocketPathSource
|
||||
if let envSocketPath {
|
||||
source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
|
||||
source = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment
|
||||
} else {
|
||||
source = .implicitDefault
|
||||
}
|
||||
|
|
@ -9450,6 +10262,7 @@ struct CMUXCLI {
|
|||
welcome
|
||||
shortcuts
|
||||
feedback [--email <email> --body <text> [--image <path> ...]]
|
||||
themes [list|set|clear]
|
||||
claude-teams [claude-args...]
|
||||
ping
|
||||
version
|
||||
|
|
@ -9566,7 +10379,7 @@ struct CMUXCLI {
|
|||
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
|
||||
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
|
||||
CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults
|
||||
to /tmp/cmux.sock and auto-discovers tagged/debug sockets.
|
||||
to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
|
|||
|
||||
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
|
||||
|
||||
Report nightly bugs on [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) or in [#nightly-bugs on Discord](https://discord.gg/xsgFEVrWCZ).
|
||||
|
||||
## Session restore (current behavior)
|
||||
|
||||
On relaunch, cmux currently restores app layout and metadata only:
|
||||
|
|
|
|||
|
|
@ -93,15 +93,27 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.splittabbar.tabtransfer</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Bonsplit Tab Transfer</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.cmux.sidebar-tab-reorder</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>cmux Sidebar Tab Reorder</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
|
|
|||
|
|
@ -845,13 +845,13 @@
|
|||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Welcome"
|
||||
"value": "Welcome to cmux!"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ようこそ"
|
||||
"value": "cmuxへようこそ!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -873,6 +873,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.discord": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Discord"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Discord"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.githubIssues": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -35131,6 +35148,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"menu.openInVSCodeDesktop": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Current Directory in VS Code"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "現在のディレクトリを VS Code で開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu.openInWarp": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -40346,6 +40380,119 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.app.appIcon.subtitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock and app switcher"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dockとアプリスイッチャー"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "程序坞和应用切换器"
|
||||
}
|
||||
},
|
||||
"zh-Hant": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock 和 App 切換器"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock 및 앱 전환기"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock und App-Umschalter"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock y selector de apps"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock et sélecteur d'apps"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock e selettore app"
|
||||
}
|
||||
},
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock og appskifter"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock i przełącznik aplikacji"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock и переключатель приложений"
|
||||
}
|
||||
},
|
||||
"bs": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock i prebacivač aplikacija"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "شريط Dock ومبدّل التطبيقات"
|
||||
}
|
||||
},
|
||||
"nb": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock og appbytter"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock e alternador de apps"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock และตัวสลับแอป"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dock ve uygulama değiştirici"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.dockBadge": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
|
|||
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
|
||||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
||||
|
||||
|
|
@ -103,6 +104,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -291,10 +305,33 @@ _cmux_bash_cleanup() {
|
|||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_cmux_preexec_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
_cmux_report_shell_activity_state running
|
||||
_cmux_report_tty_once
|
||||
_cmux_ports_kick
|
||||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_cmux_bash_preexec_hook() {
|
||||
_cmux_preexec_command
|
||||
}
|
||||
|
||||
_cmux_prompt_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
|
|
@ -439,6 +476,17 @@ _cmux_install_prompt_command() {
|
|||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
|
||||
builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }'
|
||||
else
|
||||
builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)'
|
||||
fi
|
||||
if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then
|
||||
PS0=$PS0"${_CMUX_BASH_PS0}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure Resources/bin is at the front of PATH, and remove the app's
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
|
|||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
typeset -g _CMUX_TTY_REPORTED=0
|
||||
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
|
||||
|
|
@ -204,6 +205,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -455,6 +469,7 @@ _cmux_preexec() {
|
|||
fi
|
||||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
_cmux_report_shell_activity_state running
|
||||
|
||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
|
|
@ -478,6 +493,7 @@ _cmux_precmd() {
|
|||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
# Handle cases where Ghostty integration initializes after this file.
|
||||
_cmux_patch_ghostty_semantic_redraw
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import Combine
|
|||
import ObjectiveC.runtime
|
||||
import Darwin
|
||||
|
||||
private enum CmuxThemeNotifications {
|
||||
static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
enum CmuxTypingTiming {
|
||||
static let isEnabled: Bool = {
|
||||
|
|
@ -392,6 +396,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case terminal
|
||||
case tower
|
||||
case vscode
|
||||
case vscodeInline
|
||||
case warp
|
||||
case windsurf
|
||||
case xcode
|
||||
|
|
@ -442,6 +447,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case .tower:
|
||||
return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower")
|
||||
case .vscode:
|
||||
return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code")
|
||||
case .vscodeInline:
|
||||
return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)")
|
||||
case .warp:
|
||||
return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp")
|
||||
|
|
@ -474,6 +481,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
case .tower:
|
||||
return common + ["tower", "git", "client"]
|
||||
case .vscode:
|
||||
return common + ["vs", "code", "visual", "studio", "desktop", "app"]
|
||||
case .vscodeInline:
|
||||
return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"]
|
||||
case .warp:
|
||||
return common + ["warp", "terminal", "shell"]
|
||||
|
|
@ -488,7 +497,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
|
||||
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
|
||||
guard let applicationPath = applicationPath(in: environment) else { return false }
|
||||
guard self == .vscode else { return true }
|
||||
guard self == .vscodeInline else { return true }
|
||||
return VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
|
||||
vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true),
|
||||
isExecutableAtPath: environment.isExecutableFileAtPath
|
||||
|
|
@ -553,6 +562,11 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
|||
"/Applications/Visual Studio Code.app",
|
||||
"/Applications/Code.app",
|
||||
]
|
||||
case .vscodeInline:
|
||||
return [
|
||||
"/Applications/Visual Studio Code.app",
|
||||
"/Applications/Code.app",
|
||||
]
|
||||
case .warp:
|
||||
return ["/Applications/Warp.app"]
|
||||
case .windsurf:
|
||||
|
|
@ -2119,6 +2133,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let isRunningUnderXCTest = isRunningUnderXCTest(env)
|
||||
let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(handleThemesReloadNotification(_:)),
|
||||
name: CmuxThemeNotifications.reloadConfig,
|
||||
object: nil,
|
||||
suspensionBehavior: .deliverImmediately
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
// UI tests run on a shared VM user profile, so persisted shortcuts can drift and make
|
||||
// key-equivalent routing flaky. Force defaults for deterministic tests.
|
||||
|
|
@ -2924,13 +2946,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func restartSocketListenerIfEnabled(source: String) {
|
||||
guard let tabManager,
|
||||
let config = socketListenerConfigurationIfEnabled() else { return }
|
||||
let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
|
||||
sentryBreadcrumb("socket.listener.restart", category: "socket", data: [
|
||||
"mode": config.mode.rawValue,
|
||||
"path": config.path,
|
||||
"path": restartPath,
|
||||
"source": source
|
||||
])
|
||||
TerminalController.shared.stop()
|
||||
TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode)
|
||||
TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode)
|
||||
}
|
||||
|
||||
private func startSocketListenerHealthMonitorIfNeeded() {
|
||||
|
|
@ -2958,8 +2981,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func restartSocketListenerIfNeededForHealthCheck(source: String) {
|
||||
guard !socketListenerHealthCheckInFlight,
|
||||
let config = socketListenerConfigurationIfEnabled() else { return }
|
||||
let expectedSocketPath = config.path
|
||||
let terminalController = TerminalController.shared
|
||||
let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path)
|
||||
socketListenerHealthCheckInFlight = true
|
||||
Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in
|
||||
let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath)
|
||||
|
|
@ -2980,8 +3003,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
source: String,
|
||||
expectedSocketPath: String
|
||||
) {
|
||||
guard let config = socketListenerConfigurationIfEnabled(),
|
||||
config.path == expectedSocketPath else { return }
|
||||
guard let config = socketListenerConfigurationIfEnabled() else { return }
|
||||
let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
|
||||
guard currentExpectedSocketPath == expectedSocketPath else { return }
|
||||
guard !health.isHealthy else {
|
||||
lastSocketListenerUnhealthyCaptureAt = .distantPast
|
||||
return
|
||||
|
|
@ -2989,7 +3013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let failureSignals = health.failureSignals
|
||||
var data: [String: Any] = [
|
||||
"source": source,
|
||||
"path": config.path,
|
||||
"path": currentExpectedSocketPath,
|
||||
"isRunning": health.isRunning ? 1 : 0,
|
||||
"acceptLoopAlive": health.acceptLoopAlive ? 1 : 0,
|
||||
"socketPathMatches": health.socketPathMatches ? 1 : 0,
|
||||
|
|
@ -4578,6 +4602,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
|
||||
}
|
||||
|
||||
private func resolvedWindow(for context: MainWindowContext) -> NSWindow? {
|
||||
guard let window = context.window ?? windowForMainWindowId(context.windowId) else {
|
||||
return nil
|
||||
}
|
||||
context.window = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func mainWindowId(from window: NSWindow) -> UUID? {
|
||||
guard let raw = window.identifier?.rawValue else { return nil }
|
||||
let prefix = "cmux.main."
|
||||
|
|
@ -4655,6 +4687,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return removed
|
||||
}
|
||||
|
||||
private func discardOrphanedMainWindowContext(_ context: MainWindowContext) {
|
||||
let contextKeys = mainWindowContexts.compactMap { key, value in
|
||||
value === context ? key : nil
|
||||
}
|
||||
for key in contextKeys {
|
||||
mainWindowContexts.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteEscapeSuppressionByWindowId.remove(context.windowId)
|
||||
commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId)
|
||||
commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId)
|
||||
|
||||
if tabManager === context.tabManager {
|
||||
if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) {
|
||||
tabManager = nextContext.tabManager
|
||||
sidebarState = nextContext.sidebarState
|
||||
sidebarSelectionState = nextContext.sidebarSelectionState
|
||||
TerminalController.shared.setActiveTabManager(nextContext.tabManager)
|
||||
} else {
|
||||
tabManager = nil
|
||||
sidebarState = nil
|
||||
sidebarSelectionState = nil
|
||||
TerminalController.shared.setActiveTabManager(nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let store = notificationStore {
|
||||
for tab in context.tabManager.tabs {
|
||||
store.clearNotifications(forTabId: tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mainWindowId(for window: NSWindow) -> UUID? {
|
||||
if let context = mainWindowContexts[ObjectIdentifier(window)] {
|
||||
return context.windowId
|
||||
|
|
@ -5080,11 +5149,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#endif
|
||||
return nil
|
||||
}
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
guard let window = resolvedWindow(for: context) else {
|
||||
#if DEBUG
|
||||
logWorkspaceCreationRouting(
|
||||
phase: "no_context",
|
||||
source: debugSource,
|
||||
reason: "context_window_missing",
|
||||
event: event,
|
||||
chosenContext: context,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
#endif
|
||||
discardOrphanedMainWindowContext(context)
|
||||
return nil
|
||||
}
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
|
||||
let workspace: Workspace
|
||||
|
|
@ -5173,7 +5254,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
let fallback = mainWindowContexts.values.first
|
||||
let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil })
|
||||
#if DEBUG
|
||||
logWorkspaceCreationRouting(
|
||||
phase: "choose",
|
||||
|
|
@ -9901,7 +9982,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
context.sidebarSelectionState.selection = .tabs
|
||||
bringToFront(window)
|
||||
context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
||||
guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else {
|
||||
#if DEBUG
|
||||
recordMultiWindowNotificationOpenFailureIfNeeded(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
notificationId: notificationId,
|
||||
reason: "focus_failed"
|
||||
)
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
||||
writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"])
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// UI test support: Jump-to-unread asserts that the correct workspace/panel is focused.
|
||||
|
|
@ -9966,7 +10060,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
sidebarSelectionState?.selection = .tabs
|
||||
bringToFront(window)
|
||||
tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
||||
guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
||||
writeJumpUnreadTestData([
|
||||
"jumpUnreadFallbackFail": "focus_failed",
|
||||
"jumpUnreadOpenResult": "0",
|
||||
])
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
recordJumpUnreadFocusFromModelIfNeeded(
|
||||
|
|
@ -10812,6 +10916,14 @@ private extension NSApplication {
|
|||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
@objc func handleThemesReloadNotification(_ notification: Notification) {
|
||||
DispatchQueue.main.async {
|
||||
GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSWindow {
|
||||
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
|
||||
if cmuxIsWindowFirstResponderBypassActive() {
|
||||
|
|
@ -11422,4 +11534,5 @@ private extension NSWindow {
|
|||
}
|
||||
return hitWebView === webView
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4754,7 +4754,7 @@ struct ContentView: View {
|
|||
keywords: ["vscode", "inline", "serve-web", "stop", "server"],
|
||||
when: { context in
|
||||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -4766,7 +4766,7 @@ struct ContentView: View {
|
|||
keywords: ["vscode", "inline", "serve-web", "restart", "server"],
|
||||
when: { context in
|
||||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -6227,7 +6227,7 @@ struct ContentView: View {
|
|||
case .finder:
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
|
||||
return true
|
||||
case .vscode:
|
||||
case .vscodeInline:
|
||||
return openFocusedDirectoryInInlineVSCode(directoryURL)
|
||||
default:
|
||||
guard let applicationURL = target.applicationURL() else { return false }
|
||||
|
|
@ -6238,7 +6238,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(),
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL(),
|
||||
let workspace = tabManager.selectedWorkspace,
|
||||
let sourcePanelId = workspace.focusedPanelId else {
|
||||
return false
|
||||
|
|
@ -6274,7 +6274,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func restartInlineVSCodeServeWeb() -> Bool {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else {
|
||||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else {
|
||||
return false
|
||||
}
|
||||
VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
|
||||
|
|
@ -8486,6 +8486,7 @@ private enum SidebarHelpMenuAction {
|
|||
case changelog
|
||||
case github
|
||||
case githubIssues
|
||||
case discord
|
||||
case checkForUpdates
|
||||
case sendFeedback
|
||||
case welcome
|
||||
|
|
@ -8986,6 +8987,7 @@ private struct SidebarHelpMenuButton: View {
|
|||
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
|
||||
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
|
||||
private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues")
|
||||
private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ")
|
||||
private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help")
|
||||
private let buttonSize: CGFloat = 22
|
||||
private let iconSize: CGFloat = 11
|
||||
|
|
@ -9030,7 +9032,7 @@ private struct SidebarHelpMenuButton: View {
|
|||
private var helpPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to cmux!"),
|
||||
action: .welcome,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
|
||||
isExternalLink: false
|
||||
|
|
@ -9081,6 +9083,14 @@ private struct SidebarHelpMenuButton: View {
|
|||
isExternalLink: true
|
||||
)
|
||||
}
|
||||
if discordURL != nil {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.discord", defaultValue: "Discord"),
|
||||
action: .discord,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionDiscord",
|
||||
isExternalLink: true
|
||||
)
|
||||
}
|
||||
helpOptionButton(
|
||||
title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"),
|
||||
action: .checkForUpdates,
|
||||
|
|
@ -9171,6 +9181,9 @@ private struct SidebarHelpMenuButton: View {
|
|||
case .githubIssues:
|
||||
guard let githubIssuesURL else { return }
|
||||
NSWorkspace.shared.open(githubIssuesURL)
|
||||
case .discord:
|
||||
guard let discordURL else { return }
|
||||
NSWorkspace.shared.open(discordURL)
|
||||
case .checkForUpdates:
|
||||
Task { @MainActor in
|
||||
AppDelegate.shared?.checkForUpdates(nil)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct GhosttyConfig {
|
|||
case dark
|
||||
}
|
||||
|
||||
private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app"
|
||||
private static let loadCacheLock = NSLock()
|
||||
private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:]
|
||||
|
||||
|
|
@ -87,6 +88,52 @@ struct GhosttyConfig {
|
|||
loadCacheLock.unlock()
|
||||
}
|
||||
|
||||
private static func cmuxConfigPaths(
|
||||
fileManager: FileManager = .default,
|
||||
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier
|
||||
) -> [String] {
|
||||
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return []
|
||||
}
|
||||
|
||||
func paths(for bundleIdentifier: String) -> [String] {
|
||||
let directory = appSupport.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
return [
|
||||
directory.appendingPathComponent("config", isDirectory: false).path,
|
||||
directory.appendingPathComponent("config.ghostty", isDirectory: false).path,
|
||||
]
|
||||
}
|
||||
|
||||
func hasConfig(_ paths: [String]) -> Bool {
|
||||
paths.contains { path in
|
||||
guard let attributes = try? fileManager.attributesOfItem(atPath: path),
|
||||
let type = attributes[.type] as? FileAttributeType,
|
||||
type == .typeRegular,
|
||||
let size = attributes[.size] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return size.intValue > 0
|
||||
}
|
||||
}
|
||||
|
||||
let releasePaths = paths(for: cmuxReleaseBundleIdentifier)
|
||||
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else {
|
||||
return releasePaths
|
||||
}
|
||||
if currentBundleIdentifier == cmuxReleaseBundleIdentifier {
|
||||
return releasePaths
|
||||
}
|
||||
|
||||
let currentPaths = paths(for: currentBundleIdentifier)
|
||||
if hasConfig(currentPaths) {
|
||||
return currentPaths
|
||||
}
|
||||
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
||||
return releasePaths
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
|
||||
var config = GhosttyConfig()
|
||||
|
||||
|
|
@ -96,7 +143,7 @@ struct GhosttyConfig {
|
|||
"~/.config/ghostty/config.ghostty",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
].map { NSString(string: $0).expandingTildeInPath }
|
||||
].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths()
|
||||
|
||||
for path in configPaths {
|
||||
if let contents = readConfigFile(at: path) {
|
||||
|
|
|
|||
|
|
@ -1208,9 +1208,9 @@ class GhosttyApp {
|
|||
|
||||
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
|
||||
ghostty_config_load_default_files(config)
|
||||
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
|
||||
loadLegacyGhosttyConfigIfNeeded(config)
|
||||
ghostty_config_load_recursive_files(config)
|
||||
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
||||
loadCJKFontFallbackIfNeeded(config)
|
||||
ghostty_config_finalize(config)
|
||||
}
|
||||
|
|
@ -1411,20 +1411,40 @@ class GhosttyApp {
|
|||
return true
|
||||
}
|
||||
|
||||
static func shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
static func cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: String?,
|
||||
currentConfigFileSize: Int?,
|
||||
currentLegacyConfigFileSize: Int?,
|
||||
releaseConfigFileSize: Int?,
|
||||
releaseLegacyConfigFileSize: Int?
|
||||
) -> Bool {
|
||||
guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false }
|
||||
appSupportDirectory: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> [URL] {
|
||||
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] }
|
||||
|
||||
let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0
|
||||
guard !hasCurrentAppSupportConfig else { return false }
|
||||
func existingConfigURLs(for bundleIdentifier: String) -> [URL] {
|
||||
let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
return [
|
||||
directory.appendingPathComponent("config", isDirectory: false),
|
||||
directory.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
].filter { url in
|
||||
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
||||
let type = attrs[.type] as? FileAttributeType,
|
||||
type == .typeRegular,
|
||||
let size = attrs[.size] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return size.intValue > 0
|
||||
}
|
||||
}
|
||||
|
||||
let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0
|
||||
return hasReleaseAppSupportConfig
|
||||
let currentURLs = existingConfigURLs(for: currentBundleIdentifier)
|
||||
if !currentURLs.isEmpty {
|
||||
return currentURLs
|
||||
}
|
||||
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
||||
let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier)
|
||||
if !releaseURLs.isEmpty {
|
||||
return releaseURLs
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func shouldApplyDefaultBackgroundUpdate(
|
||||
|
|
@ -1464,54 +1484,30 @@ class GhosttyApp {
|
|||
return true
|
||||
}
|
||||
|
||||
private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
|
||||
private func loadCmuxAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
|
||||
#if os(macOS)
|
||||
let fm = FileManager.default
|
||||
guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
|
||||
guard let currentBundleIdentifier = Bundle.main.bundleIdentifier,
|
||||
!currentBundleIdentifier.isEmpty else { return }
|
||||
|
||||
let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true)
|
||||
let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true)
|
||||
let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false)
|
||||
let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false)
|
||||
|
||||
func fileSize(_ url: URL) -> Int? {
|
||||
guard let attrs = try? fm.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber else { return nil }
|
||||
return size.intValue
|
||||
}
|
||||
|
||||
let releaseConfigSize = fileSize(releaseConfig)
|
||||
let releaseLegacyConfigSize = fileSize(releaseLegacyConfig)
|
||||
|
||||
guard Self.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
let urls = Self.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: currentBundleIdentifier,
|
||||
currentConfigFileSize: fileSize(currentConfig),
|
||||
currentLegacyConfigFileSize: fileSize(currentLegacyConfig),
|
||||
releaseConfigFileSize: releaseConfigSize,
|
||||
releaseLegacyConfigFileSize: releaseLegacyConfigSize
|
||||
) else { return }
|
||||
|
||||
if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 {
|
||||
releaseLegacyConfig.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
if let releaseConfigSize, releaseConfigSize > 0 {
|
||||
releaseConfig.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Self.initLog(
|
||||
"loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)"
|
||||
appSupportDirectory: appSupport,
|
||||
fileManager: fm
|
||||
)
|
||||
#endif
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
for url in urls {
|
||||
url.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))"
|
||||
)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -2517,6 +2513,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private let workingDirectory: String?
|
||||
private let initialCommand: String?
|
||||
private let initialEnvironmentOverrides: [String: String]
|
||||
var requestedWorkingDirectory: String? { workingDirectory }
|
||||
private var additionalEnvironment: [String: String]
|
||||
let hostedView: GhosttySurfaceScrollView
|
||||
private let surfaceView: GhosttyNSView
|
||||
private var lastPixelWidth: UInt32 = 0
|
||||
|
|
@ -2528,6 +2526,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private let maxPendingTextBytes = 1_048_576
|
||||
private var backgroundSurfaceStartQueued = false
|
||||
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
|
||||
#if DEBUG
|
||||
private var needsConfirmCloseOverrideForTesting: Bool?
|
||||
#endif
|
||||
private enum PortalLifecycleState: String {
|
||||
case live
|
||||
case closing
|
||||
|
|
@ -2595,10 +2596,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
|
||||
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(
|
||||
base: additionalEnvironment,
|
||||
overrides: initialEnvironmentOverrides
|
||||
)
|
||||
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides)
|
||||
self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment)
|
||||
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
|
||||
// has non-zero bounds and the renderer can initialize without presenting a blank/stretched
|
||||
// intermediate frame on the first real resize.
|
||||
|
|
@ -3061,6 +3060,34 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
env["ZDOTDIR"] = integrationDir
|
||||
} else if shellName == "bash" {
|
||||
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
||||
env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1"
|
||||
}
|
||||
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
|
||||
// integration is unsupported and HOME-based wrapper startup is
|
||||
// not reliable. Bootstrap cmux bash integration on the first
|
||||
// interactive prompt instead.
|
||||
env["PROMPT_COMMAND"] = """
|
||||
unset PROMPT_COMMAND; \
|
||||
if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
|
||||
_cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
|
||||
[[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \
|
||||
fi; \
|
||||
if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \
|
||||
_cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \
|
||||
[[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \
|
||||
fi; \
|
||||
unset _cmux_ghostty_bash _cmux_bash_integration; \
|
||||
if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
let startupEnvironment = additionalEnvironment
|
||||
if !startupEnvironment.isEmpty {
|
||||
for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty && !key.hasPrefix("CMUX_") {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3140,6 +3167,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
guard let createdSurface = surface else { return }
|
||||
|
||||
// Session scrollback replay must be one-shot. Reusing it on a later runtime
|
||||
// surface recreation would inject stale restored output into a live shell.
|
||||
additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey)
|
||||
|
||||
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
|
||||
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
|
||||
// renderer can believe vsync is "running" but never deliver frames, which looks like a
|
||||
|
|
@ -3320,6 +3351,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func needsConfirmClose() -> Bool {
|
||||
#if DEBUG
|
||||
if let needsConfirmCloseOverrideForTesting {
|
||||
return needsConfirmCloseOverrideForTesting
|
||||
}
|
||||
#endif
|
||||
guard let surface = surface else { return false }
|
||||
return ghostty_surface_needs_confirm_quit(surface)
|
||||
}
|
||||
|
|
@ -3438,6 +3474,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) {
|
||||
needsConfirmCloseOverrideForTesting = value
|
||||
}
|
||||
|
||||
/// Test-only helper to deterministically simulate a released runtime surface.
|
||||
@MainActor
|
||||
func releaseSurfaceForTesting() {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
surface.hostedView
|
||||
}
|
||||
|
||||
var requestedWorkingDirectory: String? {
|
||||
surface.requestedWorkingDirectory
|
||||
}
|
||||
|
||||
init(workspaceId: UUID, surface: TerminalSurface) {
|
||||
self.id = surface.id
|
||||
self.workspaceId = workspaceId
|
||||
|
|
@ -95,34 +99,13 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
configTemplate: configTemplate,
|
||||
workingDirectory: workingDirectory,
|
||||
initialCommand: initialCommand,
|
||||
initialEnvironmentOverrides: Self.mergedNormalizedEnvironment(
|
||||
base: additionalEnvironment,
|
||||
overrides: initialEnvironmentOverrides
|
||||
)
|
||||
initialEnvironmentOverrides: initialEnvironmentOverrides,
|
||||
additionalEnvironment: additionalEnvironment
|
||||
)
|
||||
surface.portOrdinal = portOrdinal
|
||||
self.init(workspaceId: workspaceId, surface: surface)
|
||||
}
|
||||
|
||||
private static func mergedNormalizedEnvironment(
|
||||
base: [String: String],
|
||||
overrides: [String: String]
|
||||
) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
merged.reserveCapacity(base.count + overrides.count)
|
||||
for (rawKey, value) in base {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func updateTitle(_ newTitle: String) {
|
||||
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty && title != trimmed {
|
||||
|
|
@ -211,6 +194,12 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
surface.needsConfirmClose()
|
||||
}
|
||||
|
||||
func shouldPersistScrollbackForSessionSnapshot() -> Bool {
|
||||
// Session restore only replays terminal output into a fresh shell. If Ghostty
|
||||
// says we are not safely at a prompt, replaying that state later is misleading.
|
||||
!surface.needsConfirmClose()
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import Darwin
|
||||
import Foundation
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
|
|
@ -292,6 +293,26 @@ struct SocketControlSettings {
|
|||
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
||||
static let launchTagEnvKey = "CMUX_TAG"
|
||||
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
|
||||
private static let socketDirectoryName = "cmux"
|
||||
private static let stableSocketFileName = "cmux.sock"
|
||||
private static let lastSocketPathFileName = "last-socket-path"
|
||||
static let legacyStableDefaultSocketPath = "/tmp/cmux.sock"
|
||||
static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
|
||||
|
||||
static var stableDefaultSocketPath: String {
|
||||
stableSocketFileURL()?.path ?? legacyStableDefaultSocketPath
|
||||
}
|
||||
|
||||
static var lastSocketPathFile: String {
|
||||
lastSocketPathFileURL()?.path ?? legacyLastSocketPathFile
|
||||
}
|
||||
|
||||
enum StableDefaultSocketPathEntry: Equatable {
|
||||
case missing
|
||||
case socket(ownerUserID: uid_t)
|
||||
case other(ownerUserID: uid_t)
|
||||
case inaccessible(errnoCode: Int32)
|
||||
}
|
||||
|
||||
private static func normalizeMode(_ raw: String) -> String {
|
||||
raw
|
||||
|
|
@ -402,9 +423,16 @@ struct SocketControlSettings {
|
|||
static func socketPath(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild,
|
||||
currentUserID: uid_t = getuid(),
|
||||
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
||||
) -> String {
|
||||
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
|
||||
let fallback = defaultSocketPath(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
isDebugBuild: isDebugBuild,
|
||||
currentUserID: currentUserID,
|
||||
probeStableDefaultPathEntry: probeStableDefaultPathEntry
|
||||
)
|
||||
|
||||
if let taggedDebugPath = taggedDebugSocketPath(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
|
|
@ -433,7 +461,12 @@ struct SocketControlSettings {
|
|||
return fallback
|
||||
}
|
||||
|
||||
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
|
||||
static func defaultSocketPath(
|
||||
bundleIdentifier: String?,
|
||||
isDebugBuild: Bool,
|
||||
currentUserID: uid_t = getuid(),
|
||||
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
||||
) -> String {
|
||||
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
|
||||
return taggedDebugPath
|
||||
}
|
||||
|
|
@ -446,7 +479,38 @@ struct SocketControlSettings {
|
|||
if isStagingBundleIdentifier(bundleIdentifier) {
|
||||
return "/tmp/cmux-staging.sock"
|
||||
}
|
||||
return "/tmp/cmux.sock"
|
||||
return resolvedStableDefaultSocketPath(
|
||||
currentUserID: currentUserID,
|
||||
probeStableDefaultPathEntry: probeStableDefaultPathEntry
|
||||
)
|
||||
}
|
||||
|
||||
static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String {
|
||||
stableSocketDirectoryURL()?
|
||||
.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false)
|
||||
.path ?? "/tmp/cmux-\(currentUserID).sock"
|
||||
}
|
||||
|
||||
static func resolvedStableDefaultSocketPath(
|
||||
currentUserID: uid_t = getuid(),
|
||||
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
||||
) -> String {
|
||||
switch probeStableDefaultPathEntry(stableDefaultSocketPath) {
|
||||
case .missing:
|
||||
return stableDefaultSocketPath
|
||||
case .socket(let ownerUserID) where ownerUserID == currentUserID:
|
||||
return stableDefaultSocketPath
|
||||
case .socket, .other, .inaccessible:
|
||||
return userScopedStableSocketPath(currentUserID: currentUserID)
|
||||
}
|
||||
}
|
||||
|
||||
static func recordLastSocketPath(_ path: String, filePath: String = lastSocketPathFile) {
|
||||
let payload = Data((path + "\n").utf8)
|
||||
writeSocketPathMarker(payload, to: filePath)
|
||||
if filePath != legacyLastSocketPathFile {
|
||||
writeSocketPathMarker(payload, to: legacyLastSocketPathFile)
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldHonorSocketPathOverride(
|
||||
|
|
@ -506,6 +570,51 @@ struct SocketControlSettings {
|
|||
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
|
||||
}
|
||||
|
||||
static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? {
|
||||
guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
return appSupportDirectory.appendingPathComponent(socketDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
static func stableSocketFileURL(fileManager: FileManager = .default) -> URL? {
|
||||
stableSocketDirectoryURL(fileManager: fileManager)?
|
||||
.appendingPathComponent(stableSocketFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
static func lastSocketPathFileURL(fileManager: FileManager = .default) -> URL? {
|
||||
stableSocketDirectoryURL(fileManager: fileManager)?
|
||||
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func writeSocketPathMarker(_ payload: Data, to filePath: String) {
|
||||
let fileURL = URL(fileURLWithPath: filePath)
|
||||
let parentURL = fileURL.deletingLastPathComponent()
|
||||
try? FileManager.default.createDirectory(
|
||||
at: parentURL,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
try? payload.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
private static func inspectStableDefaultSocketPathEntry(_ path: String) -> StableDefaultSocketPathEntry {
|
||||
var st = stat()
|
||||
guard lstat(path, &st) == 0 else {
|
||||
let errnoCode = errno
|
||||
if errnoCode == ENOENT {
|
||||
return .missing
|
||||
}
|
||||
return .inaccessible(errnoCode: errnoCode)
|
||||
}
|
||||
|
||||
let fileType = st.st_mode & mode_t(S_IFMT)
|
||||
if fileType == mode_t(S_IFSOCK) {
|
||||
return .socket(ownerUserID: st.st_uid)
|
||||
}
|
||||
return .other(ownerUserID: st.st_uid)
|
||||
}
|
||||
|
||||
static func isTruthy(_ raw: String?) -> Bool {
|
||||
guard let raw else { return false }
|
||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
|
|
|
|||
|
|
@ -60,18 +60,6 @@ enum WorkspaceAutoReorderSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum LastSurfaceCloseShortcutSettings {
|
||||
static let key = "closeWorkspaceOnLastSurfaceShortcut"
|
||||
static let defaultValue = false
|
||||
|
||||
static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: key) == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return defaults.bool(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarBranchLayoutSettings {
|
||||
static let key = "sidebarBranchVerticalLayout"
|
||||
static let defaultVerticalLayout = true
|
||||
|
|
@ -763,6 +751,15 @@ class TabManager: ObservableObject {
|
|||
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
|
||||
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
|
||||
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
|
||||
private struct WorkspaceCreationSnapshot {
|
||||
let tabs: [Workspace]
|
||||
let selectedTabId: UUID?
|
||||
|
||||
var selectedWorkspace: Workspace? {
|
||||
guard let selectedTabId else { return nil }
|
||||
return tabs.first(where: { $0.id == selectedTabId })
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
||||
private var debugWorkspaceSwitchId: UInt64 = 0
|
||||
|
|
@ -925,13 +922,18 @@ class TabManager: ObservableObject {
|
|||
placementOverride: NewWorkspacePlacement? = nil,
|
||||
autoWelcomeIfNeeded: Bool = true
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
|
||||
// Snapshot current published state once so workspace creation doesn't repeatedly
|
||||
// bounce through Combine-backed accessors while we're preparing the new workspace.
|
||||
let snapshot = workspaceCreationSnapshot()
|
||||
let nextTabCount = snapshot.tabs.count + 1
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount])
|
||||
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
|
||||
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab(snapshot: snapshot)
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace(snapshot: snapshot)
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let newWorkspace = Workspace(
|
||||
title: "Terminal \(tabs.count + 1)",
|
||||
title: "Terminal \(nextTabCount)",
|
||||
workingDirectory: workingDirectory,
|
||||
portOrdinal: ordinal,
|
||||
configTemplate: inheritedConfig,
|
||||
|
|
@ -940,19 +942,20 @@ class TabManager: ObservableObject {
|
|||
)
|
||||
newWorkspace.owningTabManager = self
|
||||
wireClosedBrowserTracking(for: newWorkspace)
|
||||
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
|
||||
if insertIndex >= 0 && insertIndex <= tabs.count {
|
||||
tabs.insert(newWorkspace, at: insertIndex)
|
||||
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
||||
var updatedTabs = snapshot.tabs
|
||||
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
||||
updatedTabs.insert(newWorkspace, at: insertIndex)
|
||||
} else {
|
||||
tabs.append(newWorkspace)
|
||||
updatedTabs.append(newWorkspace)
|
||||
}
|
||||
if overrideWorkingDirectory != nil,
|
||||
let workingDirectory,
|
||||
let panelId = newWorkspace.focusedTerminalPanel?.id {
|
||||
tabs = updatedTabs
|
||||
if let explicitWorkingDirectory,
|
||||
let terminalPanel = newWorkspace.focusedTerminalPanel {
|
||||
scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
workspaceId: newWorkspace.id,
|
||||
panelId: panelId,
|
||||
directory: workingDirectory
|
||||
panelId: terminalPanel.id,
|
||||
directory: explicitWorkingDirectory
|
||||
)
|
||||
}
|
||||
if eagerLoadTerminal {
|
||||
|
|
@ -972,8 +975,8 @@ class TabManager: ObservableObject {
|
|||
#if DEBUG
|
||||
UITestRecorder.incrementInt("addTabInvocations")
|
||||
UITestRecorder.record([
|
||||
"tabCount": String(tabs.count),
|
||||
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
|
||||
"tabCount": String(updatedTabs.count),
|
||||
"selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "")
|
||||
])
|
||||
#endif
|
||||
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
|
||||
|
|
@ -1201,7 +1204,20 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
terminalPanelForWorkspaceConfigInheritanceSource(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot {
|
||||
WorkspaceCreationSnapshot(
|
||||
tabs: tabs,
|
||||
selectedTabId: selectedTabId
|
||||
)
|
||||
}
|
||||
|
||||
private func terminalPanelForWorkspaceConfigInheritanceSource(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> TerminalPanel? {
|
||||
guard let workspace = snapshot.selectedWorkspace else { return nil }
|
||||
if let focusedTerminal = workspace.focusedTerminalPanel {
|
||||
return focusedTerminal
|
||||
}
|
||||
|
|
@ -1216,13 +1232,19 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
|
||||
inheritedTerminalConfigForNewWorkspace(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource(snapshot: snapshot)?.surface.surface {
|
||||
return cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: sourceSurface,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB
|
||||
)
|
||||
}
|
||||
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
if let fallbackFontPoints = snapshot.selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
var config = ghostty_surface_config_new()
|
||||
config.font_size = fallbackFontPoints
|
||||
return config
|
||||
|
|
@ -1238,24 +1260,36 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
|
||||
newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride)
|
||||
}
|
||||
|
||||
private func newTabInsertIndex(
|
||||
snapshot: WorkspaceCreationSnapshot,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
) -> Int {
|
||||
let placement = placementOverride ?? WorkspacePlacementSettings.current()
|
||||
let pinnedCount = tabs.filter { $0.isPinned }.count
|
||||
let selectedIndex = selectedTabId.flatMap { tabId in
|
||||
tabs.firstIndex(where: { $0.id == tabId })
|
||||
let pinnedCount = snapshot.tabs.filter { $0.isPinned }.count
|
||||
let selectedIndex = snapshot.selectedTabId.flatMap { tabId in
|
||||
snapshot.tabs.firstIndex(where: { $0.id == tabId })
|
||||
}
|
||||
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false
|
||||
let selectedIsPinned = selectedIndex.map { snapshot.tabs[$0].isPinned } ?? false
|
||||
return WorkspacePlacementSettings.insertionIndex(
|
||||
placement: placement,
|
||||
selectedIndex: selectedIndex,
|
||||
selectedIsPinned: selectedIsPinned,
|
||||
pinnedCount: pinnedCount,
|
||||
totalCount: tabs.count
|
||||
totalCount: snapshot.tabs.count
|
||||
)
|
||||
}
|
||||
|
||||
private func preferredWorkingDirectoryForNewTab() -> String? {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
|
||||
preferredWorkingDirectoryForNewTab(snapshot: workspaceCreationSnapshot())
|
||||
}
|
||||
|
||||
private func preferredWorkingDirectoryForNewTab(
|
||||
snapshot: WorkspaceCreationSnapshot
|
||||
) -> String? {
|
||||
guard let tab = snapshot.selectedWorkspace else {
|
||||
return nil
|
||||
}
|
||||
let focusedDirectory = tab.focusedPanelId
|
||||
|
|
@ -1369,6 +1403,15 @@ class TabManager: ObservableObject {
|
|||
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
|
||||
}
|
||||
|
||||
func updateSurfaceShellActivity(
|
||||
tabId: UUID,
|
||||
surfaceId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
||||
}
|
||||
|
||||
private func normalizeDirectory(_ directory: String) -> String {
|
||||
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return directory }
|
||||
|
|
@ -1550,6 +1593,7 @@ class TabManager: ObservableObject {
|
|||
if let confirmCloseHandler {
|
||||
return confirmCloseHandler(title, message, acceptCmdD)
|
||||
}
|
||||
_ = acceptCmdD
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
|
|
@ -1558,15 +1602,18 @@ class TabManager: ObservableObject {
|
|||
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
|
||||
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
|
||||
|
||||
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
|
||||
// We only opt into this for the "close last workspace => close window" path to avoid
|
||||
// conflicting with app-level Cmd+D (split right) during normal usage.
|
||||
if acceptCmdD, let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "d"
|
||||
closeButton.keyEquivalentModifierMask = [.command]
|
||||
|
||||
// Keep Return/Enter behavior by explicitly setting the default button cell.
|
||||
if let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "\r"
|
||||
closeButton.keyEquivalentModifierMask = []
|
||||
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
||||
alert.window.initialFirstResponder = closeButton
|
||||
}
|
||||
if let cancelButton = alert.buttons.dropFirst().first {
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
}
|
||||
|
||||
if NSApp.activationPolicy() == .regular {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
return alert.runModal() == .alertFirstButtonReturn
|
||||
|
|
@ -1692,7 +1739,11 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
if tabs.count <= 1 {
|
||||
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
|
||||
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
||||
if let window {
|
||||
window.performClose(nil)
|
||||
} else {
|
||||
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
||||
}
|
||||
} else {
|
||||
closeWorkspace(workspace)
|
||||
}
|
||||
|
|
@ -1722,14 +1773,16 @@ class TabManager: ObservableObject {
|
|||
dlog(
|
||||
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
|
||||
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " +
|
||||
"closeWorkspaceOnLastSurfaceSetting=\(LastSurfaceCloseShortcutSettings.closesWorkspace() ? 1 : 0)"
|
||||
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
|
||||
// button, including shared confirmation, setting-controlled last-surface behavior, and
|
||||
// replacement-panel flow.
|
||||
// button, including shared confirmation, last-surface workspace/window-close behavior,
|
||||
// and the usual replacement-panel flow when the close does not collapse the workspace.
|
||||
if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
|
||||
tab.markExplicitClose(surfaceId: surfaceId)
|
||||
}
|
||||
let closed = tab.closePanel(panelId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -1752,7 +1805,7 @@ class TabManager: ObservableObject {
|
|||
guard tab.panels[surfaceId] != nil else { return }
|
||||
|
||||
if let terminalPanel = tab.terminalPanel(for: surfaceId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
guard confirmClose(
|
||||
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
|
||||
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
|
||||
|
|
@ -2154,14 +2207,33 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
|
||||
@discardableResult
|
||||
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool {
|
||||
let wasSelected = selectedTabId == tabId
|
||||
let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else {
|
||||
#if DEBUG
|
||||
dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
if let surfaceId, tab.panels[surfaceId] == nil {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " +
|
||||
"panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let desiredPanelId = surfaceId ?? tab.focusedPanelId
|
||||
#if DEBUG
|
||||
if let desiredPanelId {
|
||||
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId)
|
||||
}
|
||||
#endif
|
||||
// Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom
|
||||
// state active around it.
|
||||
tab.clearSplitZoom()
|
||||
suppressFocusFlash = true
|
||||
focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true)
|
||||
if wasSelected {
|
||||
|
|
@ -2179,6 +2251,7 @@ class TabManager: ObservableObject {
|
|||
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func focusSurface(tabId: UUID, surfaceId: UUID) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class TerminalController {
|
|||
|
||||
static let shared = TerminalController()
|
||||
|
||||
private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock"
|
||||
private nonisolated(unsafe) var socketPath = SocketControlSettings.stableDefaultSocketPath
|
||||
private nonisolated(unsafe) var serverSocket: Int32 = -1
|
||||
private nonisolated(unsafe) var isRunning = false
|
||||
private nonisolated(unsafe) var acceptLoopAlive = false
|
||||
|
|
@ -73,6 +73,13 @@ class TerminalController {
|
|||
let acceptLoopAlive: Bool
|
||||
let activeGeneration: UInt64
|
||||
let pendingRearmGeneration: UInt64?
|
||||
let listenerStartInProgress: Bool
|
||||
}
|
||||
|
||||
private enum SocketBindAttemptResult {
|
||||
case success(path: String)
|
||||
case pathTooLong(path: String)
|
||||
case failure(path: String, stage: String, errnoCode: Int32)
|
||||
}
|
||||
|
||||
private static let focusIntentV1Commands: Set<String> = [
|
||||
|
|
@ -174,11 +181,20 @@ class TerminalController {
|
|||
isRunning: isRunning,
|
||||
acceptLoopAlive: acceptLoopAlive,
|
||||
activeGeneration: activeAcceptLoopGeneration,
|
||||
pendingRearmGeneration: pendingAcceptLoopRearmGeneration
|
||||
pendingRearmGeneration: pendingAcceptLoopRearmGeneration,
|
||||
listenerStartInProgress: listenerStartInProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func activeSocketPath(preferredPath: String) -> String {
|
||||
let snapshot = listenerStateSnapshot()
|
||||
if snapshot.isRunning || snapshot.acceptLoopAlive || snapshot.listenerStartInProgress || snapshot.serverSocket >= 0 {
|
||||
return snapshot.socketPath
|
||||
}
|
||||
return preferredPath
|
||||
}
|
||||
|
||||
private nonisolated func shouldContinueAcceptLoop(generation: UInt64) -> Bool {
|
||||
withListenerState {
|
||||
isRunning && generation == activeAcceptLoopGeneration
|
||||
|
|
@ -333,6 +349,52 @@ class TerminalController {
|
|||
return currentSorted != nextSorted
|
||||
}
|
||||
|
||||
private struct SocketSurfaceKey: Hashable {
|
||||
let workspaceId: UUID
|
||||
let panelId: UUID
|
||||
}
|
||||
|
||||
private final class SocketFastPathState: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
||||
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
||||
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
||||
private let maxTrackedDirectories = 4096
|
||||
private let maxTrackedShellStates = 4096
|
||||
|
||||
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
return queue.sync {
|
||||
if lastReportedDirectories[key] == directory {
|
||||
return false
|
||||
}
|
||||
if lastReportedDirectories.count >= maxTrackedDirectories {
|
||||
lastReportedDirectories.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lastReportedDirectories[key] = directory
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPublishShellActivity(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
return queue.sync {
|
||||
if lastReportedShellStates[key] == state {
|
||||
return false
|
||||
}
|
||||
if lastReportedShellStates.count >= maxTrackedShellStates {
|
||||
lastReportedShellStates.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lastReportedShellStates[key] = state
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let socketFastPathState = SocketFastPathState()
|
||||
nonisolated static func explicitSocketScope(
|
||||
options: [String: String]
|
||||
) -> (workspaceId: UUID, panelId: UUID)? {
|
||||
|
|
@ -386,6 +448,21 @@ class TerminalController {
|
|||
return directory.path.hasPrefix(temporary.path + "/")
|
||||
}
|
||||
|
||||
nonisolated static func parseReportedShellActivityState(
|
||||
_ rawState: String
|
||||
) -> Workspace.PanelShellActivityState? {
|
||||
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "prompt", "idle":
|
||||
return .promptIdle
|
||||
case "running", "busy", "command":
|
||||
return .commandRunning
|
||||
case "unknown", "clear":
|
||||
return .unknown
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Update which window's TabManager receives socket commands.
|
||||
/// This is used when the user switches between multiple terminal windows.
|
||||
func setActiveTabManager(_ tabManager: TabManager?) {
|
||||
|
|
@ -639,6 +716,60 @@ class TerminalController {
|
|||
return (false, connectErrno)
|
||||
}
|
||||
|
||||
private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult {
|
||||
if let errnoCode = ensureSocketParentDirectoryExists(path: path) {
|
||||
return .failure(path: path, stage: "create_directory", errnoCode: errnoCode)
|
||||
}
|
||||
if unlink(path) != 0, errno != ENOENT {
|
||||
return .failure(path: path, stage: "unlink", errnoCode: errno)
|
||||
}
|
||||
|
||||
guard let bindResult = bindUnixSocket(socket, path: path) else {
|
||||
return .pathTooLong(path: path)
|
||||
}
|
||||
guard bindResult >= 0 else {
|
||||
return .failure(path: path, stage: "bind", errnoCode: errno)
|
||||
}
|
||||
return .success(path: path)
|
||||
}
|
||||
|
||||
private nonisolated static func ensureSocketParentDirectoryExists(path: String) -> Int32? {
|
||||
let parentURL = URL(fileURLWithPath: path).deletingLastPathComponent()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: parentURL,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
return nil
|
||||
} catch let error as NSError {
|
||||
if error.domain == NSPOSIXErrorDomain {
|
||||
return Int32(error.code)
|
||||
}
|
||||
return EIO
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: String,
|
||||
stage: String,
|
||||
errnoCode: Int32,
|
||||
currentUserID: uid_t = getuid()
|
||||
) -> String? {
|
||||
guard requestedPath == SocketControlSettings.stableDefaultSocketPath else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch stage {
|
||||
case "unlink" where errnoCode == EACCES || errnoCode == EPERM:
|
||||
return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID)
|
||||
case "bind" where errnoCode == EACCES || errnoCode == EPERM || errnoCode == EADDRINUSE:
|
||||
return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
||||
self.tabManager = tabManager
|
||||
self.accessMode = accessMode
|
||||
|
|
@ -657,8 +788,9 @@ class TerminalController {
|
|||
stop()
|
||||
}
|
||||
|
||||
var activeSocketPath = socketPath
|
||||
withListenerState {
|
||||
self.socketPath = socketPath
|
||||
self.socketPath = activeSocketPath
|
||||
listenerStartInProgress = true
|
||||
}
|
||||
var listenerActivated = false
|
||||
|
|
@ -670,9 +802,6 @@ class TerminalController {
|
|||
}
|
||||
}
|
||||
|
||||
// Remove existing socket file
|
||||
unlink(socketPath)
|
||||
|
||||
// Create socket
|
||||
let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard newServerSocket >= 0 else {
|
||||
|
|
@ -686,29 +815,58 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
// Bind to path
|
||||
guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else {
|
||||
var bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath)
|
||||
if case .failure(let failedPath, let failedStage, let failedErrnoCode) = bindAttempt,
|
||||
let fallbackPath = Self.fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: failedPath,
|
||||
stage: failedStage,
|
||||
errnoCode: failedErrnoCode
|
||||
),
|
||||
fallbackPath != failedPath {
|
||||
sentryBreadcrumb(
|
||||
"socket.listener.path.fallback",
|
||||
category: "socket",
|
||||
data: [
|
||||
"requestedPath": failedPath,
|
||||
"fallbackPath": fallbackPath,
|
||||
"stage": failedStage,
|
||||
"errno": Int(failedErrnoCode)
|
||||
]
|
||||
)
|
||||
activeSocketPath = fallbackPath
|
||||
withListenerState {
|
||||
self.socketPath = activeSocketPath
|
||||
}
|
||||
bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath)
|
||||
}
|
||||
|
||||
switch bindAttempt {
|
||||
case .success(let boundPath):
|
||||
activeSocketPath = boundPath
|
||||
withListenerState {
|
||||
self.socketPath = activeSocketPath
|
||||
}
|
||||
case .pathTooLong(let failedPath):
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "bind_path_too_long",
|
||||
errnoCode: ENAMETOOLONG,
|
||||
extra: [
|
||||
"pathLength": socketPath.utf8.count,
|
||||
"path": failedPath,
|
||||
"pathLength": failedPath.utf8.count,
|
||||
"maxPathLength": Self.unixSocketPathMaxLength
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard bindResult >= 0 else {
|
||||
let errnoCode = errno
|
||||
case .failure(let failedPath, let failedStage, let failedErrnoCode):
|
||||
print("TerminalController: Failed to bind socket")
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "bind",
|
||||
errnoCode: errnoCode
|
||||
stage: failedStage,
|
||||
errnoCode: failedErrnoCode,
|
||||
extra: ["path": failedPath]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -728,6 +886,8 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
SocketControlSettings.recordLastSocketPath(activeSocketPath)
|
||||
|
||||
let generation = withListenerState {
|
||||
isRunning = true
|
||||
pendingAcceptLoopRearmGeneration = nil
|
||||
|
|
@ -740,12 +900,12 @@ class TerminalController {
|
|||
}
|
||||
listenerActivated = true
|
||||
let listenerSocket = newServerSocket
|
||||
print("TerminalController: Listening on \(socketPath)")
|
||||
print("TerminalController: Listening on \(activeSocketPath)")
|
||||
sentryBreadcrumb(
|
||||
"socket.listener.listening",
|
||||
category: "socket",
|
||||
data: [
|
||||
"path": socketPath,
|
||||
"path": activeSocketPath,
|
||||
"mode": accessMode.rawValue,
|
||||
"generation": generation,
|
||||
"backlog": Self.socketListenBacklog
|
||||
|
|
@ -1469,6 +1629,9 @@ class TerminalController {
|
|||
case "ports_kick":
|
||||
return portsKick(args)
|
||||
|
||||
case "report_shell_state":
|
||||
return reportShellState(args)
|
||||
|
||||
case "report_pwd":
|
||||
return reportPwd(args)
|
||||
|
||||
|
|
@ -1632,6 +1795,9 @@ class TerminalController {
|
|||
case "close_surface":
|
||||
return closeSurface(args)
|
||||
|
||||
case "reload_config":
|
||||
return reloadConfig(args)
|
||||
|
||||
case "refresh_surfaces":
|
||||
return refreshSurfaces()
|
||||
|
||||
|
|
@ -4892,21 +5058,133 @@ class TerminalController {
|
|||
return "OK \(base64)"
|
||||
}
|
||||
|
||||
func readTerminalTextForSessionSnapshot(
|
||||
private struct PasteboardItemSnapshot {
|
||||
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
|
||||
}
|
||||
|
||||
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
|
||||
guard let items = pasteboard.pasteboardItems else { return [] }
|
||||
return items.map { item in
|
||||
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
|
||||
guard let data = item.data(forType: type) else { return nil }
|
||||
return (type: type, data: data)
|
||||
}
|
||||
return PasteboardItemSnapshot(representations: representations)
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePasteboardItems(
|
||||
_ snapshots: [PasteboardItemSnapshot],
|
||||
to pasteboard: NSPasteboard
|
||||
) {
|
||||
_ = pasteboard.clearContents()
|
||||
guard !snapshots.isEmpty else { return }
|
||||
|
||||
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
|
||||
guard !snapshot.representations.isEmpty else { return nil }
|
||||
let item = NSPasteboardItem()
|
||||
for representation in snapshot.representations {
|
||||
item.setData(representation.data, forType: representation.type)
|
||||
}
|
||||
return item
|
||||
}
|
||||
guard !restoredItems.isEmpty else { return }
|
||||
_ = pasteboard.writeObjects(restoredItems)
|
||||
}
|
||||
|
||||
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
|
||||
let firstURL = urls.first,
|
||||
firstURL.isFileURL {
|
||||
return firstURL.path
|
||||
}
|
||||
if let value = pasteboard.string(forType: .string) {
|
||||
return value
|
||||
}
|
||||
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
|
||||
}
|
||||
|
||||
private func readTerminalTextFromVTExportForSnapshot(
|
||||
terminalPanel: TerminalPanel,
|
||||
lineLimit: Int?
|
||||
) -> String? {
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = snapshotPasteboardItems(pasteboard)
|
||||
defer {
|
||||
restorePasteboardItems(snapshot, to: pasteboard)
|
||||
}
|
||||
|
||||
let initialChangeCount = pasteboard.changeCount
|
||||
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
|
||||
return nil
|
||||
}
|
||||
guard pasteboard.changeCount != initialChangeCount else {
|
||||
return nil
|
||||
}
|
||||
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileURL = URL(fileURLWithPath: exportedPath)
|
||||
defer {
|
||||
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
|
||||
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
var output = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
if let lineLimit {
|
||||
output = tailTerminalLines(output, maxLines: lineLimit)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func readTerminalTextForSnapshot(
|
||||
terminalPanel: TerminalPanel,
|
||||
includeScrollback: Bool = false,
|
||||
lineLimit: Int? = nil
|
||||
) -> String? {
|
||||
if includeScrollback,
|
||||
let vtOutput = readTerminalTextFromVTExportForSnapshot(
|
||||
terminalPanel: terminalPanel,
|
||||
lineLimit: lineLimit
|
||||
) {
|
||||
return vtOutput
|
||||
}
|
||||
|
||||
let response = readTerminalTextBase64(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
guard response.hasPrefix("OK ") else { return nil }
|
||||
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !payload.isEmpty else { return "" }
|
||||
guard let data = Data(base64Encoded: payload) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if base64.isEmpty {
|
||||
return ""
|
||||
}
|
||||
guard let data = Data(base64Encoded: base64),
|
||||
let decoded = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func readTerminalTextForSessionSnapshot(
|
||||
terminalPanel: TerminalPanel,
|
||||
includeScrollback: Bool = false,
|
||||
lineLimit: Int? = nil
|
||||
) -> String? {
|
||||
readTerminalTextForSnapshot(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
}
|
||||
|
||||
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
|
||||
|
|
@ -9966,6 +10244,7 @@ class TerminalController {
|
|||
focus_pane <pane-id|index> - Focus a pane
|
||||
focus_surface_by_panel <panel_id> - Focus surface by panel ID
|
||||
close_surface [id|idx] - Close surface (collapse split)
|
||||
reload_config [soft] - Reload Ghostty config and refresh terminals
|
||||
refresh_surfaces - Force refresh all terminals
|
||||
surface_health [workspace] - Check view health of all surfaces
|
||||
|
||||
|
|
@ -10006,6 +10285,7 @@ class TerminalController {
|
|||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
|
||||
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
||||
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
||||
sidebar_state [--tab=X] - Dump sidebar metadata
|
||||
|
|
@ -11348,7 +11628,9 @@ class TerminalController {
|
|||
result = "ERROR: Surface not found"
|
||||
return
|
||||
}
|
||||
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
|
||||
if !tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) {
|
||||
result = "ERROR: Focus failed"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -13761,6 +14043,72 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportShellState(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
|
||||
return "ERROR: Missing shell state — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
}
|
||||
guard let state = Self.parseReportedShellActivityState(rawState) else {
|
||||
return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running"
|
||||
}
|
||||
|
||||
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
||||
guard Self.socketFastPathState.shouldPublishShellActivity(
|
||||
workspaceId: scope.workspaceId,
|
||||
panelId: scope.panelId,
|
||||
state: state
|
||||
) else {
|
||||
return "OK"
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
|
||||
tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
|
|
@ -13959,6 +14307,24 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reloadConfig(_ args: String) -> String {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let soft: Bool
|
||||
switch trimmed {
|
||||
case "", "full":
|
||||
soft = false
|
||||
case "soft":
|
||||
soft = true
|
||||
default:
|
||||
return "ERROR: Usage: reload_config [soft]"
|
||||
}
|
||||
|
||||
v2MainSync {
|
||||
GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config")
|
||||
}
|
||||
return soft ? "OK Reloaded config (soft)" : "OK Reloaded config"
|
||||
}
|
||||
|
||||
private func refreshSurfaces() -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
|
|
|
|||
|
|
@ -356,8 +356,9 @@ extension Workspace {
|
|||
switch panel.panelType {
|
||||
case .terminal:
|
||||
guard let terminalPanel = panel as? TerminalPanel else { return nil }
|
||||
let capturedScrollback = includeScrollback
|
||||
? TerminalController.shared.readTerminalTextForSessionSnapshot(
|
||||
let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot()
|
||||
let capturedScrollback = includeScrollback && shouldPersistScrollback
|
||||
? TerminalController.shared.readTerminalTextForSnapshot(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: true,
|
||||
lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal
|
||||
|
|
@ -366,7 +367,8 @@ extension Workspace {
|
|||
let resolvedScrollback = terminalSnapshotScrollback(
|
||||
panelId: panelId,
|
||||
capturedScrollback: capturedScrollback,
|
||||
includeScrollback: includeScrollback
|
||||
includeScrollback: includeScrollback,
|
||||
allowFallbackScrollback: shouldPersistScrollback
|
||||
)
|
||||
terminalSnapshot = SessionTerminalPanelSnapshot(
|
||||
workingDirectory: panelDirectories[panelId],
|
||||
|
|
@ -413,24 +415,28 @@ extension Workspace {
|
|||
|
||||
nonisolated static func resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: String?,
|
||||
fallbackScrollback: String?
|
||||
fallbackScrollback: String?,
|
||||
allowFallbackScrollback: Bool = true
|
||||
) -> String? {
|
||||
if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) {
|
||||
return captured
|
||||
}
|
||||
guard allowFallbackScrollback else { return nil }
|
||||
return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback)
|
||||
}
|
||||
|
||||
private func terminalSnapshotScrollback(
|
||||
panelId: UUID,
|
||||
capturedScrollback: String?,
|
||||
includeScrollback: Bool
|
||||
includeScrollback: Bool,
|
||||
allowFallbackScrollback: Bool = true
|
||||
) -> String? {
|
||||
guard includeScrollback else { return nil }
|
||||
let fallback = restoredTerminalScrollbackByPanelId[panelId]
|
||||
let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil
|
||||
let resolved = Self.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: capturedScrollback,
|
||||
fallbackScrollback: fallback
|
||||
fallbackScrollback: fallback,
|
||||
allowFallbackScrollback: allowFallbackScrollback
|
||||
)
|
||||
if let resolved {
|
||||
restoredTerminalScrollbackByPanelId[panelId] = resolved
|
||||
|
|
@ -4612,6 +4618,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
||||
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
||||
|
||||
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
|
||||
|
|
@ -4637,6 +4644,26 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
static let markdown = "markdown"
|
||||
}
|
||||
|
||||
enum PanelShellActivityState: String {
|
||||
case unknown
|
||||
case promptIdle
|
||||
case commandRunning
|
||||
}
|
||||
|
||||
nonisolated static func resolveCloseConfirmation(
|
||||
shellActivityState: PanelShellActivityState?,
|
||||
fallbackNeedsConfirmClose: Bool
|
||||
) -> Bool {
|
||||
switch shellActivityState ?? .unknown {
|
||||
case .promptIdle:
|
||||
return false
|
||||
case .commandRunning:
|
||||
return true
|
||||
case .unknown:
|
||||
return fallbackNeedsConfirmClose
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
||||
|
|
@ -4800,6 +4827,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
bonsplitController.onExternalTabDrop = { [weak self] request in
|
||||
self?.handleExternalTabDrop(request) ?? false
|
||||
}
|
||||
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
|
||||
self?.markExplicitClose(surfaceId: tabId)
|
||||
}
|
||||
|
||||
// Set ourselves as delegate
|
||||
bonsplitController.delegate = self
|
||||
|
|
@ -4851,6 +4881,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
|
||||
private var pendingCloseConfirmTabIds: Set<TabID> = []
|
||||
|
||||
/// Tab IDs whose next close attempt came from an explicit user close gesture
|
||||
/// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
|
||||
private var explicitUserCloseTabIds: Set<TabID> = []
|
||||
|
||||
/// Deterministic tab selection to apply after a tab closes.
|
||||
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
|
||||
private var postCloseSelectTabId: [TabID: TabID] = [:]
|
||||
|
|
@ -4917,6 +4951,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceIdToPanelId[surfaceId]
|
||||
}
|
||||
|
||||
func markExplicitClose(surfaceId: TabID) {
|
||||
explicitUserCloseTabIds.insert(surfaceId)
|
||||
}
|
||||
|
||||
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
|
||||
surfaceIdToPanelId.first { $0.value == panelId }?.key
|
||||
}
|
||||
|
|
@ -5286,6 +5324,26 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) {
|
||||
guard panels[panelId] != nil else { return }
|
||||
let previousState = panelShellActivityStates[panelId] ?? .unknown
|
||||
guard previousState != state else { return }
|
||||
panelShellActivityStates[panelId] = state
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
|
||||
Self.resolveCloseConfirmation(
|
||||
shellActivityState: panelShellActivityStates[panelId],
|
||||
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
|
||||
)
|
||||
}
|
||||
|
||||
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
|
||||
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
|
||||
let existing = panelGitBranches[panelId]
|
||||
|
|
@ -5427,6 +5485,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
|
@ -6038,11 +6097,35 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
||||
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
|
||||
|
||||
// Inherit working directory: prefer the source panel's reported cwd,
|
||||
// then its requested startup cwd if shell integration has not reported
|
||||
// back yet, and finally fall back to the workspace's current directory.
|
||||
let splitWorkingDirectory: String? = {
|
||||
if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!panelDirectory.isEmpty {
|
||||
return panelDirectory
|
||||
}
|
||||
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
|
||||
.requestedWorkingDirectory?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!requestedWorkingDirectory.isEmpty {
|
||||
return requestedWorkingDirectory
|
||||
}
|
||||
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
|
||||
}()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Create the new terminal panel.
|
||||
let newPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: inheritedConfig,
|
||||
workingDirectory: splitWorkingDirectory,
|
||||
portOrdinal: portOrdinal,
|
||||
initialCommand: remoteTerminalStartupCommand
|
||||
)
|
||||
|
|
@ -7412,9 +7495,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// Check if any panel needs close confirmation
|
||||
func needsConfirmClose() -> Bool {
|
||||
for panel in panels.values {
|
||||
for (panelId, panel) in panels {
|
||||
if let terminalPanel = panel as? TerminalPanel,
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -8056,8 +8139,7 @@ extension Workspace: BonsplitDelegate {
|
|||
@MainActor
|
||||
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
|
||||
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
|
||||
guard LastSurfaceCloseShortcutSettings.closesWorkspace(),
|
||||
panels.count <= 1,
|
||||
guard panels.count <= 1,
|
||||
panelIdFromSurfaceId(tabId) != nil,
|
||||
let manager,
|
||||
manager.tabs.contains(where: { $0.id == id }) else {
|
||||
|
|
@ -8069,11 +8151,21 @@ extension Workspace: BonsplitDelegate {
|
|||
@MainActor
|
||||
private func confirmClosePanel(for tabId: TabID) async -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Close tab?"
|
||||
alert.informativeText = "This will close the current tab."
|
||||
alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
|
||||
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Close")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
|
||||
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
|
||||
|
||||
if let closeButton = alert.buttons.first {
|
||||
closeButton.keyEquivalent = "\r"
|
||||
closeButton.keyEquivalentModifierMask = []
|
||||
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
||||
alert.window.initialFirstResponder = closeButton
|
||||
}
|
||||
if let cancelButton = alert.buttons.dropFirst().first {
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
}
|
||||
|
||||
// Prefer a sheet if we can find a window, otherwise fall back to modal.
|
||||
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
||||
|
|
@ -8468,6 +8560,8 @@ extension Workspace: BonsplitDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
|
||||
|
||||
if forceCloseTabIds.contains(tab.id) {
|
||||
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
|
||||
recordPostCloseSelection()
|
||||
|
|
@ -8481,7 +8575,7 @@ extension Workspace: BonsplitDelegate {
|
|||
return false
|
||||
}
|
||||
|
||||
if shouldCloseWorkspaceOnLastSurface(for: tab.id) {
|
||||
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
|
||||
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
||||
owningTabManager?.closeWorkspaceWithConfirmation(self)
|
||||
return false
|
||||
|
|
@ -8498,7 +8592,7 @@ extension Workspace: BonsplitDelegate {
|
|||
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
|
||||
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
|
||||
// this gating on the second pass.
|
||||
if terminalPanel.needsConfirmClose() {
|
||||
if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
||||
if pendingCloseConfirmTabIds.contains(tab.id) {
|
||||
return false
|
||||
|
|
@ -8591,6 +8685,7 @@ extension Workspace: BonsplitDelegate {
|
|||
manualUnreadPanelIds.remove(panelId)
|
||||
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
|
|
@ -8738,6 +8833,7 @@ extension Workspace: BonsplitDelegate {
|
|||
pinnedPanelIds.remove(panelId)
|
||||
manualUnreadPanelIds.remove(panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
|
|
@ -8770,7 +8866,7 @@ extension Workspace: BonsplitDelegate {
|
|||
if forceCloseTabIds.contains(tab.id) { continue }
|
||||
if let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,10 +452,9 @@ struct cmuxApp: App {
|
|||
Divider()
|
||||
|
||||
// Terminal semantics:
|
||||
// Cmd+W closes the focused tab/surface (with confirmation if needed) and keeps
|
||||
// the workspace open by default. Cmd+Shift+W is the explicit workspace-close
|
||||
// action, unless the user opts into closing the workspace when its last surface
|
||||
// is closed.
|
||||
// Cmd+W closes the focused tab/surface (with confirmation if needed). When that
|
||||
// was the last surface in the workspace, cmux removes the workspace and closes
|
||||
// the window if it was also the last workspace.
|
||||
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
|
||||
closePanelOrWindow()
|
||||
}
|
||||
|
|
@ -3077,8 +3076,6 @@ struct SettingsView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
|
||||
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(LastSurfaceCloseShortcutSettings.key)
|
||||
private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey)
|
||||
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
|
||||
|
|
@ -3125,19 +3122,6 @@ struct SettingsView: View {
|
|||
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
|
||||
}
|
||||
|
||||
private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String {
|
||||
if closeWorkspaceOnLastSurfaceShortcut {
|
||||
return String(
|
||||
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn",
|
||||
defaultValue: "Closing the last surface also closes its workspace."
|
||||
)
|
||||
}
|
||||
return String(
|
||||
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff",
|
||||
defaultValue: "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
|
||||
)
|
||||
}
|
||||
|
||||
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
|
||||
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
|
||||
}
|
||||
|
|
@ -3443,14 +3427,6 @@ struct SettingsView: View {
|
|||
VStack(alignment: .leading, spacing: 14) {
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
|
||||
SettingsCard {
|
||||
SettingsPickerRow(String(localized: "settings.app.theme", defaultValue: "Theme"), controlWidth: pickerColumnWidth, selection: $appearanceMode) {
|
||||
ForEach(AppearanceMode.visibleCases) { mode in
|
||||
Text(mode.displayName).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.language", defaultValue: "Language"),
|
||||
subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue
|
||||
|
|
@ -3482,6 +3458,15 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
ThemePickerRow(
|
||||
selectedMode: appearanceMode,
|
||||
onSelect: { mode in
|
||||
appearanceMode = mode.rawValue
|
||||
}
|
||||
)
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
AppIconPickerRow(
|
||||
selectedMode: appIconMode,
|
||||
onSelect: { mode in
|
||||
|
|
@ -3505,17 +3490,6 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Closing Last Surface Closes Workspace"),
|
||||
subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle
|
||||
) {
|
||||
Toggle("", isOn: $closeWorkspaceOnLastSurfaceShortcut)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
|
||||
subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.")
|
||||
|
|
@ -4496,7 +4470,6 @@ struct SettingsView: View {
|
|||
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
||||
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
|
||||
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
|
||||
|
|
@ -4769,6 +4742,193 @@ private struct SettingsCardNote: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ThemeWindowThumbnail: View {
|
||||
let isDark: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
let height = geo.size.height
|
||||
|
||||
ZStack {
|
||||
// Wallpaper background
|
||||
if isDark {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
||||
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
||||
path.addLine(to: CGPoint(x: width, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
.fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
} else {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 0, y: height * 0.5))
|
||||
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
|
||||
path.addLine(to: CGPoint(x: width, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
.fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
}
|
||||
|
||||
// Menu bar
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "applelogo")
|
||||
.font(.system(size: max(height * 0.08, 6)))
|
||||
.foregroundColor(isDark ? .white : .black)
|
||||
.opacity(0.8)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, max(width * 0.04, 4))
|
||||
.frame(height: max(height * 0.12, 8))
|
||||
.background(.ultraThinMaterial)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Back window
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.2) : Color(white: 0.9))
|
||||
.frame(height: max(height * 0.15, 8))
|
||||
ZStack(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.15) : Color(white: 0.98))
|
||||
RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous)
|
||||
.fill(Color.accentColor)
|
||||
.frame(height: max(height * 0.12, 6))
|
||||
.padding(max(width * 0.04, 4))
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous))
|
||||
.frame(width: width * 0.65, height: height * 0.45)
|
||||
.shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2)
|
||||
.offset(x: -width * 0.08, y: -height * 0.1)
|
||||
|
||||
// Front window with traffic lights
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.18) : Color(white: 0.92))
|
||||
HStack(spacing: max(width * 0.025, 2)) {
|
||||
Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3))
|
||||
Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3))
|
||||
Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, max(width * 0.04, 4))
|
||||
}
|
||||
.frame(height: max(height * 0.18, 10))
|
||||
Rectangle()
|
||||
.fill(isDark ? Color(white: 0.1) : .white)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous))
|
||||
.shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3)
|
||||
.frame(width: width * 0.75, height: height * 0.55)
|
||||
.offset(x: width * 0.12, y: height * 0.2)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThemePickerRow: View {
|
||||
let selectedMode: String
|
||||
let onSelect: (AppearanceMode) -> Void
|
||||
|
||||
private let thumbWidth: CGFloat = 76
|
||||
private let thumbHeight: CGFloat = 50
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(String(localized: "settings.app.theme", defaultValue: "Theme"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AppearanceMode.visibleCases) { mode in
|
||||
let isSelected = selectedMode == mode.rawValue
|
||||
Button {
|
||||
onSelect(mode)
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Group {
|
||||
if mode == .system {
|
||||
ZStack {
|
||||
ThemeWindowThumbnail(isDark: false)
|
||||
.mask(
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.frame(width: geo.size.width / 2, height: geo.size.height)
|
||||
.position(x: geo.size.width / 4, y: geo.size.height / 2)
|
||||
}
|
||||
)
|
||||
ThemeWindowThumbnail(isDark: true)
|
||||
.mask(
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.frame(width: geo.size.width / 2, height: geo.size.height)
|
||||
.position(x: geo.size.width * 0.75, y: geo.size.height / 2)
|
||||
}
|
||||
)
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(0.15))
|
||||
.frame(width: 1, height: geo.size.height)
|
||||
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ThemeWindowThumbnail(isDark: mode == .dark)
|
||||
}
|
||||
}
|
||||
.frame(width: thumbWidth, height: thumbHeight)
|
||||
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 10))
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundColor(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppIconPickerRow: View {
|
||||
let selectedMode: String
|
||||
let onSelect: (AppIconMode) -> Void
|
||||
|
|
@ -4777,20 +4937,25 @@ private struct AppIconPickerRow: View {
|
|||
private let autoIconSize: CGFloat = 36
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AppIconMode.allCases) { mode in
|
||||
let isSelected = selectedMode == mode.rawValue
|
||||
Button {
|
||||
onSelect(mode)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 4) {
|
||||
Group {
|
||||
if mode == .automatic {
|
||||
// Show both icons overlapping
|
||||
ZStack {
|
||||
Image("AppIconLight")
|
||||
.resizable()
|
||||
|
|
@ -4816,25 +4981,29 @@ private struct AppIconPickerRow: View {
|
|||
}
|
||||
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 10)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
|
|
|
|||
|
|
@ -235,6 +235,113 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowPrunesOrphanedContextWithoutLiveWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let orphanWindowId = UUID()
|
||||
let orphanManager = TabManager()
|
||||
let orphanSidebarState = SidebarState()
|
||||
let orphanSidebarSelectionState = SidebarSelectionState()
|
||||
|
||||
autoreleasepool {
|
||||
var orphanWindow: NSWindow? = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
|
||||
appDelegate.registerMainWindow(
|
||||
orphanWindow!,
|
||||
windowId: orphanWindowId,
|
||||
tabManager: orphanManager,
|
||||
sidebarState: orphanSidebarState,
|
||||
sidebarSelectionState: orphanSidebarSelectionState
|
||||
)
|
||||
orphanWindow = nil
|
||||
}
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
|
||||
|
||||
let orphanCount = orphanManager.tabs.count
|
||||
XCTAssertNil(
|
||||
appDelegate.addWorkspaceInPreferredMainWindow(),
|
||||
"Workspace creation should refuse orphaned contexts with no live window"
|
||||
)
|
||||
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace")
|
||||
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Orphaned context should be pruned after failed resolution")
|
||||
}
|
||||
|
||||
func testCustomCmdTNewWorkspacePrunesOrphanedContextWithoutLiveWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let existingWindowIds = mainWindowIds()
|
||||
let orphanWindowId = UUID()
|
||||
let orphanManager = TabManager()
|
||||
let orphanSidebarState = SidebarState()
|
||||
let orphanSidebarSelectionState = SidebarSelectionState()
|
||||
|
||||
autoreleasepool {
|
||||
var orphanWindow: NSWindow? = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
|
||||
appDelegate.registerMainWindow(
|
||||
orphanWindow!,
|
||||
windowId: orphanWindowId,
|
||||
tabManager: orphanManager,
|
||||
sidebarState: orphanSidebarState,
|
||||
sidebarSelectionState: orphanSidebarSelectionState
|
||||
)
|
||||
orphanWindow = nil
|
||||
}
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
|
||||
|
||||
let orphanCount = orphanManager.tabs.count
|
||||
let remappedCmdT = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
|
||||
|
||||
withTemporaryShortcut(action: .newTab, shortcut: remappedCmdT) {
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "t",
|
||||
modifiers: [.command],
|
||||
keyCode: 17, // kVK_ANSI_T
|
||||
windowNumber: 0
|
||||
) else {
|
||||
XCTFail("Failed to construct remapped Cmd+T event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
|
||||
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace from remapped Cmd+T")
|
||||
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Remapped Cmd+T should prune the orphaned context after failed resolution")
|
||||
|
||||
let createdWindowIds = mainWindowIds().subtracting(existingWindowIds)
|
||||
for windowId in createdWindowIds {
|
||||
closeWindow(withId: windowId)
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -422,6 +529,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window")
|
||||
}
|
||||
|
||||
func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let targetWindow = window(withId: windowId),
|
||||
let manager = appDelegate.tabManagerFor(windowId: windowId) else {
|
||||
XCTFail("Expected test window and manager")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(manager.tabs[0].panels.count, 1)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "w",
|
||||
modifiers: [.command],
|
||||
keyCode: 13,
|
||||
windowNumber: targetWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+W event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertNil(
|
||||
self.window(withId: windowId),
|
||||
"Cmd+W on the last surface in the last workspace should close the window"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -2336,6 +2485,16 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
|
||||
}
|
||||
|
||||
private func mainWindowIds() -> Set<UUID> {
|
||||
Set(NSApp.windows.compactMap { window in
|
||||
guard let raw = window.identifier?.rawValue,
|
||||
raw.hasPrefix("cmux.main.") else {
|
||||
return nil
|
||||
}
|
||||
return UUID(uuidString: String(raw.dropFirst("cmux.main.".count)))
|
||||
})
|
||||
}
|
||||
|
||||
private func closeWindow(withId windowId: UUID) {
|
||||
guard let window = window(withId: windowId) else { return }
|
||||
window.performClose(nil)
|
||||
|
|
|
|||
|
|
@ -4802,43 +4802,6 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
|
||||
func testDefaultKeepsWorkspaceOpen() {
|
||||
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
|
||||
}
|
||||
|
||||
func testStoredTrueClosesWorkspace() {
|
||||
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
|
||||
}
|
||||
|
||||
func testStoredFalseKeepsWorkspaceOpen() {
|
||||
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarBranchLayoutSettingsTests: XCTestCase {
|
||||
func testDefaultUsesVerticalLayout() {
|
||||
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
|
||||
|
|
@ -5325,6 +5288,54 @@ final class WorkspaceTeardownTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
|
||||
func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
|
||||
let workspace = Workspace()
|
||||
guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
|
||||
XCTFail("Expected focused pane in new workspace")
|
||||
return
|
||||
}
|
||||
|
||||
let staleCurrentDirectory = workspace.currentDirectory
|
||||
let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
|
||||
guard let sourcePanel = workspace.newTerminalSurface(
|
||||
inPane: sourcePaneId,
|
||||
focus: false,
|
||||
workingDirectory: requestedDirectory
|
||||
) else {
|
||||
XCTFail("Expected source terminal panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
|
||||
XCTAssertNil(
|
||||
workspace.panelDirectories[sourcePanel.id],
|
||||
"Expected requested cwd to exist before shell integration reports a live cwd"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
workspace.currentDirectory,
|
||||
staleCurrentDirectory,
|
||||
"Expected focused workspace cwd to remain stale before panel directory updates"
|
||||
)
|
||||
|
||||
guard let splitPanel = workspace.newTerminalSplit(
|
||||
from: sourcePanel.id,
|
||||
orientation: .horizontal,
|
||||
focus: false
|
||||
) else {
|
||||
XCTFail("Expected split terminal panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
splitPanel.requestedWorkingDirectory,
|
||||
requestedDirectory,
|
||||
"Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerWorkspaceOwnershipTests: XCTestCase {
|
||||
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
|
||||
|
|
@ -5456,31 +5467,112 @@ final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
|
|||
|
||||
@MainActor
|
||||
final class TabManagerCloseCurrentPanelTests: XCTestCase {
|
||||
func testCloseCurrentPanelKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
|
||||
func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let initialPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace and focused panel")
|
||||
let panelId = workspace.focusedPanelId,
|
||||
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
||||
XCTFail("Expected selected workspace and focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
let initialWorkspaceId = workspace.id
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(workspace.panels.count, 1)
|
||||
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
|
||||
workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
|
||||
|
||||
var promptCount = 0
|
||||
manager.confirmCloseHandler = { _, _, _ in
|
||||
promptCount += 1
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
|
||||
XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
|
||||
XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
|
||||
}
|
||||
|
||||
func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let panelId = workspace.focusedPanelId,
|
||||
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
||||
XCTFail("Expected selected workspace and focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
|
||||
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
|
||||
|
||||
var promptCount = 0
|
||||
manager.confirmCloseHandler = { _, _, _ in
|
||||
promptCount += 1
|
||||
return false
|
||||
}
|
||||
|
||||
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
||||
|
||||
XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
|
||||
XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
|
||||
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
||||
XCTFail("Expected focused panel in selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
manager.closeCurrentPanelWithConfirmation()
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
|
||||
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
|
||||
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
|
||||
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
|
||||
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
|
||||
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
||||
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
||||
}
|
||||
|
||||
func testClosePanelButtonKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
|
||||
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
|
||||
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
||||
XCTFail("Expected focused panel in selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
|
||||
XCTFail("Expected bonsplit surface ID for focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
|
||||
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
||||
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
||||
}
|
||||
|
||||
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let initialPanelId = workspace.focusedPanelId else {
|
||||
|
|
@ -5496,85 +5588,15 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
|
|||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
|
||||
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
|
||||
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
|
||||
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
|
||||
XCTAssertNil(workspace.panels[initialPanelId])
|
||||
XCTAssertEqual(workspace.panels.count, 1)
|
||||
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
|
||||
let defaults = UserDefaults.standard
|
||||
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defer {
|
||||
if let originalSetting {
|
||||
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
}
|
||||
}
|
||||
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
manager.closeCurrentPanelWithConfirmation()
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
}
|
||||
|
||||
func testClosePanelButtonClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
|
||||
let defaults = UserDefaults.standard
|
||||
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defer {
|
||||
if let originalSetting {
|
||||
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
}
|
||||
}
|
||||
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
|
||||
manager.selectWorkspace(secondWorkspace)
|
||||
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
||||
XCTFail("Expected focused panel in selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
||||
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
||||
|
||||
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
||||
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelWithLegacySettingIgnoresStaleSurfaceId() {
|
||||
let defaults = UserDefaults.standard
|
||||
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
defer {
|
||||
if let originalSetting {
|
||||
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
|
||||
}
|
||||
}
|
||||
|
||||
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
|
||||
let manager = TabManager()
|
||||
let firstWorkspace = manager.tabs[0]
|
||||
let secondWorkspace = manager.addWorkspace()
|
||||
|
|
@ -5626,6 +5648,43 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerNotificationFocusTests: XCTestCase {
|
||||
func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let leftPanelId = workspace.focusedPanelId,
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.focusPanel(leftPanelId)
|
||||
XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable")
|
||||
XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed")
|
||||
|
||||
XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertFalse(
|
||||
workspace.bonsplitController.isSplitZoomed,
|
||||
"Expected notification focus to exit split zoom so the target pane becomes visible"
|
||||
)
|
||||
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused")
|
||||
}
|
||||
|
||||
func testFocusTabFromNotificationReturnsFalseForMissingPanel() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID()))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
|
||||
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
|
||||
|
|
@ -11060,6 +11119,27 @@ final class InternalTabDragConfigurationTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InternalTabDragBundleDeclarationTests: XCTestCase {
|
||||
private func exportedTypeIdentifiers(bundle: Bundle) -> Set<String> {
|
||||
let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
|
||||
return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
|
||||
}
|
||||
|
||||
func testAppBundleExportsInternalDragTypes() {
|
||||
let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
|
||||
|
||||
XCTAssertTrue(
|
||||
exported.contains("com.splittabbar.tabtransfer"),
|
||||
"Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
exported.contains("com.cmux.sidebar-tab-reorder"),
|
||||
"Expected app bundle to export sidebar tab-reorder type, got \(exported)"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
|
|
@ -14588,6 +14668,29 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
|
|||
}
|
||||
|
||||
final class TerminalControllerSocketListenerHealthTests: XCTestCase {
|
||||
func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: SocketControlSettings.stableDefaultSocketPath,
|
||||
stage: "bind",
|
||||
errnoCode: EACCES,
|
||||
currentUserID: 501
|
||||
),
|
||||
SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)
|
||||
)
|
||||
}
|
||||
|
||||
func testNonStableSocketBindFailureDoesNotFallback() {
|
||||
XCTAssertNil(
|
||||
TerminalController.fallbackSocketPathAfterBindFailure(
|
||||
requestedPath: "/tmp/cmux-debug.sock",
|
||||
stage: "bind",
|
||||
errnoCode: EACCES,
|
||||
currentUserID: 501
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTempSocketPath() -> String {
|
||||
"/tmp/cmux-socket-health-\(UUID().uuidString).sock"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1218,10 +1218,11 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux.sock")
|
||||
XCTAssertEqual(path, SocketControlSettings.stableDefaultSocketPath)
|
||||
}
|
||||
|
||||
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
|
||||
|
|
@ -1230,7 +1231,8 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app.nightly",
|
||||
isDebugBuild: false
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
|
||||
|
|
@ -1267,7 +1269,8 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
|
||||
|
|
@ -1275,23 +1278,61 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
|
||||
func testDefaultSocketPathByChannel() {
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
|
||||
"/tmp/cmux.sock"
|
||||
SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
),
|
||||
SocketControlSettings.stableDefaultSocketPath
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
|
||||
SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app.nightly",
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
),
|
||||
"/tmp/cmux-nightly.sock"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
|
||||
SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.tag",
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
),
|
||||
"/tmp/cmux-debug.sock"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
|
||||
SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app.staging.tag",
|
||||
isDebugBuild: false,
|
||||
probeStableDefaultPathEntry: { _ in .missing }
|
||||
),
|
||||
"/tmp/cmux-staging.sock"
|
||||
)
|
||||
}
|
||||
|
||||
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathOwnedByDifferentUser() {
|
||||
let path = SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false,
|
||||
currentUserID: 501,
|
||||
probeStableDefaultPathEntry: { _ in .socket(ownerUserID: 0) }
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
|
||||
}
|
||||
|
||||
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathIsBlockedByNonSocketEntry() {
|
||||
let path = SocketControlSettings.defaultSocketPath(
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false,
|
||||
currentUserID: 501,
|
||||
probeStableDefaultPathEntry: { _ in .other(ownerUserID: 501) }
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
||||
XCTAssertTrue(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
|
|
|
|||
|
|
@ -728,6 +728,16 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackSkipsFallbackWhenRestoreIsUnsafe() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: "fallback-value",
|
||||
allowFallbackScrollback: false
|
||||
)
|
||||
|
||||
XCTAssertNil(resolved)
|
||||
}
|
||||
|
||||
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
|
||||
let workspace = SessionWorkspaceSnapshot(
|
||||
processTitle: "Terminal",
|
||||
|
|
|
|||
|
|
@ -982,6 +982,7 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
if includeGlobalFallback {
|
||||
candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12))
|
||||
candidates.append("/tmp/cmux-debug.sock")
|
||||
candidates.append(stableSocketPath())
|
||||
candidates.append("/tmp/cmux.sock")
|
||||
}
|
||||
|
||||
|
|
@ -995,6 +996,13 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return unique
|
||||
}
|
||||
|
||||
private func stableSocketPath() -> String {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent("cmux.sock", isDirectory: false)
|
||||
.path ?? "/tmp/cmux.sock"
|
||||
}
|
||||
|
||||
private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool {
|
||||
guard let workspaceId, !workspaceId.isEmpty else { return true }
|
||||
let originalPath = socketPath
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
|
@ -88,6 +88,20 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
|
|||
|
||||
The fork branch HEAD is now the section 6 zsh redraw follow-up commit.
|
||||
|
||||
### 7) cmux theme picker helper hooks
|
||||
|
||||
- Commit: `0c52c987b` (Add cmux theme picker helper hooks)
|
||||
- Files:
|
||||
- `build.zig`
|
||||
- `src/cli/list_themes.zig`
|
||||
- `src/main_ghostty.zig`
|
||||
- Summary:
|
||||
- Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS.
|
||||
- Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview.
|
||||
- Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain.
|
||||
|
||||
The fork branch HEAD is now the section 7 cmux theme picker helper commit.
|
||||
|
||||
## Upstreamed fork changes
|
||||
|
||||
### cursor-click-to-move respects OSC 133 click-to-move
|
||||
|
|
@ -111,4 +125,9 @@ These files change frequently upstream; be careful when rebasing the fork:
|
|||
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}`
|
||||
prompt newlines should not get an extra explicit continuation marker after the hidden CR.
|
||||
|
||||
- `src/cli/list_themes.zig`
|
||||
- cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore.
|
||||
If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the
|
||||
stock Ghostty behavior unchanged when the cmux env vars are absent.
|
||||
|
||||
If you resolve a conflict, update this doc with what changed.
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec
|
||||
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42
|
||||
|
|
@ -7,3 +7,4 @@ c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606b
|
|||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
|
||||
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd
|
||||
bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 6b83b66768e8bba871a3753ae8ffbaabd03370b306c429cd86c9cdcc8db82589
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ DERIVED_SET=0
|
|||
TAG=""
|
||||
CMUX_DEBUG_LOG=""
|
||||
CLI_PATH=""
|
||||
LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux"
|
||||
LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path"
|
||||
|
||||
write_dev_cli_shim() {
|
||||
local target="$1"
|
||||
|
|
@ -90,6 +92,13 @@ select_cmux_shim_target() {
|
|||
return 1
|
||||
}
|
||||
|
||||
write_last_socket_path() {
|
||||
local socket_path="$1"
|
||||
mkdir -p "$LAST_SOCKET_PATH_DIR"
|
||||
echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true
|
||||
echo "$socket_path" > /tmp/cmux-last-socket-path || true
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/reload.sh --tag <name> [options]
|
||||
|
|
@ -349,7 +358,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
|
|||
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock"
|
||||
CMUX_SOCKET="/tmp/cmux-debug-${TAG_SLUG}.sock"
|
||||
CMUX_DEBUG_LOG="/tmp/cmux-debug-${TAG_SLUG}.log"
|
||||
echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true
|
||||
write_last_socket_path "$CMUX_SOCKET"
|
||||
echo "$CMUX_DEBUG_LOG" > /tmp/cmux-last-debug-log-path || true
|
||||
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|
|
@ -404,15 +413,25 @@ else
|
|||
fi
|
||||
sleep 0.3
|
||||
CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd"
|
||||
GHOSTTY_HELPER_SRC="$PWD/ghostty/zig-out/bin/ghostty"
|
||||
if [[ -d "$PWD/cmuxd" ]]; then
|
||||
(cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast)
|
||||
fi
|
||||
if [[ -d "$PWD/ghostty" ]]; then
|
||||
(cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast)
|
||||
fi
|
||||
if [[ -x "$CMUXD_SRC" ]]; then
|
||||
BIN_DIR="$APP_PATH/Contents/Resources/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cp "$CMUXD_SRC" "$BIN_DIR/cmuxd"
|
||||
chmod +x "$BIN_DIR/cmuxd"
|
||||
fi
|
||||
if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then
|
||||
BIN_DIR="$APP_PATH/Contents/Resources/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty"
|
||||
chmod +x "$BIN_DIR/ghostty"
|
||||
fi
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
if [[ -x "$CLI_PATH" ]]; then
|
||||
echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ NAME_SET=0
|
|||
BUNDLE_SET=0
|
||||
DERIVED_SET=0
|
||||
TAG=""
|
||||
LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux"
|
||||
LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path"
|
||||
|
||||
write_last_socket_path() {
|
||||
local socket_path="$1"
|
||||
mkdir -p "$LAST_SOCKET_PATH_DIR"
|
||||
echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true
|
||||
echo "$socket_path" > /tmp/cmux-last-socket-path || true
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
|
|
@ -186,12 +195,12 @@ if [[ -f "$INFO_PLIST" ]]; then
|
|||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST"
|
||||
|
||||
# Inject staging socket paths via LSEnvironment so the Release binary
|
||||
# (which defaults to /tmp/cmux.sock) uses isolated sockets instead.
|
||||
# (which defaults to the per-user stable socket) uses isolated sockets instead.
|
||||
STAGING_SLUG="${TAG_SLUG:-staging}"
|
||||
APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux"
|
||||
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-${STAGING_SLUG}.sock"
|
||||
CMUX_SOCKET="/tmp/cmux-${STAGING_SLUG}.sock"
|
||||
echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true
|
||||
write_last_socket_path "$CMUX_SOCKET"
|
||||
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,13 @@ class cmuxError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
_LAST_SOCKET_PATH_FILE = "/tmp/cmux-last-socket-path"
|
||||
_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux")
|
||||
_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock")
|
||||
_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock"
|
||||
_LAST_SOCKET_PATH_FILES = [
|
||||
os.path.join(_APP_SUPPORT_DIR, "last-socket-path"),
|
||||
"/tmp/cmux-last-socket-path",
|
||||
]
|
||||
_DEFAULT_DEBUG_BUNDLE_ID = "com.cmuxterm.app.debug"
|
||||
|
||||
|
||||
|
|
@ -83,13 +89,14 @@ def _default_bundle_id() -> str:
|
|||
|
||||
|
||||
def _read_last_socket_path() -> Optional[str]:
|
||||
try:
|
||||
with open(_LAST_SOCKET_PATH_FILE, "r", encoding="utf-8") as f:
|
||||
path = f.read().strip()
|
||||
if path:
|
||||
return path
|
||||
except OSError:
|
||||
pass
|
||||
for marker_path in _LAST_SOCKET_PATH_FILES:
|
||||
try:
|
||||
with open(marker_path, "r", encoding="utf-8") as f:
|
||||
path = f.read().strip()
|
||||
if path:
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -134,8 +141,8 @@ def _default_socket_path() -> str:
|
|||
if override:
|
||||
if os.path.exists(override) and _can_connect(override):
|
||||
return override
|
||||
# Fall back to other heuristics if the override points at a stale socket file.
|
||||
if not os.path.exists(override):
|
||||
# Treat stable defaults as implicit so old env values still migrate cleanly.
|
||||
if not os.path.exists(override) and override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}:
|
||||
return override
|
||||
|
||||
last_socket = _read_last_socket_path()
|
||||
|
|
@ -144,13 +151,14 @@ def _default_socket_path() -> str:
|
|||
return last_socket
|
||||
|
||||
# Prefer the non-tagged sockets when present.
|
||||
candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
|
||||
candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH]
|
||||
for path in candidates:
|
||||
if os.path.exists(path) and _can_connect(path):
|
||||
return path
|
||||
|
||||
# Otherwise, fall back to the newest tagged debug socket if there is one.
|
||||
# Otherwise, fall back to the newest discovered socket if there is one.
|
||||
tagged = glob.glob("/tmp/cmux-debug-*.sock")
|
||||
tagged.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock")))
|
||||
tagged = [p for p in tagged if os.path.exists(p)]
|
||||
if tagged:
|
||||
tagged.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
|
|
|
|||
41
tests/test_ci_ghosttykit_checksum_present.sh
Executable file
41
tests/test_ci_ghosttykit_checksum_present.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Fails fast when the checked-in ghostty submodule SHA lacks a pinned
|
||||
# GhosttyKit archive checksum. This prevents new ghostty bumps from merging
|
||||
# without the checksum entry that nightly/release workflows require.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CHECKSUMS_FILE="$ROOT_DIR/scripts/ghosttykit-checksums.txt"
|
||||
|
||||
if [ ! -f "$CHECKSUMS_FILE" ]; then
|
||||
echo "FAIL: missing checksum file $CHECKSUMS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GHOSTTY_SHA="$(
|
||||
git -C "$ROOT_DIR" ls-tree HEAD ghostty \
|
||||
| awk '$4 == "ghostty" { print $3; found = 1 } END { if (!found) exit 1 }'
|
||||
)"
|
||||
|
||||
MATCH_COUNT="$(
|
||||
awk -v sha="$GHOSTTY_SHA" '
|
||||
$1 == sha {
|
||||
count += 1
|
||||
}
|
||||
END {
|
||||
print count + 0
|
||||
}
|
||||
' "$CHECKSUMS_FILE"
|
||||
)"
|
||||
|
||||
if [ "$MATCH_COUNT" -eq 0 ]; then
|
||||
echo "FAIL: scripts/ghosttykit-checksums.txt is missing an entry for ghostty $GHOSTTY_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MATCH_COUNT" -ne 1 ]; then
|
||||
echo "FAIL: scripts/ghosttykit-checksums.txt has $MATCH_COUNT entries for ghostty $GHOSTTY_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: scripts/ghosttykit-checksums.txt pins ghostty $GHOSTTY_SHA"
|
||||
|
|
@ -59,24 +59,29 @@ def _wait_for_focused_cwd(
|
|||
client: cmux,
|
||||
expected: str,
|
||||
timeout: float = 12.0,
|
||||
exclude_panel: str | None = None,
|
||||
panel: str | None = None,
|
||||
tab: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Wait for focused_cwd to match expected.
|
||||
|
||||
If exclude_panel is given, also require that focused_panel differs from
|
||||
that value — ensuring we're checking the *new* pane, not the original.
|
||||
If panel is given, also require that focused_panel matches that panel.
|
||||
If tab is given, also require that the selected tab matches that tab.
|
||||
"""
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
cwd = state.get("focused_cwd", "")
|
||||
if cwd != expected:
|
||||
return None
|
||||
if exclude_panel and state.get("focused_panel", "") == exclude_panel:
|
||||
if panel and state.get("focused_panel", "") != panel:
|
||||
return None
|
||||
if tab and state.get("tab", "") != tab:
|
||||
return None
|
||||
return state
|
||||
label = f"focused_cwd={expected!r}"
|
||||
if exclude_panel:
|
||||
label += f" (panel != {exclude_panel})"
|
||||
if panel:
|
||||
label += f" (panel == {panel})"
|
||||
if tab:
|
||||
label += f" (tab == {tab})"
|
||||
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
|
||||
|
||||
|
||||
|
|
@ -84,12 +89,25 @@ def _send_cd_and_wait(
|
|||
client: cmux,
|
||||
target: str,
|
||||
timeout: float = 12.0,
|
||||
surface: str | int | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""cd to target and wait for sidebar focused_cwd to reflect it."""
|
||||
client.send(f"cd {target}\n")
|
||||
if surface is None:
|
||||
client.send(f"cd {target}\n")
|
||||
else:
|
||||
client.send_surface(surface, f"cd {target}\n")
|
||||
return _wait_for_focused_cwd(client, target, timeout=timeout)
|
||||
|
||||
|
||||
def _focus_first_surface(client: cmux) -> str:
|
||||
surfaces = client.list_surfaces()
|
||||
if not surfaces:
|
||||
raise AssertionError("Current tab has no surfaces")
|
||||
surface_id = surfaces[0][1]
|
||||
client.focus_surface(surface_id)
|
||||
return surface_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG", "")
|
||||
|
||||
|
|
@ -119,17 +137,22 @@ def main() -> int:
|
|||
|
||||
print("=== Split CWD Inheritance Tests ===")
|
||||
|
||||
print(" [setup] creating isolated workspace tab...")
|
||||
setup_tab = client.new_tab()
|
||||
client.select_tab(setup_tab)
|
||||
time.sleep(1.0)
|
||||
setup_surface = _focus_first_surface(client)
|
||||
time.sleep(0.5)
|
||||
|
||||
# --- Setup: cd to test_dir_a in workspace 1 ---
|
||||
print(" [setup] cd to test_dir_a and wait for shell integration...")
|
||||
_send_cd_and_wait(client, test_dir_a)
|
||||
_send_cd_and_wait(client, test_dir_a, surface=setup_surface)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
|
||||
f"got {state.get('focused_cwd')!r}")
|
||||
|
||||
# --- Test 1: New split inherits test_dir_a ---
|
||||
print(" [test1] creating right split from test_dir_a...")
|
||||
# Record the original panel so we can verify focus moves to the NEW pane.
|
||||
original_panel = state.get("focused_panel", "")
|
||||
split_result = client.new_split("right")
|
||||
if not split_result:
|
||||
check("split created", False)
|
||||
|
|
@ -138,15 +161,15 @@ def main() -> int:
|
|||
return 1
|
||||
check("split created", True)
|
||||
|
||||
# Wait for the NEW pane (different panel ID) to report test_dir_a.
|
||||
# Socket split commands should not steal focus; focus the returned pane
|
||||
# explicitly, then assert that pane inherited the source cwd.
|
||||
new_panel = split_result.strip()
|
||||
client.focus_surface_by_panel(new_panel)
|
||||
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
|
||||
try:
|
||||
state = _wait_for_focused_cwd(
|
||||
client, test_dir_a, timeout=15.0, exclude_panel=original_panel,
|
||||
client, test_dir_a, timeout=15.0, panel=new_panel,
|
||||
)
|
||||
new_panel = state.get("focused_panel", "")
|
||||
check("test1: focus moved to new pane", new_panel != original_panel,
|
||||
f"original={original_panel!r}, current={new_panel!r}")
|
||||
check("test1: split inherited test_dir_a",
|
||||
state.get("focused_cwd") == test_dir_a,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
|
|
@ -159,8 +182,6 @@ def main() -> int:
|
|||
# First cd to test_dir_b so we have a different dir to inherit
|
||||
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
|
||||
_send_cd_and_wait(client, test_dir_b)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
original_tab = state.get("tab", "")
|
||||
|
||||
tab_result = client.new_tab()
|
||||
if not tab_result:
|
||||
|
|
@ -170,23 +191,14 @@ def main() -> int:
|
|||
return 1
|
||||
check("new tab created", True)
|
||||
|
||||
# New workspace should be a different tab AND inherit test_dir_b
|
||||
# Focus the returned workspace explicitly, then assert it inherited cwd.
|
||||
new_tab = tab_result.strip()
|
||||
client.select_tab(new_tab)
|
||||
time.sleep(4)
|
||||
try:
|
||||
def _new_tab_with_cwd():
|
||||
s = _parse_sidebar_state(client.sidebar_state())
|
||||
tab_id = s.get("tab", "")
|
||||
cwd = s.get("focused_cwd", "")
|
||||
if tab_id != original_tab and cwd == test_dir_b:
|
||||
return s
|
||||
return None
|
||||
|
||||
state = _wait_for(
|
||||
_new_tab_with_cwd, timeout=15.0, interval=0.3,
|
||||
label=f"new tab with focused_cwd={test_dir_b!r}",
|
||||
state = _wait_for_focused_cwd(
|
||||
client, test_dir_b, timeout=15.0, tab=new_tab,
|
||||
)
|
||||
check("test2: focus moved to new tab", state.get("tab") != original_tab,
|
||||
f"original={original_tab!r}, current={state.get('tab')!r}")
|
||||
check("test2: new workspace inherited test_dir_b",
|
||||
state.get("focused_cwd") == test_dir_b,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Notes:
|
|||
|
||||
import base64
|
||||
import errno
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
|
|
@ -32,16 +33,53 @@ class cmuxError(Exception):
|
|||
"""Exception raised for cmux errors."""
|
||||
|
||||
|
||||
_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux")
|
||||
_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock")
|
||||
_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock"
|
||||
_LAST_SOCKET_PATH_FILES = [
|
||||
os.path.join(_APP_SUPPORT_DIR, "last-socket-path"),
|
||||
"/tmp/cmux-last-socket-path",
|
||||
]
|
||||
|
||||
|
||||
def _read_last_socket_path() -> Optional[str]:
|
||||
for marker_path in _LAST_SOCKET_PATH_FILES:
|
||||
try:
|
||||
with open(marker_path, "r", encoding="utf-8") as f:
|
||||
path = f.read().strip()
|
||||
if path:
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _default_socket_path() -> str:
|
||||
# Backwards/forward compatibility: some scripts export CMUX_SOCKET,
|
||||
# while the client historically used CMUX_SOCKET_PATH.
|
||||
override = os.environ.get("CMUX_SOCKET_PATH") or os.environ.get("CMUX_SOCKET")
|
||||
if override:
|
||||
return override
|
||||
candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
|
||||
if os.path.exists(override):
|
||||
return override
|
||||
if override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}:
|
||||
return override
|
||||
|
||||
last_socket = _read_last_socket_path()
|
||||
if last_socket and os.path.exists(last_socket):
|
||||
return last_socket
|
||||
|
||||
candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
discovered = glob.glob("/tmp/cmux-debug-*.sock")
|
||||
discovered.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock")))
|
||||
discovered = [path for path in discovered if os.path.exists(path)]
|
||||
if discovered:
|
||||
discovered.sort(key=os.path.getmtime, reverse=True)
|
||||
return discovered[0]
|
||||
|
||||
return candidates[0]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -227,7 +227,11 @@ def main():
|
|||
print(f"\nFound cmux process: PID {pid}")
|
||||
|
||||
# Try to connect to the socket
|
||||
socket_paths = ["/tmp/cmux.sock", "/tmp/cmux-debug.sock"]
|
||||
socket_paths = [
|
||||
os.path.expanduser("~/Library/Application Support/cmux/cmux.sock"),
|
||||
"/tmp/cmux.sock",
|
||||
"/tmp/cmux-debug.sock",
|
||||
]
|
||||
client = None
|
||||
for socket_path in socket_paths:
|
||||
if os.path.exists(socket_path):
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def infer_app_name_for_osascript(socket_path: str) -> str:
|
|||
Examples:
|
||||
- /tmp/cmux-debug.sock -> "cmux DEV"
|
||||
- /tmp/cmux-debug-foo.sock -> "cmux DEV foo"
|
||||
- /tmp/cmux.sock -> "cmux"
|
||||
- ~/Library/Application Support/cmux/cmux.sock -> "cmux"
|
||||
- /tmp/cmux-foo.sock -> "cmux foo"
|
||||
"""
|
||||
base = Path(socket_path).name
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit fa452db181f361514087558a29204bda7e38218f
|
||||
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826
|
||||
|
|
@ -16,6 +16,7 @@ export async function SiteFooter() {
|
|||
links: [
|
||||
{ label: t("blog"), href: "/blog" },
|
||||
{ label: t("community"), href: "/community" },
|
||||
{ label: t("nightly"), href: "/nightly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
99
web/app/[locale]/nightly/page.tsx
Normal file
99
web/app/[locale]/nightly/page.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { SiteHeader } from "../components/site-header";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "nightly" });
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"underline underline-offset-2 decoration-border hover:decoration-foreground transition-colors";
|
||||
|
||||
export default function NightlyPage() {
|
||||
const t = useTranslations("nightly");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader section={t("title")} />
|
||||
<main className="w-full max-w-2xl mx-auto px-6 py-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src="/logo-nightly.png"
|
||||
alt="cmux NIGHTLY icon"
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-[15px] text-muted mb-8"
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{/* Download button */}
|
||||
<a
|
||||
href="https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
|
||||
className="inline-flex items-center gap-2.5 rounded-full font-medium bg-foreground hover:opacity-85 transition-opacity px-5 py-2.5 text-[15px]"
|
||||
style={{ color: "var(--background)", textDecoration: "none" }}
|
||||
>
|
||||
<svg
|
||||
width={16}
|
||||
height={19}
|
||||
viewBox="0 0 814 1000"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57.8-155.5-127.4c-58.3-81.6-105.6-208.4-105.6-328.6 0-193 125.6-295.5 249.2-295.5 65.7 0 120.5 43.1 161.7 43.1 39.2 0 100.4-45.8 175.1-45.8 28.3 0 130.3 2.6 197.2 99.2zM554.1 159.4c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.9 32.4-57.2 83.6-57.2 135.4 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 137.6-71.2z" />
|
||||
</svg>
|
||||
{t("download")}
|
||||
</a>
|
||||
|
||||
<p
|
||||
className="text-[15px] text-muted mt-8"
|
||||
style={{ lineHeight: 1.5 }}
|
||||
>
|
||||
{t.rich("warning", {
|
||||
githubLink: (chunks) => (
|
||||
<a
|
||||
href="https://github.com/manaflow-ai/cmux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
discordLink: (chunks) => (
|
||||
<a
|
||||
href="https://discord.gg/xsgFEVrWCZ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
{ path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
|
||||
{ path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
|
||||
{ path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
|
||||
{ path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
|
||||
];
|
||||
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "تواصل معنا",
|
||||
"nightly": "إصدار ليلي",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "اللغة"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "أستخدمه منذ أسبوع وهو رائع. علامة تبويب عمودية لكل مهمة قيد التنفيذ. بالداخل، Claude على جانب والمتصفح مع PR والموارد على الجانب الآخر، أتنقل بين المهام وأبقى منظماً. امزج ذلك مع المهارات لجعل Claude يراقب CI بشكل متكرر وما إلى ذلك. أشعر بالتنوير بصراحة",
|
||||
"tonkotsuboy": "انتقلت من Warp إلى Ghostty في بداية السنة، لكن الآن انتقلت إلى cmux. علامات التبويب العمودية مريحة، وأقدر الإشعارات عندما تنتهي مهام Claude Code. هو مبني على Ghostty لذا الأداء السريع ينتقل معه. عرض الفرع والإكمالات التي أعددتها في Ghostty لا تزال تعمل أيضاً."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "أحدث الإصدارات من الفرع الرئيسي",
|
||||
"metaTitle": "cmux NIGHTLY — إصدارات ليلية",
|
||||
"metaDescription": "حمّل cmux NIGHTLY، تطبيق مستقل يُبنى تلقائياً من أحدث commit على main. يعمل بجانب النسخة المستقرة مع تحديثات تلقائية خاصة به.",
|
||||
"description": "يُبنى cmux NIGHTLY تلقائياً من أحدث commit على main. يمتلك معرّف حزمة خاص به، لذا يعمل بجانب النسخة المستقرة دون تعارض. استخدمه لاختبار الميزات الجديدة قبل إصدارها.",
|
||||
"download": "تحميل NIGHTLY لنظام Mac",
|
||||
"warning": "قد تحتوي الإصدارات الليلية على أخطاء أو ميزات غير مكتملة. إذا حدثت مشكلة، أبلغ عنها على <githubLink>GitHub</githubLink> أو في <discordLink>#nightly-bugs على Discord</discordLink> وارجع إلى النسخة المستقرة."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "اللغة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Jezik"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Koristim ovo sedmicu i fantastično je. Vertikalni tab za svaki zadatak u toku. Unutra, Claude na jednoj strani a preglednik sa PR-ovima i resursima na drugoj, prebacujem se između zadataka i ostajam organizovan. Pomiješajte to sa skillovima da Claude prati CI rekurzivno itd. osjećam se prosvijećenim iskreno",
|
||||
"tonkotsuboy": "Prešao sam sa Warpa na Ghostty početkom godine, ali sad sam prešao na cmux. Vertikalni tabovi su praktični, i cijenim što dobijem notifikaciju kada Claude Code zadaci završe. Baziran je na Ghostty-ju tako da munjevite performanse ostaju. Prikaz grane i completioni koje sam podesio u Ghostty-ju i dalje rade."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Najnovije verzije iz main grane",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly verzije",
|
||||
"metaDescription": "Preuzmite cmux NIGHTLY, zasebnu aplikaciju koja se automatski kompajlira iz posljednjeg commita na main. Radi uporedo sa stabilnom verzijom s vlastitim automatskim ažuriranjima.",
|
||||
"description": "cmux NIGHTLY se automatski kompajlira iz posljednjeg commita na main. Ima vlastiti bundle ID, pa radi uporedo sa stabilnom verzijom bez konflikata. Koristite ga za testiranje novih funkcija prije objavljivanja.",
|
||||
"download": "Preuzmi NIGHTLY za Mac",
|
||||
"warning": "Nightly verzije mogu sadržavati greške ili nepotpune funkcije. Ako nešto ne radi, prijavite to na <githubLink>GitHubu</githubLink> ili u <discordLink>#nightly-bugs na Discordu</discordLink> i prebacite se na stabilnu verziju."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Jezik"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Sprog"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Har brugt det i en uge og det er fantastisk. Vertikal fane for hver igangværende opgave. Indeni, Claude på den ene side og browser med PR og ressourcer på den anden, skift mellem opgaver og hold orden. Bland det med skills så Claude kan overvåge CI rekursivt, osv. føler mig oplyst ærlig talt",
|
||||
"tonkotsuboy": "Jeg skiftede fra Warp til Ghostty i starten af året, men nu er jeg skiftet til cmux. De vertikale faner er praktiske, og jeg sætter pris på at blive notificeret når Claude Code-opgaver er færdige. Det er Ghostty-baseret så den lynhurtige ydeevne følger med. Branch-visning og completions jeg satte op i Ghostty virker stadig."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Seneste builds fra main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Download cmux NIGHTLY, en separat app bygget automatisk fra det seneste main-commit. Kører ved siden af den stabile version med egne automatiske opdateringer.",
|
||||
"description": "cmux NIGHTLY bygges automatisk fra det seneste commit på main. Den har sit eget bundle-ID, så den kører ved siden af den stabile version uden konflikter. Brug den til at teste nye funktioner før de udkommer.",
|
||||
"download": "Download NIGHTLY til Mac",
|
||||
"warning": "Nightly builds kan indeholde fejl eller ufærdige funktioner. Hvis noget går galt, rapportér det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og skift tilbage til den stabile version."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Sprog"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Sprache"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Nutze das seit einer Woche und es ist fantastisch. Ein vertikaler Tab pro WIP-Aufgabe. Darin Claude auf einer Seite und Browser mit PR und Ressourcen auf der anderen. Zwischen Aufgaben wechseln und organisiert bleiben. Dazu Skills, damit Claude CI rekursiv überwacht usw. Fühle mich ehrlich gesagt erleuchtet.",
|
||||
"tonkotsuboy": "Anfang des Jahres bin ich von Warp zu Ghostty gewechselt, aber jetzt bin ich bei cmux. Die vertikalen Tabs sind praktisch, und ich schätze die Benachrichtigungen, wenn Claude-Code-Aufgaben fertig sind. Da es auf Ghostty basiert, bleibt die blitzschnelle Performance erhalten. Branch-Anzeige und Vervollständigungen, die ich in Ghostty eingerichtet hatte, funktionieren auch weiterhin."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Aktuelle Builds vom main-Branch",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Laden Sie cmux NIGHTLY herunter, eine separate App, die automatisch aus dem neuesten main-Commit erstellt wird. Läuft neben der stabilen Version mit eigenen Auto-Updates.",
|
||||
"description": "cmux NIGHTLY wird automatisch aus dem neuesten Commit auf main erstellt. Es hat eine eigene Bundle-ID und läuft daher ohne Konflikte neben der stabilen Version. Damit können Sie neue Funktionen testen, bevor sie veröffentlicht werden.",
|
||||
"download": "NIGHTLY für Mac herunterladen",
|
||||
"warning": "Nightly Builds können Fehler oder unfertige Funktionen enthalten. Falls Probleme auftreten, melden Sie diese auf <githubLink>GitHub</githubLink> oder in <discordLink>#nightly-bugs auf Discord</discordLink> und wechseln Sie zur stabilen Version."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Sprache"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contact",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Language"
|
||||
},
|
||||
|
|
@ -583,6 +584,15 @@
|
|||
"connorelsea": "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh",
|
||||
"tonkotsuboy": "I switched from Warp to Ghostty at the start of the year, but now I've switched to cmux. The vertical tabs are convenient, and I appreciate getting notified when Claude Code tasks finish. It's Ghostty-based so the blazing fast performance carries over. Branch display and completions I set up in Ghostty still work too."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Bleeding-edge builds from main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Builds",
|
||||
"metaDescription": "Download cmux NIGHTLY, a separate app built automatically from the latest main commit. Runs alongside the stable version with its own auto-updates.",
|
||||
"description": "cmux NIGHTLY is built automatically from the latest commit on main. It has its own bundle ID, so it runs alongside the stable version without conflicts. Use it to test new features before they ship.",
|
||||
"download": "Download NIGHTLY for Mac",
|
||||
"warning": "Nightly builds may contain bugs or incomplete features. If something breaks, report it on <githubLink>GitHub</githubLink> or in <discordLink>#nightly-bugs on Discord</discordLink>, and switch back to the stable release."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Language"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contacto",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Idioma"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Lo llevo usando una semana y es fantástico. Una pestaña vertical por cada tarea WIP. Dentro, Claude a un lado y navegador con PR y recursos al otro. Cambiar entre tareas y mantener todo organizado. Combinado con skills para que Claude vigile CI recursivamente, etc. Sinceramente me siento iluminado.",
|
||||
"tonkotsuboy": "A principios de año cambié de Warp a Ghostty, pero ahora me cambié a cmux. Las pestañas verticales son cómodas y agradezco las notificaciones cuando terminan las tareas de Claude Code. Al estar basado en Ghostty, el rendimiento ultrarrápido se mantiene. La visualización de ramas y las completaciones que configuré en Ghostty siguen funcionando."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Compilaciones de última hora desde main",
|
||||
"metaTitle": "cmux NIGHTLY — Compilaciones Nightly",
|
||||
"metaDescription": "Descarga cmux NIGHTLY, una app independiente compilada automáticamente desde el último commit en main. Funciona junto a la versión estable con sus propias actualizaciones automáticas.",
|
||||
"description": "cmux NIGHTLY se compila automáticamente desde el último commit en main. Tiene su propio bundle ID, así que funciona junto a la versión estable sin conflictos. Úsala para probar nuevas funciones antes de su lanzamiento.",
|
||||
"download": "Descargar NIGHTLY para Mac",
|
||||
"warning": "Las compilaciones nightly pueden contener errores o funciones incompletas. Si algo falla, repórtalo en <githubLink>GitHub</githubLink> o en <discordLink>#nightly-bugs en Discord</discordLink> y cambia a la versión estable."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Idioma"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contact",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Langue"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Je l'utilise depuis une semaine et c'est fantastique. Un onglet vertical par tache en cours. A l'interieur, Claude d'un cote et le navigateur avec la PR et les ressources de l'autre. Basculer entre les taches en restant organise. En combinant avec les skills pour que Claude surveille le CI recursivement, etc. Franchement, je me sens eclaire.",
|
||||
"tonkotsuboy": "J'etais passe de Warp a Ghostty en debut d'annee, mais maintenant je suis passe a cmux. Les onglets verticaux sont pratiques, et j'apprecie les notifications quand les taches Claude Code sont terminees. Comme c'est base sur Ghostty, les performances ultra-rapides sont conservees. L'affichage des branches et les completions que j'avais configures dans Ghostty fonctionnent toujours."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Builds de pointe depuis main",
|
||||
"metaTitle": "cmux NIGHTLY — Builds Nightly",
|
||||
"metaDescription": "Téléchargez cmux NIGHTLY, une app séparée compilée automatiquement depuis le dernier commit sur main. Fonctionne à côté de la version stable avec ses propres mises à jour automatiques.",
|
||||
"description": "cmux NIGHTLY est compilé automatiquement depuis le dernier commit sur main. Il possède son propre bundle ID et fonctionne donc à côté de la version stable sans conflit. Utilisez-le pour tester les nouvelles fonctionnalités avant leur sortie.",
|
||||
"download": "Télécharger NIGHTLY pour Mac",
|
||||
"warning": "Les builds nightly peuvent contenir des bugs ou des fonctionnalités incomplètes. En cas de problème, signalez-le sur <githubLink>GitHub</githubLink> ou dans <discordLink>#nightly-bugs sur Discord</discordLink> et revenez à la version stable."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Langue"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contatti",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Lingua"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Lo uso da una settimana ed è fantastico. Un tab verticale per ogni task in corso. Dentro, Claude da un lato e il browser con PR e risorse dall'altro, passo tra i task e resto organizzato. Combinalo con le skill per far monitorare la CI a Claude ricorsivamente, ecc. mi sento illuminato onestamente",
|
||||
"tonkotsuboy": "A inizio anno sono passato da Warp a Ghostty, ma ora sono passato a cmux. I tab verticali sono comodi e apprezzo le notifiche quando i task di Claude Code finiscono. È basato su Ghostty quindi le prestazioni fulminee restano. Anche la visualizzazione del branch e i completamenti che avevo impostato su Ghostty funzionano ancora."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Build di ultima generazione dal branch main",
|
||||
"metaTitle": "cmux NIGHTLY — Build Nightly",
|
||||
"metaDescription": "Scarica cmux NIGHTLY, un'app separata compilata automaticamente dall'ultimo commit su main. Funziona accanto alla versione stabile con aggiornamenti automatici propri.",
|
||||
"description": "cmux NIGHTLY viene compilata automaticamente dall'ultimo commit su main. Ha un proprio bundle ID, quindi funziona accanto alla versione stabile senza conflitti. Usala per testare le nuove funzionalità prima del rilascio.",
|
||||
"download": "Scarica NIGHTLY per Mac",
|
||||
"warning": "Le build nightly possono contenere bug o funzionalità incomplete. In caso di problemi, segnalali su <githubLink>GitHub</githubLink> o in <discordLink>#nightly-bugs su Discord</discordLink> e torna alla versione stabile."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Lingua"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "お問い合わせ",
|
||||
"nightly": "ナイトリー",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "言語"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "1週間使ってるけど最高。WIPタスクごとに縦タブ。中にはClaudeを片側に、PRやリソースのブラウザをもう片側に。タスクを切り替えながら整理できる。スキルでClaudeにCIを再帰的に監視させたり。正直、悟りを開いた気分。",
|
||||
"tonkotsuboy": "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた💻 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "mainブランチからの最新ビルド",
|
||||
"metaTitle": "cmux NIGHTLY — ナイトリービルド",
|
||||
"metaDescription": "cmux NIGHTLYをダウンロード。mainの最新コミットから自動ビルドされる独立アプリ。安定版と並行して動作し、独自の自動アップデート機能付き。",
|
||||
"description": "cmux NIGHTLYはmainの最新コミットから自動ビルドされます。独自のバンドルIDを持つため、安定版と競合せず並行して動作します。新機能をリリース前にテストできます。",
|
||||
"download": "Mac版 NIGHTLYをダウンロード",
|
||||
"warning": "ナイトリービルドにはバグや未完成の機能が含まれる場合があります。問題が発生した場合は<githubLink>GitHub</githubLink>または<discordLink>Discordの#nightly-bugs</discordLink>で報告し、安定版に切り替えてください。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "言語"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "ទំនាក់ទំនង",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "ភាសា"
|
||||
},
|
||||
|
|
@ -577,6 +578,15 @@
|
|||
"connorelsea": "ប្រើមកមួយសប្តាហ៍ហើយ វាល្អខ្លាំង។ ផ្ទាំងបញ្ឈរសម្រាប់កិច្ចការនីមួយៗ។ ខាងក្នុង Claude នៅម្ខាង កម្មវិធីរុករកជាមួយ PR និងធនធាននៅម្ខាង ប្ដូររវាងកិច្ចការហើយរក្សាការរៀបចំ។ ផ្សំជាមួយ skills ឱ្យ Claude តាមដាន CI ដដែលៗ ។ រឹតតែស្រស់បំព្រង",
|
||||
"tonkotsuboy": "ខ្ញុំប្ដូរពី Warp មក Ghostty ដើមឆ្នាំ ប៉ុន្តែឥឡូវខ្ញុំប្ដូរមក cmux។ ផ្ទាំងបញ្ឈរងាយស្រួល ហើយខ្ញុំពេញចិត្តដែលទទួលបានជូនដំណឹងពេល Claude Code បានបញ្ចប់។ វាផ្អែកលើ Ghostty ដូច្នេះល្បឿនលឿនប្រែកៗនៅតែមាន។ ការបង្ហាញ branch និង completion ដែលខ្ញុំបានកំណត់ក្នុង Ghostty នៅតែដំណើរការដែរ។"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "កំណែចុងក្រោយពីសាខា main",
|
||||
"metaTitle": "cmux NIGHTLY — កំណែ Nightly",
|
||||
"metaDescription": "ទាញយក cmux NIGHTLY កម្មវិធីដាច់ដោយឡែកដែលត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ ដំណើរការស្របជាមួយកំណែស្ថិរភាពជាមួយការអាប់ដេតស្វ័យប្រវត្តិផ្ទាល់ខ្លួន។",
|
||||
"description": "cmux NIGHTLY ត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ វាមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាដំណើរការស្របជាមួយកំណែស្ថិរភាពដោយគ្មានជម្លោះ។ ប្រើវាដើម្បីសាកល្បងមុខងារថ្មីមុនពេលចេញផ្សាយ។",
|
||||
"download": "ទាញយក NIGHTLY សម្រាប់ Mac",
|
||||
"warning": "កំណែ nightly អាចមានកំហុស ឬមុខងារមិនទាន់ពេញលេញ។ ប្រសិនបើមានបញ្ហា សូមរាយការណ៍នៅលើ <githubLink>GitHub</githubLink> ឬក្នុង <discordLink>#nightly-bugs នៅលើ Discord</discordLink> ហើយប្តូរទៅកំណែស្ថិរភាពវិញ។"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "ភាសា"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "문의",
|
||||
"nightly": "나이틀리",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "언어"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "일주일째 쓰고 있는데 환상적이에요. WIP 작업마다 세로 탭 하나씩. 안에는 한쪽에 Claude, 다른 쪽에 PR과 리소스 브라우저. 작업 전환하면서 정리가 돼요. 스킬로 Claude에게 CI를 재귀적으로 감시시키는 것도 가능. 솔직히 깨달음을 얻은 기분.",
|
||||
"tonkotsuboy": "연초에 Warp에서 Ghostty로 갈아탔는데, 이제는 cmux로 갈아탔어요. 세로 탭이 편하고, Claude Code 작업이 끝나면 알림이 와서 좋아요. Ghostty 기반이라 빠른 성능은 그대로. Ghostty에서 설정한 브랜치 표시랑 자동완성도 그대로 쓸 수 있어요."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "main 브랜치의 최신 빌드",
|
||||
"metaTitle": "cmux NIGHTLY — 나이틀리 빌드",
|
||||
"metaDescription": "cmux NIGHTLY를 다운로드하세요. main의 최신 커밋에서 자동으로 빌드되는 독립 앱입니다. 안정 버전과 나란히 실행되며 독자적인 자동 업데이트를 제공합니다.",
|
||||
"description": "cmux NIGHTLY는 main의 최신 커밋에서 자동으로 빌드됩니다. 자체 번들 ID를 가지고 있어 안정 버전과 충돌 없이 나란히 실행됩니다. 출시 전에 새로운 기능을 테스트할 수 있습니다.",
|
||||
"download": "Mac용 NIGHTLY 다운로드",
|
||||
"warning": "나이틀리 빌드에는 버그나 미완성 기능이 포함될 수 있습니다. 문제가 발생하면 <githubLink>GitHub</githubLink> 또는 <discordLink>Discord의 #nightly-bugs</discordLink>에서 보고하고 안정 버전으로 전환하세요."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "언어"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Språk"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Har brukt dette i en uke og det er fantastisk. Vertikal fane for hver pågående oppgave. Inni har jeg Claude på den ene siden og nettleser med PR og ressurser på den andre, bytter mellom oppgaver og holder orden. Bland det med skills for å la Claude overvåke CI rekursivt, osv. Føler meg opplyst tbh",
|
||||
"tonkotsuboy": "Jeg byttet fra Warp til Ghostty i begynnelsen av året, men nå har jeg byttet til cmux. De vertikale fanene er praktiske, og jeg setter pris på å bli varslet når Claude Code-oppgaver er ferdige. Det er Ghostty-basert, så den lynraske ytelsen følger med. Grenvisning og autofullføringer jeg satte opp i Ghostty fungerer fortsatt også."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Nyeste bygg fra main",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly-bygg",
|
||||
"metaDescription": "Last ned cmux NIGHTLY, en separat app som bygges automatisk fra siste main-commit. Kjører ved siden av den stabile versjonen med egne automatiske oppdateringer.",
|
||||
"description": "cmux NIGHTLY bygges automatisk fra siste commit på main. Den har sin egen bundle-ID, så den kjører ved siden av den stabile versjonen uten konflikter. Bruk den til å teste nye funksjoner før de lanseres.",
|
||||
"download": "Last ned NIGHTLY for Mac",
|
||||
"warning": "Nightly-bygg kan inneholde feil eller uferdige funksjoner. Hvis noe går galt, rapporter det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og bytt tilbake til den stabile versjonen."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Språk"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Kontakt",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Język"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Używam tego od tygodnia i jest fantastyczne. Pionowa karta dla każdego zadania w toku. Wewnątrz, Claude po jednej stronie a przeglądarka z PR i zasobami po drugiej, przełączam się między zadaniami i utrzymuję porządek. Połącz to ze skillami żeby Claude monitorował CI rekursywnie itp. czuję się oświecony szczerze mówiąc",
|
||||
"tonkotsuboy": "Na początku roku przeszedłem z Warpa na Ghostty, ale teraz przeszedłem na cmux. Pionowe karty są wygodne i doceniam powiadomienia gdy zadania Claude Code się kończą. Jest oparty na Ghostty więc błyskawiczna wydajność zostaje. Wyświetlanie brancha i uzupełniania które skonfigurowałem w Ghostty nadal działają."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Najnowsze buildy z gałęzi main",
|
||||
"metaTitle": "cmux NIGHTLY — Buildy Nightly",
|
||||
"metaDescription": "Pobierz cmux NIGHTLY, osobną aplikację budowaną automatycznie z najnowszego commita na main. Działa obok wersji stabilnej z własnymi aktualizacjami automatycznymi.",
|
||||
"description": "cmux NIGHTLY jest budowany automatycznie z najnowszego commita na main. Ma własne bundle ID, więc działa obok wersji stabilnej bez konfliktów. Używaj go, aby testować nowe funkcje przed ich wydaniem.",
|
||||
"download": "Pobierz NIGHTLY na Maca",
|
||||
"warning": "Buildy nightly mogą zawierać błędy lub niekompletne funkcje. W razie problemów zgłoś je na <githubLink>GitHubie</githubLink> lub w <discordLink>#nightly-bugs na Discordzie</discordLink> i przełącz się na wersję stabilną."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Język"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Contato",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Idioma"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Usando há uma semana e é fantástico. Aba vertical para cada tarefa em andamento. Dentro, claudes de um lado e navegador com PR e recursos do outro, alterno entre tarefas e mantenho tudo organizado. Misture com skills para o Claude monitorar CI recursivamente, etc. me sinto iluminado pra ser honesto",
|
||||
"tonkotsuboy": "Mudei do Warp para o Ghostty no início do ano, mas agora migrei para o cmux. As abas verticais são práticas e gosto de ser notificado quando tarefas do Claude Code terminam. É baseado no Ghostty, então a performance ultrarrápida se mantém. A exibição de branches e completions que configurei no Ghostty continuam funcionando também."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Builds de ponta do branch main",
|
||||
"metaTitle": "cmux NIGHTLY — Builds Nightly",
|
||||
"metaDescription": "Baixe o cmux NIGHTLY, um app separado compilado automaticamente do commit mais recente no main. Funciona ao lado da versão estável com suas próprias atualizações automáticas.",
|
||||
"description": "O cmux NIGHTLY é compilado automaticamente do commit mais recente no main. Ele tem seu próprio bundle ID, então funciona ao lado da versão estável sem conflitos. Use-o para testar novos recursos antes do lançamento.",
|
||||
"download": "Baixar NIGHTLY para Mac",
|
||||
"warning": "Builds nightly podem conter bugs ou recursos incompletos. Se algo quebrar, reporte no <githubLink>GitHub</githubLink> ou em <discordLink>#nightly-bugs no Discord</discordLink> e volte para a versão estável."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Idioma"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "Контакты",
|
||||
"nightly": "Ночные сборки",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Язык"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Использую неделю и это фантастика. Вертикальная вкладка для каждой текущей задачи. Внутри Claude с одной стороны и браузер с PR и ресурсами с другой, переключаюсь между задачами и остаюсь организованным. Сочетай это со скиллами чтобы Claude рекурсивно следил за CI и т.д. чувствую себя просветлённым честно говоря",
|
||||
"tonkotsuboy": "В начале года перешёл с Warp на Ghostty, а теперь перешёл на cmux. Вертикальные вкладки удобны, и ценю уведомления когда задачи Claude Code завершаются. Он на базе Ghostty, так что молниеносная скорость сохраняется. Отображение веток и автодополнения, которые я настроил в Ghostty, тоже работают."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "Актуальные сборки из ветки main",
|
||||
"metaTitle": "cmux NIGHTLY — Ночные сборки",
|
||||
"metaDescription": "Скачайте cmux NIGHTLY — отдельное приложение, автоматически собираемое из последнего коммита в main. Работает параллельно со стабильной версией с собственными автообновлениями.",
|
||||
"description": "cmux NIGHTLY автоматически собирается из последнего коммита в main. У него собственный bundle ID, поэтому он работает параллельно со стабильной версией без конфликтов. Используйте его для тестирования новых функций до релиза.",
|
||||
"download": "Скачать NIGHTLY для Mac",
|
||||
"warning": "Ночные сборки могут содержать ошибки или незавершённые функции. Если что-то сломалось, сообщите на <githubLink>GitHub</githubLink> или в <discordLink>#nightly-bugs в Discord</discordLink> и переключитесь на стабильную версию."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Язык"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "ติดต่อ",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "ภาษา"
|
||||
},
|
||||
|
|
@ -577,6 +578,15 @@
|
|||
"connorelsea": "ใช้มาสัปดาห์นึงแล้ว เยี่ยมมาก แท็บแนวตั้งสำหรับแต่ละงานที่ทำอยู่ ข้างในมี Claude อยู่ด้านนึงและเบราว์เซอร์กับ PR และทรัพยากรอยู่อีกด้าน สลับไปมาระหว่างงานได้อย่างเป็นระเบียบ ผสมกับ skills ให้ Claude คอยดู CI แบบ recursive ฯลฯ รู้สึกตาสว่างเลย",
|
||||
"tonkotsuboy": "ผมเปลี่ยนจาก Warp มา Ghostty ตอนต้นปี แต่ตอนนี้เปลี่ยนมา cmux แล้ว แท็บแนวตั้งสะดวกดี และชอบที่แจ้งเตือนเมื่องาน Claude Code เสร็จ มันใช้ Ghostty เป็นฐานก็เลยเร็วเหมือนเดิม การแสดง branch และ completion ที่ตั้งไว้ใน Ghostty ก็ยังใช้ได้อยู่"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "บิลด์ล่าสุดจาก main",
|
||||
"metaTitle": "cmux NIGHTLY — บิลด์ Nightly",
|
||||
"metaDescription": "ดาวน์โหลด cmux NIGHTLY แอปแยกที่สร้างอัตโนมัติจาก commit ล่าสุดบน main ทำงานควบคู่กับเวอร์ชันเสถียรพร้อมอัปเดตอัตโนมัติของตัวเอง",
|
||||
"description": "cmux NIGHTLY สร้างอัตโนมัติจาก commit ล่าสุดบน main มี bundle ID เป็นของตัวเอง จึงทำงานควบคู่กับเวอร์ชันเสถียรได้โดยไม่ขัดแย้ง ใช้เพื่อทดสอบฟีเจอร์ใหม่ก่อนเปิดตัว",
|
||||
"download": "ดาวน์โหลด NIGHTLY สำหรับ Mac",
|
||||
"warning": "บิลด์ nightly อาจมีบั๊กหรือฟีเจอร์ที่ยังไม่สมบูรณ์ หากพบปัญหา รายงานบน <githubLink>GitHub</githubLink> หรือใน <discordLink>#nightly-bugs บน Discord</discordLink> แล้วสลับกลับไปใช้เวอร์ชันเสถียร"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "ภาษา"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "İletişim",
|
||||
"nightly": "Nightly",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "Dil"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "Bir haftadır kullanıyorum ve harika. Her devam eden görev için dikey sekme. İçinde bir tarafta Claude'lar, diğer tarafta PR ve kaynaklarla tarayıcı, görevler arasında geçiş yapıp düzenli kalıyorum. Bunu Claude'un CI'ı özyinelemeli izlemesi için skill'lerle birleştirin, vs. aydınlanmış hissediyorum açıkçası",
|
||||
"tonkotsuboy": "Yılın başında Warp'tan Ghostty'ye geçtim ama şimdi cmux'a geçtim. Dikey sekmeler kullanışlı ve Claude Code görevleri bittiğinde bildirim almayı takdir ediyorum. Ghostty tabanlı olduğu için çok hızlı performans aynen devam ediyor. Ghostty'de ayarladığım dal gösterimi ve tamamlamalar da hâlâ çalışıyor."
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "main dalından güncel derlemeler",
|
||||
"metaTitle": "cmux NIGHTLY — Nightly Derlemeler",
|
||||
"metaDescription": "cmux NIGHTLY indirin. main deki en son commit ten otomatik olarak derlenen bağımsız bir uygulama. Kararlı sürümle yan yana çalışır ve kendi otomatik güncellemelerine sahiptir.",
|
||||
"description": "cmux NIGHTLY, main deki en son commit ten otomatik olarak derlenir. Kendi bundle ID sine sahip olduğundan kararlı sürümle çakışmadan yan yana çalışır. Yeni özellikleri yayınlanmadan önce test etmek için kullanın.",
|
||||
"download": "Mac için NIGHTLY indir",
|
||||
"warning": "Nightly derlemeler hatalar veya tamamlanmamış özellikler içerebilir. Bir sorun oluşursa <githubLink>GitHub</githubLink> veya <discordLink>Discord daki #nightly-bugs</discordLink> kanalında bildirin ve kararlı sürüme geçin."
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "Dil"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "联系我们",
|
||||
"nightly": "每夜构建",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "语言"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "用了一周,非常棒。每个进行中的任务一个垂直标签页。里面一边是 Claude,另一边是浏览器看 PR 和资料,在任务之间切换保持有序。配合 skill 让 Claude 递归监控 CI 等等。感觉开悟了。",
|
||||
"tonkotsuboy": "年初从 Warp 换到 Ghostty,现在又换到了 cmux。垂直标签页很方便,Claude Code 任务完成时收到通知很实用。基于 Ghostty 所以依然飞快。之前在 Ghostty 里设置的分支显示和补全也都能用。"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "来自 main 分支的最新构建",
|
||||
"metaTitle": "cmux NIGHTLY — 每夜构建",
|
||||
"metaDescription": "下载 cmux NIGHTLY,从最新 main 提交自动构建的独立应用。与稳定版并行运行,拥有独立的自动更新。",
|
||||
"description": "cmux NIGHTLY 从 main 的最新提交自动构建。它拥有独立的 Bundle ID,因此可以与稳定版并行运行,互不冲突。用它来测试尚未发布的新功能。",
|
||||
"download": "下载 Mac 版 NIGHTLY",
|
||||
"warning": "每夜构建可能包含错误或不完整的功能。如果遇到问题,请在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上报告,并切换回稳定版。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "语言"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"twitter": "X / Twitter",
|
||||
"discord": "Discord",
|
||||
"contact": "聯絡我們",
|
||||
"nightly": "每夜建置",
|
||||
"copyright": "© {year} Manaflow",
|
||||
"language": "語言"
|
||||
},
|
||||
|
|
@ -581,6 +582,15 @@
|
|||
"connorelsea": "用了一週,非常棒。每個進行中的任務一個垂直分頁。裡面一邊是 Claude,另一邊是瀏覽器看 PR 和資料,在任務之間切換保持有序。搭配 skill 讓 Claude 遞迴監控 CI 等等。感覺開悟了。",
|
||||
"tonkotsuboy": "年初從 Warp 換到 Ghostty,現在又換到了 cmux。垂直分頁很方便,Claude Code 任務完成時收到通知很實用。基於 Ghostty 所以依然飛快。之前在 Ghostty 裡設定的分支顯示和補全也都能用。"
|
||||
},
|
||||
"nightly": {
|
||||
"title": "cmux NIGHTLY",
|
||||
"subtitle": "來自 main 分支的最新建置",
|
||||
"metaTitle": "cmux NIGHTLY — 每夜建置",
|
||||
"metaDescription": "下載 cmux NIGHTLY,從最新 main 提交自動建置的獨立應用。與穩定版並行運行,擁有獨立的自動更新。",
|
||||
"description": "cmux NIGHTLY 從 main 的最新提交自動建置。它擁有獨立的 Bundle ID,因此可以與穩定版並行運行,互不衝突。用它來測試尚未發佈的新功能。",
|
||||
"download": "下載 Mac 版 NIGHTLY",
|
||||
"warning": "每夜建置可能包含錯誤或不完整的功能。如果遇到問題,請在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上回報,並切換回穩定版。"
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"label": "語言"
|
||||
}
|
||||
|
|
|
|||
BIN
web/public/logo-nightly.png
Normal file
BIN
web/public/logo-nightly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Loading…
Add table
Add a link
Reference in a new issue