Repository navigation
Add sidebar sections with persistence and auto-apply #2642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
853003f
8d3c578
14a0980
352ad20
0519a81
7662dde
6a15254
93a9341
ad605e1
2e9b441
c4378cf
27ddeee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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? | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
@@ -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) { | ||
|
|
@@ -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 | ||
|
|
@@ -292,6 +308,7 @@ final class CmuxConfigStore: ObservableObject { | |
| // MARK: - Public API | ||
|
|
||
| func wireDirectoryTracking(tabManager: TabManager) { | ||
| trackedTabManager = tabManager | ||
| cancellables.removeAll() | ||
|
|
||
| tabManager.$selectedTabId | ||
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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 } | ||
|
|
||
| autoAppliedWorkspaceIds.insert(workspace.id) | ||
| CmuxConfigExecutor.execute( | ||
| command: command, | ||
| tabManager: tabManager, | ||
| baseCwd: baseCwd, | ||
| configSourcePath: commandSourcePaths[command.id], | ||
| globalConfigPath: globalConfigPath | ||
| ) | ||
| } | ||
|
Comment on lines
+418
to
441
|
||
|
|
||
| // MARK: - Parsing | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||
| 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) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
autoApplycommand fires; others are silently droppedcheckAutoApplyuses.first(where:), so if a config defines multiple commands withautoApply: trueandtarget: "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 overloadedCommands.filter { ... }instead.