-
Notifications
You must be signed in to change notification settings - Fork 944
Add sidebar sections with persistence and auto-apply #2646
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
base: main
Are you sure you want to change the base?
Changes from 12 commits
853003f
8d3c578
14a0980
352ad20
0519a81
7662dde
6a15254
93a9341
ad605e1
2e9b441
c4378cf
27ddeee
2f90041
69e6439
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 | ||
| ) | ||
| } | ||
|
|
||
| // 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
+103
to
+108
|
||||||||||||||||||||||||||||
| // 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) | |
| } |
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.
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).
Uh oh!
There was an error while loading. Please reload this page.