Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; };
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; };
A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */; };
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; };
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
Expand Down Expand Up @@ -211,6 +212,7 @@
A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = "<group>"; };
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = "<group>"; };
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = "<group>"; };
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -458,6 +460,7 @@
children = (
A5001011 /* cmuxApp.swift */,
A5001012 /* ContentView.swift */,
A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */,
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */,
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */,
A50012F0 /* Backport.swift */,
Expand Down Expand Up @@ -785,6 +788,7 @@
A5001001 /* cmuxApp.swift in Sources */,
A5001002 /* ContentView.swift in Sources */,
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */,
A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */,
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */,
A50012F1 /* Backport.swift in Sources */,
A50012F3 /* KeyboardShortcutSettings.swift in Sources */,
Expand Down
59 changes: 58 additions & 1 deletion Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
var description: String?
var keywords: [String]?
var restart: CmuxRestartBehavior?
var autoApply: Bool?
var workspace: CmuxWorkspaceDefinition?
var command: String?
var confirm: Bool?
Expand All @@ -24,6 +25,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
description: String? = nil,
keywords: [String]? = nil,
restart: CmuxRestartBehavior? = nil,
autoApply: Bool? = nil,
workspace: CmuxWorkspaceDefinition? = nil,
command: String? = nil,
confirm: Bool? = nil
Expand All @@ -32,6 +34,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
self.description = description
self.keywords = keywords
self.restart = restart
self.autoApply = autoApply
self.workspace = workspace
self.command = command
self.confirm = confirm
Expand All @@ -43,6 +46,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
description = try container.decodeIfPresent(String.self, forKey: .description)
keywords = try container.decodeIfPresent([String].self, forKey: .keywords)
restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart)
autoApply = try container.decodeIfPresent(Bool.self, forKey: .autoApply)
workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace)
command = try container.decodeIfPresent(String.self, forKey: .command)
confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm)
Expand Down Expand Up @@ -90,23 +94,33 @@ enum CmuxRestartBehavior: String, Codable, Sendable {
case confirm
}

enum CmuxWorkspaceTarget: String, Codable, Sendable {
/// Apply the layout to the currently selected workspace.
case current
/// Create a new workspace (default).
case new
}

struct CmuxWorkspaceDefinition: Codable, Sendable {
var name: String?
var cwd: String?
var color: String?
var target: CmuxWorkspaceTarget?
var layout: CmuxLayoutNode?

init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) {
init(name: String? = nil, cwd: String? = nil, color: String? = nil, target: CmuxWorkspaceTarget? = nil, layout: CmuxLayoutNode? = nil) {
self.name = name
self.cwd = cwd
self.color = color
self.target = target
self.layout = layout
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
target = try container.decodeIfPresent(CmuxWorkspaceTarget.self, forKey: .target)
layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout)

if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) {
Expand Down Expand Up @@ -270,6 +284,8 @@ final class CmuxConfigStore: ObservableObject {
return (home as NSString).appendingPathComponent(".config/cmux/cmux.json")
}()

private weak var trackedTabManager: TabManager?
private var autoAppliedWorkspaceIds = Set<UUID>()
private var cancellables = Set<AnyCancellable>()
private var localFileWatchSource: DispatchSourceFileSystemObject?
private var localFileDescriptor: Int32 = -1
Expand All @@ -292,6 +308,7 @@ final class CmuxConfigStore: ObservableObject {
// MARK: - Public API

func wireDirectoryTracking(tabManager: TabManager) {
trackedTabManager = tabManager
cancellables.removeAll()

tabManager.$selectedTabId
Expand All @@ -311,6 +328,20 @@ final class CmuxConfigStore: ObservableObject {
}
.store(in: &cancellables)

// Separate observer for autoApply: fires on every workspace switch
// (after a short delay so the workspace is fully visible).
tabManager.$selectedTabId
.dropFirst() // skip the initial value on subscribe
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
// Small delay so the config for the new directory loads first.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
self?.checkAutoApply()
}
}
.store(in: &cancellables)

if let directory = tabManager.selectedWorkspace?.currentDirectory {
updateLocalConfigPath(directory)
}
Expand Down Expand Up @@ -381,6 +412,32 @@ final class CmuxConfigStore: ObservableObject {
loadedCommands = commands
commandSourcePaths = sourcePaths
configRevision &+= 1
checkAutoApply()
}

/// If the selected workspace hasn't been auto-applied this session and a
/// loaded command has `autoApply: true` with `target: "current"`, execute
/// it automatically. Tracks applied workspaces so it only fires once per
/// workspace per app session.
private func checkAutoApply() {
guard let tabManager = trackedTabManager,
let workspace = tabManager.selectedWorkspace,
!autoAppliedWorkspaceIds.contains(workspace.id),
let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent })
else { return }

guard let command = loadedCommands.first(where: {
$0.autoApply == true && $0.workspace?.target == .current
}) else { return }
Comment on lines +429 to +431

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Only the first autoApply command fires; others are silently dropped

checkAutoApply uses .first(where:), so if a config defines multiple commands with autoApply: true and target: "current", only the first one executes. If "at most one" is intentional, a comment here would prevent future confusion; if all matching commands should run, this should iterate over loadedCommands.filter { ... } instead.


autoAppliedWorkspaceIds.insert(workspace.id)
CmuxConfigExecutor.execute(
command: command,
tabManager: tabManager,
baseCwd: baseCwd,
configSourcePath: commandSourcePaths[command.id],
globalConfigPath: globalConfigPath
)
}
Comment on lines +418 to 441

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkAutoApply() can execute workspace commands automatically on tab switch based on a local cmux.json, but it does not apply any trust/confirmation gating (unlike confirm: true shell commands). Since this now runs without explicit user action, consider requiring the config source path to be trusted (or limiting autoApply to global config / trusted directories) to avoid unexpected behavior from untrusted repos.

Copilot uses AI. Check for mistakes.

// MARK: - Parsing
Expand Down
21 changes: 20 additions & 1 deletion Sources/CmuxConfigExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ struct CmuxConfigExecutor {
baseCwd: String
) {
let workspaceName = wsDef.name ?? command.name
let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd)

// "target": "current" — apply the layout to the selected workspace in-place.
if wsDef.target == .current, let current = tabManager.selectedWorkspace {
current.setCustomTitle(workspaceName)
if let color = wsDef.color {
current.setCustomColor(color)
}
if let layout = wsDef.layout {
// Close all panels except the focused one so applyCustomLayout
// starts from a single pane and doesn't stack on existing splits.
let keep = current.focusedPanelId
for panelId in current.panels.keys where panelId != keep {
current.closePanel(panelId, force: true)
Comment on lines +102 to +104

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

focusedPanelId is optional; if it is nil here, the loop will close all panels because panelId != keep is always true, potentially leaving the workspace empty before applying the layout. Please guard against nil (e.g., fall back to a known panel to keep, or early-return when there is no focused panel).

Suggested change
let keep = current.focusedPanelId
for panelId in current.panels.keys where panelId != keep {
current.closePanel(panelId, force: true)
if let keep = current.focusedPanelId ?? current.panels.keys.first {
for panelId in current.panels.keys where panelId != keep {
current.closePanel(panelId, force: true)
}

Copilot uses AI. Check for mistakes.
}
current.applyCustomLayout(layout, baseCwd: resolvedCwd)
}
Comment on lines +99 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against nil focusedPanelId to prevent closing all panels.

If focusedPanelId is nil (e.g., empty workspace or transient state), keep will be nil and panelId != keep evaluates to true for all panel IDs. This closes every panel, leaving zero panes. Then applyCustomLayout silently returns at its guard (per Sources/Workspace.swift:765), resulting in an empty workspace with no layout applied.

🛡️ Proposed fix to guard focusedPanelId
 if let layout = wsDef.layout {
     // Close all panels except the focused one so applyCustomLayout
     // starts from a single pane and doesn't stack on existing splits.
-    let keep = current.focusedPanelId
-    for panelId in current.panels.keys where panelId != keep {
-        current.closePanel(panelId, force: true)
+    if let keep = current.focusedPanelId {
+        for panelId in current.panels.keys where panelId != keep {
+            current.closePanel(panelId, force: true)
+        }
     }
     current.applyCustomLayout(layout, baseCwd: resolvedCwd)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let layout = wsDef.layout {
// Close all panels except the focused one so applyCustomLayout
// starts from a single pane and doesn't stack on existing splits.
let keep = current.focusedPanelId
for panelId in current.panels.keys where panelId != keep {
current.closePanel(panelId, force: true)
}
current.applyCustomLayout(layout, baseCwd: resolvedCwd)
}
if let layout = wsDef.layout {
// Close all panels except the focused one so applyCustomLayout
// starts from a single pane and doesn't stack on existing splits.
if let keep = current.focusedPanelId {
for panelId in current.panels.keys where panelId != keep {
current.closePanel(panelId, force: true)
}
}
current.applyCustomLayout(layout, baseCwd: resolvedCwd)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfigExecutor.swift` around lines 99 - 107, Guard against a nil
focusedPanelId before closing panels: check current.focusedPanelId and if it's
nil, skip the "close all except focused" loop (or bail out of the layout branch)
so you don't close every panel; specifically, in the block that checks
wsDef.layout, ensure you only set let keep = current.focusedPanelId and iterate
current.panels.keys to call current.closePanel(panelId, force: true) when keep
is non-nil, and only call current.applyCustomLayout(layout, baseCwd:
resolvedCwd) after confirming a valid focusedPanelId (or handling the nil case
explicitly).

return
}

let restart = command.restart ?? .ignore

if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) {
Expand Down Expand Up @@ -118,7 +138,6 @@ struct CmuxConfigExecutor {
}
}

let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd)
let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd)
newWorkspace.setCustomTitle(workspaceName)
if let color = wsDef.color {
Expand Down
Loading
Loading