Fix tmux compat store decoding, layout cleanup, and cross-workspace fallback (#2207)
* Fix tmux compat store decoding, layout cleanup, and cross-workspace fallback Three hardening fixes for the tmux compatibility layer: 1. Add custom init(from:) to TmuxCompatStore using decodeIfPresent so older store files missing mainVerticalLayouts/lastSplitSurface keys decode gracefully instead of silently resetting to an empty store. 2. Clear mainVerticalLayouts entry when a non-main-vertical layout is selected, preventing stale state from redirecting future splits. 3. Only enter caller-anchoring block in split-window when resolveWorkspaceId succeeds, avoiding cross-workspace splits when the fallback target.workspaceId differs from the caller's workspace. * Add auto-equalize after teammate splits After each teammate split-window, call workspace.equalize_splits with orientation: "vertical" to evenly distribute panes in the agent column without affecting the leader/column horizontal divider. Uses the equalize_splits socket method from the omo integration (PR #2087). The equalization works synchronously because bonsplit's setDividerPosition sets ratios on the internal split state directly, no layout flush needed. * Refactor: DRY up claude-teams and omo shared launcher code Extract shared functions from the nearly-identical claude-teams and omo integration code: - configureTmuxCompatEnvironment: parameterized env setup (tmux path prefix, bin env var, term override, extra vars) - createTmuxCompatShimDirectory: shared tmux shim creation with writeShimIfChanged - resolveExecutableInSearchPath: generic PATH search with optional skip predicate - Rename ClaudeTeamsFocusedContext -> TmuxCompatFocusedContext, claudeTeamsFocusedContext -> tmuxCompatFocusedContext, claudeTeamsResolvedSocketPath -> tmuxCompatResolvedSocketPath Both integrations are now thin wrappers over the shared functions. No behavior changes. * Address PR review comments: store load, stale state, pane targets 1. Move loadTmuxCompatStore() inside the caller-anchoring if-let so it's only called when env vars are present (avoids unnecessary file I/O on non-agent splits). 2. Clear lastSplitSurface alongside mainVerticalLayouts when switching away from main-vertical layout, preventing stale seed values on re-entry. 3. Resolve select-layout -t via tmuxResolvePaneTarget first (tmux accepts pane targets like %1), falling back to workspace target. Removes duplicate workspace resolution in the else branch. * Fix select-layout -t fallback: don't apply to wrong workspace When an explicit -t target fails to resolve, error instead of silently falling back to the caller's current workspace. Only use the current workspace as default when no -t was provided. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
71f0e69578
commit
1826cb5698
1 changed files with 149 additions and 87 deletions
236
CLI/cmux.swift
236
CLI/cmux.swift
|
|
@ -9305,7 +9305,7 @@ struct CMUXCLI {
|
|||
return ordered.joined(separator: ":")
|
||||
}
|
||||
|
||||
private struct ClaudeTeamsFocusedContext {
|
||||
private struct TmuxCompatFocusedContext {
|
||||
let socketPath: String
|
||||
let workspaceId: String
|
||||
let windowId: String?
|
||||
|
|
@ -9314,7 +9314,7 @@ struct CMUXCLI {
|
|||
let surfaceId: String?
|
||||
}
|
||||
|
||||
private func claudeTeamsResolvedSocketPath(processEnvironment: [String: String]) -> String {
|
||||
private func tmuxCompatResolvedSocketPath(processEnvironment: [String: String]) -> String {
|
||||
let envSocketPath: String? = {
|
||||
for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] {
|
||||
guard let raw = processEnvironment[key] else { continue }
|
||||
|
|
@ -9341,11 +9341,11 @@ struct CMUXCLI {
|
|||
)
|
||||
}
|
||||
|
||||
private func claudeTeamsFocusedContext(
|
||||
private func tmuxCompatFocusedContext(
|
||||
processEnvironment: [String: String],
|
||||
explicitPassword: String?
|
||||
) -> ClaudeTeamsFocusedContext? {
|
||||
let socketPath = claudeTeamsResolvedSocketPath(processEnvironment: processEnvironment)
|
||||
) -> TmuxCompatFocusedContext? {
|
||||
let socketPath = tmuxCompatResolvedSocketPath(processEnvironment: processEnvironment)
|
||||
let client = SocketClient(path: socketPath)
|
||||
|
||||
do {
|
||||
|
|
@ -9379,7 +9379,7 @@ struct CMUXCLI {
|
|||
let surfaceId = (focused["surface_id"] as? String)
|
||||
?? (focused["surface_ref"] as? String)
|
||||
|
||||
return ClaudeTeamsFocusedContext(
|
||||
return TmuxCompatFocusedContext(
|
||||
socketPath: socketPath,
|
||||
workspaceId: workspaceId,
|
||||
windowId: windowId,
|
||||
|
|
@ -9400,19 +9400,31 @@ struct CMUXCLI {
|
|||
return prefix.contains("cmux claude wrapper - injects hooks and session tracking")
|
||||
}
|
||||
|
||||
private func resolveClaudeExecutable(searchPath: String?) -> String? {
|
||||
private func resolveExecutableInSearchPath(
|
||||
_ name: String,
|
||||
searchPath: String?,
|
||||
skip: ((String) -> Bool)? = nil
|
||||
) -> String? {
|
||||
let entries = searchPath?.split(separator: ":").map(String.init) ?? []
|
||||
for entry in entries where !entry.isEmpty {
|
||||
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
|
||||
.appendingPathComponent("claude", isDirectory: false)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.path
|
||||
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
|
||||
guard !isCmuxClaudeWrapper(at: candidate) else { continue }
|
||||
if let skip, skip(candidate) { continue }
|
||||
return candidate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveClaudeExecutable(searchPath: String?) -> String? {
|
||||
resolveExecutableInSearchPath(
|
||||
"claude",
|
||||
searchPath: searchPath,
|
||||
skip: { self.isCmuxClaudeWrapper(at: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool {
|
||||
commandArgs.contains { arg in
|
||||
arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=")
|
||||
|
|
@ -9426,13 +9438,17 @@ struct CMUXCLI {
|
|||
return ["--teammate-mode", "auto"] + commandArgs
|
||||
}
|
||||
|
||||
private func configureClaudeTeamsEnvironment(
|
||||
private func configureTmuxCompatEnvironment(
|
||||
processEnvironment: [String: String],
|
||||
shimDirectory: URL,
|
||||
executablePath: String,
|
||||
socketPath: String,
|
||||
explicitPassword: String?,
|
||||
focusedContext: ClaudeTeamsFocusedContext?
|
||||
focusedContext: TmuxCompatFocusedContext?,
|
||||
tmuxPathPrefix: String,
|
||||
cmuxBinEnvVar: String,
|
||||
termOverrideEnvVar: String,
|
||||
extraEnvVars: [(key: String, value: String)] = []
|
||||
) {
|
||||
let updatedPath = prependPathEntries(
|
||||
[shimDirectory.path],
|
||||
|
|
@ -9441,17 +9457,16 @@ struct CMUXCLI {
|
|||
let fakeTmuxValue: String = {
|
||||
if let focusedContext {
|
||||
let windowToken = focusedContext.windowId ?? focusedContext.workspaceId
|
||||
return "/tmp/cmux-claude-teams/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
|
||||
return "/tmp/\(tmuxPathPrefix)/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
|
||||
}
|
||||
return processEnvironment["TMUX"] ?? "/tmp/cmux-claude-teams/default,0,0"
|
||||
return processEnvironment["TMUX"] ?? "/tmp/\(tmuxPathPrefix)/default,0,0"
|
||||
}()
|
||||
let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
|
||||
?? processEnvironment["TMUX_PANE"]
|
||||
?? "%1"
|
||||
let fakeTerm = processEnvironment["CMUX_CLAUDE_TEAMS_TERM"] ?? "screen-256color"
|
||||
let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "screen-256color"
|
||||
|
||||
setenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1", 1)
|
||||
setenv("CMUX_CLAUDE_TEAMS_CMUX_BIN", executablePath, 1)
|
||||
setenv(cmuxBinEnvVar, executablePath, 1)
|
||||
setenv("PATH", updatedPath, 1)
|
||||
setenv("TMUX", fakeTmuxValue, 1)
|
||||
setenv("TMUX_PANE", fakeTmuxPane, 1)
|
||||
|
|
@ -9463,6 +9478,9 @@ struct CMUXCLI {
|
|||
setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1)
|
||||
}
|
||||
unsetenv("TERM_PROGRAM")
|
||||
for envVar in extraEnvVars {
|
||||
setenv(envVar.key, envVar.value, 1)
|
||||
}
|
||||
if let focusedContext {
|
||||
setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1)
|
||||
if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty {
|
||||
|
|
@ -9471,27 +9489,54 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
|
||||
private func createClaudeTeamsShimDirectory() throws -> URL {
|
||||
private func configureClaudeTeamsEnvironment(
|
||||
processEnvironment: [String: String],
|
||||
shimDirectory: URL,
|
||||
executablePath: String,
|
||||
socketPath: String,
|
||||
explicitPassword: String?,
|
||||
focusedContext: TmuxCompatFocusedContext?
|
||||
) {
|
||||
configureTmuxCompatEnvironment(
|
||||
processEnvironment: processEnvironment,
|
||||
shimDirectory: shimDirectory,
|
||||
executablePath: executablePath,
|
||||
socketPath: socketPath,
|
||||
explicitPassword: explicitPassword,
|
||||
focusedContext: focusedContext,
|
||||
tmuxPathPrefix: "cmux-claude-teams",
|
||||
cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN",
|
||||
termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM",
|
||||
extraEnvVars: [
|
||||
(key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func createTmuxCompatShimDirectory(
|
||||
directoryName: String,
|
||||
tmuxShimScript: String
|
||||
) throws -> URL {
|
||||
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
let rootPath = URL(fileURLWithPath: homePath, isDirectory: true)
|
||||
let root = URL(fileURLWithPath: homePath, isDirectory: true)
|
||||
.appendingPathComponent(".cmuxterm", isDirectory: true)
|
||||
.appendingPathComponent("claude-teams-bin", isDirectory: true)
|
||||
.path
|
||||
let root = URL(fileURLWithPath: rootPath, isDirectory: true)
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil)
|
||||
let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false)
|
||||
try writeShimIfChanged(tmuxShimScript, to: tmuxURL)
|
||||
return root
|
||||
}
|
||||
|
||||
private func createClaudeTeamsShimDirectory() throws -> URL {
|
||||
let script = """
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@"
|
||||
"""
|
||||
let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8)
|
||||
if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript {
|
||||
try script.write(to: tmuxURL, atomically: false, encoding: .utf8)
|
||||
}
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path)
|
||||
return root
|
||||
return try createTmuxCompatShimDirectory(
|
||||
directoryName: "claude-teams-bin",
|
||||
tmuxShimScript: script
|
||||
)
|
||||
}
|
||||
|
||||
private func runClaudeTeams(
|
||||
|
|
@ -9509,7 +9554,7 @@ struct CMUXCLI {
|
|||
}
|
||||
let shimDirectory = try createClaudeTeamsShimDirectory()
|
||||
let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux")
|
||||
let focusedContext = claudeTeamsFocusedContext(
|
||||
let focusedContext = tmuxCompatFocusedContext(
|
||||
processEnvironment: launcherEnvironment,
|
||||
explicitPassword: explicitPassword
|
||||
)
|
||||
|
|
@ -9554,27 +9599,12 @@ struct CMUXCLI {
|
|||
// MARK: - cmux omo (OpenCode + oh-my-openagent)
|
||||
|
||||
private func resolveOpenCodeExecutable(searchPath: String?) -> String? {
|
||||
let entries = searchPath?.split(separator: ":").map(String.init) ?? []
|
||||
for entry in entries where !entry.isEmpty {
|
||||
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
|
||||
.appendingPathComponent("opencode", isDirectory: false)
|
||||
.path
|
||||
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
|
||||
return candidate
|
||||
}
|
||||
return nil
|
||||
resolveExecutableInSearchPath("opencode", searchPath: searchPath)
|
||||
}
|
||||
|
||||
private func createOMOShimDirectory() throws -> URL {
|
||||
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
let root = URL(fileURLWithPath: homePath, isDirectory: true)
|
||||
.appendingPathComponent(".cmuxterm", isDirectory: true)
|
||||
.appendingPathComponent("omo-bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
// tmux shim: redirects tmux commands to cmux __tmux-compat
|
||||
// Handle -V locally (no socket needed) since __tmux-compat requires a connection.
|
||||
let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false)
|
||||
let tmuxScript = """
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
|
@ -9585,7 +9615,10 @@ struct CMUXCLI {
|
|||
esac
|
||||
exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@"
|
||||
"""
|
||||
try writeShimIfChanged(tmuxScript, to: tmuxURL)
|
||||
let root = try createTmuxCompatShimDirectory(
|
||||
directoryName: "omo-bin",
|
||||
tmuxShimScript: tmuxScript
|
||||
)
|
||||
|
||||
// terminal-notifier shim: intercepts macOS notifications and routes to cmux notify
|
||||
let notifierURL = root.appendingPathComponent("terminal-notifier", isDirectory: false)
|
||||
|
|
@ -9831,46 +9864,25 @@ struct CMUXCLI {
|
|||
executablePath: String,
|
||||
socketPath: String,
|
||||
explicitPassword: String?,
|
||||
focusedContext: ClaudeTeamsFocusedContext?
|
||||
focusedContext: TmuxCompatFocusedContext?
|
||||
) {
|
||||
let updatedPath = prependPathEntries(
|
||||
[shimDirectory.path],
|
||||
to: processEnvironment["PATH"]
|
||||
)
|
||||
let fakeTmuxValue: String = {
|
||||
if let focusedContext {
|
||||
let windowToken = focusedContext.windowId ?? focusedContext.workspaceId
|
||||
return "/tmp/cmux-omo/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
|
||||
}
|
||||
return processEnvironment["TMUX"] ?? "/tmp/cmux-omo/default,0,0"
|
||||
}()
|
||||
let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
|
||||
?? processEnvironment["TMUX_PANE"]
|
||||
?? "%1"
|
||||
let fakeTerm = processEnvironment["CMUX_OMO_TERM"] ?? "screen-256color"
|
||||
|
||||
setenv("CMUX_OMO_CMUX_BIN", executablePath, 1)
|
||||
setenv("PATH", updatedPath, 1)
|
||||
setenv("TMUX", fakeTmuxValue, 1)
|
||||
setenv("TMUX_PANE", fakeTmuxPane, 1)
|
||||
setenv("TERM", fakeTerm, 1)
|
||||
setenv("CMUX_SOCKET_PATH", socketPath, 1)
|
||||
setenv("CMUX_SOCKET", socketPath, 1)
|
||||
if let explicitPassword,
|
||||
!explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1)
|
||||
}
|
||||
unsetenv("TERM_PROGRAM")
|
||||
// Tell oh-my-opencode the API server port so subagent attach works
|
||||
var extraEnvVars: [(key: String, value: String)] = []
|
||||
if processEnvironment["OPENCODE_PORT"] == nil {
|
||||
setenv("OPENCODE_PORT", "4096", 1)
|
||||
}
|
||||
if let focusedContext {
|
||||
setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1)
|
||||
if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty {
|
||||
setenv("CMUX_SURFACE_ID", surfaceId, 1)
|
||||
}
|
||||
extraEnvVars.append((key: "OPENCODE_PORT", value: "4096"))
|
||||
}
|
||||
configureTmuxCompatEnvironment(
|
||||
processEnvironment: processEnvironment,
|
||||
shimDirectory: shimDirectory,
|
||||
executablePath: executablePath,
|
||||
socketPath: socketPath,
|
||||
explicitPassword: explicitPassword,
|
||||
focusedContext: focusedContext,
|
||||
tmuxPathPrefix: "cmux-omo",
|
||||
cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN",
|
||||
termOverrideEnvVar: "CMUX_OMO_TERM",
|
||||
extraEnvVars: extraEnvVars
|
||||
)
|
||||
}
|
||||
|
||||
private func runOMO(
|
||||
|
|
@ -9891,7 +9903,7 @@ struct CMUXCLI {
|
|||
}
|
||||
let shimDirectory = try createOMOShimDirectory()
|
||||
let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux")
|
||||
let focusedContext = claudeTeamsFocusedContext(
|
||||
let focusedContext = tmuxCompatFocusedContext(
|
||||
processEnvironment: launcherEnvironment,
|
||||
explicitPassword: explicitPassword
|
||||
)
|
||||
|
|
@ -10032,10 +10044,14 @@ struct CMUXCLI {
|
|||
// Claude's agent teams targets arbitrary panes (from list-panes),
|
||||
// not necessarily the leader pane from TMUX_PANE. Override the
|
||||
// target to anchor all teammate splits to the leader surface.
|
||||
let store = loadTmuxCompatStore()
|
||||
// Only apply caller anchoring when the caller's workspace resolves
|
||||
// successfully. Falling back to target.workspaceId would pair
|
||||
// the caller's surface with a different workspace, creating an
|
||||
// invalid cross-workspace split.
|
||||
if let callerSurface = tmuxCallerSurfaceHandle(),
|
||||
let callerWorkspace = tmuxCallerWorkspaceHandle() {
|
||||
let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId
|
||||
let callerWorkspace = tmuxCallerWorkspaceHandle(),
|
||||
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client) {
|
||||
let store = loadTmuxCompatStore()
|
||||
if let mvState = store.mainVerticalLayouts[wsId],
|
||||
let lastColumn = mvState.lastColumnSurfaceId {
|
||||
// Main-vertical active: stack in right column.
|
||||
|
|
@ -10079,6 +10095,14 @@ struct CMUXCLI {
|
|||
try saveTmuxCompatStore(updatedStore)
|
||||
}
|
||||
|
||||
// Equalize vertical splits so teammate panes are evenly distributed.
|
||||
// Use orientation: "vertical" to only equalize the agent column,
|
||||
// preserving the leader/column horizontal divider position.
|
||||
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
|
||||
"workspace_id": target.workspaceId,
|
||||
"orientation": "vertical"
|
||||
])
|
||||
|
||||
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
|
||||
_ = try client.sendV2(method: "surface.send_text", params: [
|
||||
"workspace_id": target.workspaceId,
|
||||
|
|
@ -10345,7 +10369,23 @@ struct CMUXCLI {
|
|||
case "select-layout":
|
||||
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
|
||||
let layoutName = parsed.positional.first ?? ""
|
||||
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
|
||||
// select-layout -t accepts pane targets (e.g. %1) in real tmux.
|
||||
// Try pane target first, then workspace target. Only fall back to
|
||||
// the caller's current workspace when no -t was provided; an
|
||||
// explicit -t that fails to resolve should error, not silently
|
||||
// apply to the wrong workspace.
|
||||
let workspaceId: String = {
|
||||
if let target = parsed.value("-t") {
|
||||
if let resolved = try? tmuxResolvePaneTarget(target, client: client) {
|
||||
return resolved.workspaceId
|
||||
}
|
||||
return (try? tmuxResolveWorkspaceTarget(target, client: client)) ?? ""
|
||||
}
|
||||
return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? ""
|
||||
}()
|
||||
guard !workspaceId.isEmpty else {
|
||||
throw CLIError(message: "Could not resolve workspace for select-layout")
|
||||
}
|
||||
if layoutName == "main-vertical" || layoutName == "main-horizontal" {
|
||||
// For main-* layouts, only equalize the agent column (vertical splits),
|
||||
// not the top-level horizontal split between main and agents.
|
||||
|
|
@ -10369,6 +10409,15 @@ struct CMUXCLI {
|
|||
)
|
||||
try saveTmuxCompatStore(store)
|
||||
}
|
||||
} else if !layoutName.isEmpty {
|
||||
// Non-main-vertical layout selected: clear stale state so
|
||||
// future splits don't incorrectly redirect to the old column.
|
||||
var store = loadTmuxCompatStore()
|
||||
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
|
||||
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
|
||||
if removedLayout || removedSplit {
|
||||
try saveTmuxCompatStore(store)
|
||||
}
|
||||
}
|
||||
|
||||
case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
|
||||
|
|
@ -10400,6 +10449,19 @@ struct CMUXCLI {
|
|||
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
|
||||
/// is called after the first split.
|
||||
var lastSplitSurface: [String: String] = [:]
|
||||
|
||||
/// Custom decoder so older store files missing newer keys
|
||||
/// (mainVerticalLayouts, lastSplitSurface) decode gracefully
|
||||
/// instead of throwing and resetting the entire store.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
buffers = try container.decodeIfPresent([String: String].self, forKey: .buffers) ?? [:]
|
||||
hooks = try container.decodeIfPresent([String: String].self, forKey: .hooks) ?? [:]
|
||||
mainVerticalLayouts = try container.decodeIfPresent([String: MainVerticalState].self, forKey: .mainVerticalLayouts) ?? [:]
|
||||
lastSplitSurface = try container.decodeIfPresent([String: String].self, forKey: .lastSplitSurface) ?? [:]
|
||||
}
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
private func tmuxCompatStoreURL() -> URL {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue