Skip to content

Commit 1ec4234

Browse files
committed
Add config options: persistentWorkspaces & configVersion
#999
1 parent 14b9a36 commit 1ec4234

8 files changed

Lines changed: 90 additions & 18 deletions

File tree

Sources/AppBundle/config/Config.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import AppKit
22
import Common
33
import HotKey
4+
import OrderedCollections
45

56
func getDefaultConfigUrlFromProject() -> URL {
67
var url = URL(filePath: #filePath)
@@ -32,6 +33,7 @@ var defaultConfigUrl: URL {
3233
@MainActor var configUrl: URL = defaultConfigUrl
3334

3435
struct Config: ConvenienceCopyable {
36+
var configVersion: Int = 1
3537
var afterLoginCommand: [any Command] = []
3638
var afterStartupCommand: [any Command] = []
3739
var _indentForNestedContainersWithTheSameOrientation: Void = ()
@@ -43,6 +45,7 @@ struct Config: ConvenienceCopyable {
4345
var automaticallyUnhideMacosHiddenApps: Bool = false
4446
var accordionPadding: Int = 30
4547
var enableNormalizationOppositeOrientationForNestedContainers: Bool = true
48+
var persistentWorkspaces: OrderedSet<String> = []
4649
var execOnWorkspaceChange: [String] = [] // todo deprecate
4750
var keyMapping = KeyMapping()
4851
var execConfig: ExecConfig = ExecConfig()
@@ -56,8 +59,6 @@ struct Config: ConvenienceCopyable {
5659
var modes: [String: Mode] = [:]
5760
var onWindowDetected: [WindowDetectedCallback] = []
5861
var onModeChanged: [any Command] = []
59-
60-
var preservedWorkspaceNames: [String] = []
6162
}
6263

6364
enum DefaultContainerOrientation: String {

Sources/AppBundle/config/parseConfig.swift

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AppKit
22
import Common
33
import HotKey
44
import TOMLKit
5+
import OrderedCollections
56

67
@MainActor
78
func readConfig(forceConfigUrl: URL? = nil) -> Result<(Config, URL), String> {
@@ -83,11 +84,14 @@ struct Parser<S: ConvenienceCopyable, T>: ParserProtocol {
8384

8485
private let keyMappingConfigRootKey = "key-mapping"
8586
private let modeConfigRootKey = "mode"
87+
private let persistentWorkspacesKey = "persistent-workspaces"
8688

8789
// For every new config option you add, think:
8890
// 1. Does it make sense to have different value
8991
// 2. Prefer commands and commands flags over toml options if possible
9092
private let configParser: [String: any ParserProtocol<Config>] = [
93+
"config-version": Parser(\.configVersion, parseConfigVersion),
94+
9195
"after-login-command": Parser(\.afterLoginCommand, parseAfterLoginCommand),
9296
"after-startup-command": Parser(\.afterStartupCommand) { parseCommandOrCommands($0).toParsedToml($1) },
9397

@@ -105,6 +109,7 @@ private let configParser: [String: any ParserProtocol<Config>] = [
105109
"start-at-login": Parser(\.startAtLogin, parseBool),
106110
"automatically-unhide-macos-hidden-apps": Parser(\.automaticallyUnhideMacosHiddenApps, parseBool),
107111
"accordion-padding": Parser(\.accordionPadding, parseInt),
112+
persistentWorkspacesKey: Parser(\.persistentWorkspaces, parsePersistentWorkspaces),
108113
"exec-on-workspace-change": Parser(\.execOnWorkspaceChange, parseArrayOfStrings),
109114
"exec": Parser(\.execConfig, parseExecConfig),
110115

@@ -181,17 +186,24 @@ func parseCommandOrCommands(_ raw: TOMLValueConvertible) -> Parsed<[any Command]
181186
config.keyMapping = mapping
182187
}
183188

189+
// Parse modeConfigRootKey after keyMappingConfigRootKey
184190
if let modes = rawTable[modeConfigRootKey].flatMap({ parseModes($0, .rootKey(modeConfigRootKey), &errors, config.keyMapping.resolve()) }) {
185191
config.modes = modes
186192
}
187193

188-
config.preservedWorkspaceNames = config.modes.values.lazy
189-
.flatMap { (mode: Mode) -> [HotkeyBinding] in Array(mode.bindings.values) }
190-
.flatMap { (binding: HotkeyBinding) -> [String] in
191-
binding.commands.filterIsInstance(of: WorkspaceCommand.self).compactMap { $0.args.target.val.workspaceNameOrNil()?.raw } +
192-
binding.commands.filterIsInstance(of: MoveNodeToWorkspaceCommand.self).compactMap { $0.args.target.val.workspaceNameOrNil()?.raw }
194+
if config.configVersion <= 1 {
195+
if rawTable.contains(key: persistentWorkspacesKey) {
196+
errors += [.semantic(.rootKey(persistentWorkspacesKey), "This config option is only available since 'config-version = 2'")]
193197
}
194-
+ (config.workspaceToMonitorForceAssignment).keys
198+
config.persistentWorkspaces = (config.modes.values.lazy
199+
.flatMap { (mode: Mode) -> [HotkeyBinding] in Array(mode.bindings.values) }
200+
.flatMap { (binding: HotkeyBinding) -> [String] in
201+
binding.commands.filterIsInstance(of: WorkspaceCommand.self).compactMap { $0.args.target.val.workspaceNameOrNil()?.raw } +
202+
binding.commands.filterIsInstance(of: MoveNodeToWorkspaceCommand.self).compactMap { $0.args.target.val.workspaceNameOrNil()?.raw }
203+
}
204+
+ (config.workspaceToMonitorForceAssignment).keys)
205+
.toOrderedSet()
206+
}
195207

196208
if config.enableNormalizationFlattenContainers {
197209
let containsSplitCommand = config.modes.values.lazy.flatMap { $0.bindings.values }
@@ -222,6 +234,13 @@ func parseIndentForNestedContainersWithTheSameOrientation(
222234
return .failure(.semantic(backtrace, msg))
223235
}
224236

237+
func parseConfigVersion(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml<Int> {
238+
let min = 1
239+
let max = 2
240+
return parseInt(raw, backtrace)
241+
.filter(.semantic(backtrace, "Must be in [\(min), \(max)] range")) { (min ... max).contains($0) }
242+
}
243+
225244
func parseInt(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml<Int> {
226245
raw.int.orFailure(expectedActualTypeError(expected: .int, actual: raw.type, backtrace))
227246
}
@@ -289,6 +308,14 @@ private func skipParsing<T: Sendable>(_ value: T) -> @Sendable (_ raw: TOMLValue
289308
{ _, _ in .success(value) }
290309
}
291310

311+
private func parsePersistentWorkspaces(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml<OrderedSet<String>> {
312+
parseArrayOfStrings(raw, backtrace)
313+
.flatMap { arr in
314+
let set = arr.toOrderedSet()
315+
return set.count == arr.count ? .success(set) : .failure(.semantic(backtrace, "Contains duplicated workspace names"))
316+
}
317+
}
318+
292319
private func parseArrayOfStrings(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml<[String]> {
293320
parseTomlArray(raw, backtrace)
294321
.flatMap { arr in

Sources/AppBundle/tree/Workspace.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ private func getStubWorkspace(forPoint point: CGPoint) -> Workspace {
2424
{
2525
return candidate
2626
}
27-
let preservedNames = config.preservedWorkspaceNames.toSet()
2827
return (1 ... Int.max).lazy
2928
.map { Workspace.get(byName: String($0)) }
30-
.first { $0.isEffectivelyEmpty && !$0.isVisible && !preservedNames.contains($0.name) && $0.forceAssignedMonitor == nil }
29+
.first { $0.isEffectivelyEmpty && !$0.isVisible && !config.persistentWorkspaces.contains($0.name) && $0.forceAssignedMonitor == nil }
3130
.orDie("Can't create empty workspace")
3231
}
3332

@@ -72,24 +71,22 @@ final class Workspace: TreeNode, NonLeafTreeNodeObject, Hashable, Comparable {
7271

7372
@MainActor
7473
var description: String {
75-
let preservedNames = config.preservedWorkspaceNames.toSet()
7674
let description = [
7775
("name", name),
7876
("isVisible", String(isVisible)),
7977
("isEffectivelyEmpty", String(isEffectivelyEmpty)),
80-
("doKeepAlive", String(preservedNames.contains(name))),
78+
("doKeepAlive", String(config.persistentWorkspaces.contains(name))),
8179
].map { "\($0.0): '\(String(describing: $0.1))'" }.joined(separator: ", ")
8280
return "Workspace(\(description))"
8381
}
8482

8583
@MainActor
8684
static func garbageCollectUnusedWorkspaces() {
87-
let preservedNames = config.preservedWorkspaceNames.toSet()
88-
for name in preservedNames {
89-
_ = get(byName: name) // Make sure that all preserved workspaces are "cached"
85+
for name in config.persistentWorkspaces {
86+
_ = get(byName: name) // Make sure that all persistent workspaces are "cached"
9087
}
9188
workspaceNameToWorkspace = workspaceNameToWorkspace.filter { (_, workspace: Workspace) in
92-
preservedNames.contains(workspace.name) ||
89+
config.persistentWorkspaces.contains(workspace.name) ||
9390
!workspace.isEffectivelyEmpty ||
9491
workspace.isVisible ||
9592
workspace.name == focus.workspace.name

Sources/AppBundleTests/config/ConfigTest.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ final class ConfigTest: XCTestCase {
1919
assertEquals(errors, [])
2020
}
2121

22+
func testConfigVersionOutOfBounds() {
23+
let (_, errors) = parseConfig(
24+
"""
25+
config-version = 0
26+
""",
27+
)
28+
assertEquals(errors.descriptions, ["config-version: Must be in [1, 2] range"])
29+
}
30+
2231
func testExecOnWorkspaceChangeDifferentTypesError() {
2332
let (_, errors) = parseConfig(
2433
"""
@@ -28,6 +37,25 @@ final class ConfigTest: XCTestCase {
2837
assertEquals(errors.descriptions, ["exec-on-workspace-change[1]: Expected type is \'string\'. But actual type is \'integer\'"])
2938
}
3039

40+
func testDuplicatedPersistentWorkspaces() {
41+
let (_, errors) = parseConfig(
42+
"""
43+
config-version = 2
44+
persistent-workspaces = ['a', 'a']
45+
""",
46+
)
47+
assertEquals(errors.descriptions, ["persistent-workspaces: Contains duplicated workspace names"])
48+
}
49+
50+
func testPersistentWorkspacesAreAvailableOnlySinceVersion2() {
51+
let (_, errors) = parseConfig(
52+
"""
53+
persistent-workspaces = ['a']
54+
""",
55+
)
56+
assertEquals(errors.descriptions, ["persistent-workspaces: This config option is only available since \'config-version = 2\'"])
57+
}
58+
3159
func testQueryCantBeUsedInConfig() {
3260
let (_, errors) = parseConfig(
3361
"""
@@ -111,7 +139,7 @@ final class ConfigTest: XCTestCase {
111139
""",
112140
)
113141
assertEquals(errors.descriptions, [])
114-
assertEquals(config.preservedWorkspaceNames.sorted(), ["1", "2", "3", "4"])
142+
assertEquals(config.persistentWorkspaces.sorted(), ["1", "2", "3", "4"])
115143
}
116144

117145
func testUnknownTopLevelKeyParseError() {

Sources/AppBundleTests/testUtil.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func setUpWorkspacesForTests() {
2323

2424
// Don't create any bindings and workspaces for tests
2525
config.modes = [mainModeId: Mode(name: nil, bindings: [:])]
26-
config.preservedWorkspaceNames = []
26+
config.persistentWorkspaces = []
2727

2828
for workspace in Workspace.all {
2929
for child in workspace.children {

Sources/Common/util/SequenceEx.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AppKit
2+
import OrderedCollections
23

34
extension Sequence {
45
public func filterNotNil<Unwrapped>() -> [Unwrapped] where Element == Unwrapped? {
@@ -118,4 +119,5 @@ extension Sequence where Self.Element: Comparable {
118119

119120
extension Sequence where Element: Hashable {
120121
public func toSet() -> Set<Element> { Set(self) }
122+
public func toOrderedSet() -> OrderedSet<Element> { OrderedSet(self) }
121123
}

docs/config-examples/default-config.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Place a copy of this config to ~/.aerospace.toml
22
# After that, you can edit ~/.aerospace.toml to your liking
33

4+
# Config version for compatibility and deprecations
5+
# Fallback value (if you omit the key): config-version = 1
6+
config-version = 2
7+
48
# You can use it to add commands that run after AeroSpace startup.
59
# Available commands : https://nikitabobko.github.io/AeroSpace/commands
610
after-startup-command = []
@@ -37,6 +41,14 @@ on-focused-monitor-changed = ['move-mouse monitor-lazy-center']
3741
# Also see: https://nikitabobko.github.io/AeroSpace/goodies#disable-hide-app
3842
automatically-unhide-macos-hidden-apps = false
3943

44+
# List of workspaces that should stay alive even when they contain no windows,
45+
# even when they are invisible.
46+
# This config version is only available since 'config-version = 2'
47+
# Fallback value (if you omit the key): persistent-workspaces = []
48+
persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C",
49+
"D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O",
50+
"P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]
51+
4052
# A callback that runs every time binding mode changes
4153
# See: https://nikitabobko.github.io/AeroSpace/guide#binding-modes
4254
# See: https://nikitabobko.github.io/AeroSpace/commands#mode

docs/config-examples/i3-like-config-example.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Reference: https://github.com/i3/i3/blob/next/etc/config
22

3+
config-version = 2
4+
5+
# In i3, all workspaces are phantom
6+
persistent-workspaces = []
7+
38
# i3 doesn't have "normalizations" feature that why we disable them here.
49
# But the feature is very helpful.
510
# Normalizations eliminate all sorts of weird tree configurations that don't make sense.

0 commit comments

Comments
 (0)