Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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 }
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 autoApply silently skips global-config commands

The guard requires localConfigPath to be non-nil, so if a user defines autoApply: true with target: "current" in their global config and navigates to a directory with no local config, checkAutoApply returns early without explanation. loadedCommands already contains the global command, but baseCwd resolution blocks it. Consider falling back to the global config's parent directory when localConfigPath is nil, or documenting that autoApply only works when a local config is present.


guard let command = loadedCommands.first(where: {
$0.autoApply == true && $0.workspace?.target == .current
}) else { return }

autoAppliedWorkspaceIds.insert(workspace.id)
CmuxConfigExecutor.execute(
command: command,
tabManager: tabManager,
baseCwd: baseCwd,
configSourcePath: commandSourcePaths[command.id],
globalConfigPath: globalConfigPath
)
}

// 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 +103 to +108
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This loop iterates current.panels.keys while calling current.closePanel(...), which can mutate current.panels (directly or via bonsplit delegate callbacks). Mutating a dictionary while iterating its keys can trap at runtime. Snapshot the panel IDs first (e.g., let panelIds = Array(current.panels.keys)) before closing, and also handle the case where focusedPanelId is nil (pick a stable panel to keep or skip the pruning) so you don’t accidentally close every panel before applying the layout.

Suggested change
// 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)
// Close all panels except one preserved panel so applyCustomLayout
// starts from a single pane and doesn't stack on existing splits.
let panelIds = Array(current.panels.keys)
let keep = current.focusedPanelId ?? panelIds.first
if let keep {
for panelId in panelIds where panelId != keep {
current.closePanel(panelId, force: true)
}

Copilot uses AI. Check for mistakes.
}
current.applyCustomLayout(layout, baseCwd: resolvedCwd)
Comment on lines +102 to +110
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

Don’t silently force-close existing panels before auto-applying a layout.

This path is also used by autoApply, so force: true can tear down restored panes, kill running terminals, or discard editor/browser state on tab switch with no user intervention.

Safer direction
             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)
+                let panelIdsToClose = current.panels.keys.filter { $0 != keep }
+                for panelId in panelIdsToClose {
+                    guard current.closePanel(panelId, force: command.autoApply != true) else { return }
                 }
                 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 - 106, The code is
force-closing panels unconditionally (current.closePanel(panelId, force: true))
before calling current.applyCustomLayout(layout, baseCwd: resolvedCwd), which
can kill restored/running panes when this path is used by autoApply; change the
logic to avoid silent force-closes by either using non-forcing close (force:
false or the default closePanel call) or only closing panels that are safe
(e.g., check panel state via current.panels[panelId]?.isIdle or similar) and
skip panels with running processes or unsaved state, then proceed to call
applyCustomLayout(layout, baseCwd: resolvedCwd).

}
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