Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions Sources/AppBundle/command/cmdManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ extension CmdArgs {
command = ListModesCommand(args: self as! ListModesCmdArgs)
case .listMonitors:
command = ListMonitorsCommand(args: self as! ListMonitorsCmdArgs)
case .listTree:
command = ListTreeCommand(args: self as! ListTreeCmdArgs)
case .listWindows:
command = ListWindowsCommand(args: self as! ListWindowsCmdArgs)
case .listWorkspaces:
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppBundle/command/format.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func unknownInterpolationVariable(variable: String, _ obj: AeroObj) -> String {
"Possible values:\n\(getAvailableInterVars(for: obj.kind).joined(separator: "\n").prependLines(" "))"
}

private func toLayoutString(tc: TilingContainer) -> String {
func toLayoutString(tc: TilingContainer) -> String {
switch (tc.layout, tc.orientation) {
case (.tiles, .h): return LayoutCmdArgs.LayoutDescription.h_tiles.rawValue
case (.tiles, .v): return LayoutCmdArgs.LayoutDescription.v_tiles.rawValue
Expand Down
83 changes: 83 additions & 0 deletions Sources/AppBundle/command/impl/ListTreeCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import AppKit
import Common

struct ListTreeCommand: Command {
let args: ListTreeCmdArgs
/*conforms*/ let shouldResetClosedWindowsCache = false

func run(_ env: CmdEnv, _ io: CmdIo) -> BinaryExitCode {
let focus = focus
let workspaces: [Workspace] = args.focused
? [focus.workspace]
: args.workspaces
.flatMap { filter in
switch filter {
case .focused: [focus.workspace]
case .visible: Workspace.all.filter(\.isVisible)
case .name(let name): [Workspace.get(byName: name.raw)]
}
}
let trees = workspaces.toSet().sorted { $0.name < $1.name }
.map { WorkspaceTreeJson(workspace: $0.name, root: .of($0.rootTilingContainer)) }

return switch args.json {
case true:
JSONEncoder.aeroSpaceDefault.encodeToString(trees).map { .succ(io.out($0)) }
?? .fail(io.err("Failed to encode JSON"))
case false:
.succ(io.out(trees.flatMap { $0.render() }))
}
}
}

private struct WorkspaceTreeJson: Encodable {
let workspace: String
let root: TreeNodeJson

func render() -> [String] {
["workspace \(workspace)"] + root.render(depth: 1)
}
}

/// The tiling tree only. Floating, minimized and fullscreen windows aren't part of it,
/// the same way they aren't part of `flatten-workspace-tree`
private struct TreeNodeJson: Encodable {
static let windowType = "window"
static let containerType = "tiling-container"

let type: String
let layout: String?
let windowId: UInt32?
let children: [TreeNodeJson]?

enum CodingKeys: String, CodingKey {
case type
case layout
case windowId = "window-id"
case children
}

@MainActor
static func of(_ node: TreeNode) -> TreeNodeJson {
switch node.tilingTreeNodeCasesOrDie() {
case .window(let window):
TreeNodeJson(type: windowType, layout: nil, windowId: window.windowId, children: nil)
case .tilingContainer(let container):
TreeNodeJson(
type: containerType,
layout: toLayoutString(tc: container),
windowId: nil,
children: container.children.map { of($0) },
)
}
}

func render(depth: Int) -> [String] {
let indent = String(repeating: " ", count: depth)
return switch (type, windowId, layout) {
case (Self.windowType, let windowId?, _): ["\(indent)window \(windowId)"]
case (_, _, let layout?): ["\(indent)\(layout)"] + (children ?? []).flatMap { $0.render(depth: depth + 1) }
default: []
}
}
}
116 changes: 116 additions & 0 deletions Sources/AppBundleTests/command/ListTreeTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
@testable import AppBundle
import Common
import XCTest

@MainActor
final class ListTreeTest: XCTestCase {
override func setUp() async throws { setUpWorkspacesForTests() }

func testParse() {
assertNotNil(parseCommand("list-tree --focused").cmdOrNil)
assertNotNil(parseCommand("list-tree --workspace a").cmdOrNil)
assertNotNil(parseCommand("list-tree --workspace a --json").cmdOrNil)
assertEquals(parseCommand("list-tree").errorOrNil, "Mandatory option is not specified (--focused|--workspace)")
assertEquals(
parseCommand("list-tree --focused --workspace a").errorOrNil,
"ERROR: Conflicting options: --focused, --workspace",
)
}

/// h_tiles(1, v_tiles(2, 3))
private func nestedWorkspace() -> Workspace {
Workspace.get(byName: "a").apply {
$0.rootTilingContainer.apply {
TestWindow.new(id: 1, parent: $0)
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
TestWindow.new(id: 2, parent: $0)
TestWindow.new(id: 3, parent: $0)
}
}
}
}

func testRunNested() async {
_ = nestedWorkspace()
let result = await parseCommand("list-tree --workspace a").cmdOrDie.run(.defaultEnv, .emptyStdin)
assertEquals(result.exitCode.rawValue, 0)
assertEquals(result.stderr, [])
assertEquals(result.stdout, [
"workspace a",
" h_tiles",
" window 1",
" v_tiles",
" window 2",
" window 3",
])
}

/// Floating windows are not part of the tiling tree
func testRunIgnoresFloatingWindows() async {
let workspace = Workspace.get(byName: "a")
TestWindow.new(id: 1, parent: workspace.rootTilingContainer)
TestWindow.new(id: 2, parent: workspace.floatingWindowsContainer)
let result = await parseCommand("list-tree --workspace a").cmdOrDie.run(.defaultEnv, .emptyStdin)
assertEquals(result.exitCode.rawValue, 0)
assertEquals(result.stdout, ["workspace a", " h_tiles", " window 1"])
}

func testRunEmptyWorkspace() async {
_ = Workspace.get(byName: "a")
let result = await parseCommand("list-tree --workspace a").cmdOrDie.run(.defaultEnv, .emptyStdin)
assertEquals(result.exitCode.rawValue, 0)
assertEquals(result.stdout, ["workspace a", " h_tiles"])
}

func testRunSeveralWorkspacesAreSorted() async {
TestWindow.new(id: 1, parent: Workspace.get(byName: "b").rootTilingContainer)
TestWindow.new(id: 2, parent: Workspace.get(byName: "a").rootTilingContainer)
let result = await parseCommand("list-tree --workspace b a").cmdOrDie.run(.defaultEnv, .emptyStdin)
assertEquals(result.exitCode.rawValue, 0)
assertEquals(result.stdout, [
"workspace a",
" h_tiles",
" window 2",
"workspace b",
" h_tiles",
" window 1",
])
}

func testRunJson() async {
_ = nestedWorkspace()
let result = await parseCommand("list-tree --workspace a --json").cmdOrDie.run(.defaultEnv, .emptyStdin)
assertEquals(result.exitCode.rawValue, 0)
assertEquals(result.stdout, ["""
[
{
"root" : {
"children" : [
{
"type" : "window",
"window-id" : 1
},
{
"children" : [
{
"type" : "window",
"window-id" : 2
},
{
"type" : "window",
"window-id" : 3
}
],
"layout" : "v_tiles",
"type" : "tiling-container"
}
],
"layout" : "h_tiles",
"type" : "tiling-container"
},
"workspace" : "a"
}
]
"""])
}
}
1 change: 1 addition & 0 deletions Sources/Cli/subcommandDescriptionsGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let subcommandDescriptions = [
[" list-exec-env-vars", "List environment variables that exec-* commands and callbacks are run with"],
[" list-modes", "Print a list of modes currently specified in the configuration"],
[" list-monitors", "Print monitors that satisfy conditions"],
[" list-tree", "Print the tiling tree of the specified workspaces"],
[" list-windows", "Print windows that satisfy conditions"],
[" list-workspaces", "Print workspaces that satisfy conditions"],
[" macos-native-fullscreen", "Toggle macOS fullscreen for the focused window"],
Expand Down
3 changes: 3 additions & 0 deletions Sources/Common/cmdArgs/cmdArgsManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum CmdKind: String, CaseIterable, Equatable, Sendable {
case listExecEnvVars = "list-exec-env-vars"
case listModes = "list-modes"
case listMonitors = "list-monitors"
case listTree = "list-tree"
case listWindows = "list-windows"
case listWorkspaces = "list-workspaces"
case macosNativeFullscreen = "macos-native-fullscreen"
Expand Down Expand Up @@ -98,6 +99,8 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] {
result[kind.rawValue] = SubCommandParser(parseListModesCmdArgs)
case .listMonitors:
result[kind.rawValue] = SubCommandParser(parseListMonitorsCmdArgs)
case .listTree:
result[kind.rawValue] = SubCommandParser(parseListTreeCmdArgs)
case .listWindows:
result[kind.rawValue] = SubCommandParser(parseListWindowsCmdArgs)
case .listWorkspaces:
Expand Down
30 changes: 30 additions & 0 deletions Sources/Common/cmdArgs/impl/ListTreeCmdArgs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
public struct ListTreeCmdArgs: CmdArgs {
/*conforms*/ public var commonState: CmdArgsCommonState
public static let parser: CmdParser<Self> = .init(
kind: .listTree,
help: list_tree_help_generated,
flags: [
// Filtering flags
"--focused": trueBoolFlag(\.focused),
"--workspace": ArgParser(\.workspaces, parseWorkspaces),

// Formatting flags
"--json": trueBoolFlag(\.json),
],
posArgs: [],
conflictingOptions: [
["--focused", "--workspace"],
],
)

public var focused: Bool = false
public var workspaces: [WorkspaceFilter] = []
public var json: Bool = false
}

func parseListTreeCmdArgs(_ args: StrArrSlice) -> ParsedCmd<ListTreeCmdArgs> {
parseSpecificCmdArgs(ListTreeCmdArgs(commonState: .init(args)), args)
.filter("Mandatory option is not specified (--focused|--workspace)") { raw in
raw.focused || !raw.workspaces.isEmpty
}
}
2 changes: 1 addition & 1 deletion Sources/Common/cmdArgs/impl/ListWindowsCmdArgs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func formatParser<Root>(
}
}

private func parseWorkspaces(input: SubArgParserInput) -> ParsedCliArgs<[WorkspaceFilter]> {
func parseWorkspaces(input: SubArgParserInput) -> ParsedCliArgs<[WorkspaceFilter]> {
let args = input.nonFlagArgs()
let possibleValues = "\(workspace) possible values: (<workspace-name>|focused|visible)"
if args.isEmpty {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Common/cmdHelpGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ let list_modes_help_generated = """
let list_monitors_help_generated = """
USAGE: list-monitors [-h|--help] [--focused [no]] [--mouse [no]] [--format <output-format>] [--count] [--json]
"""
let list_tree_help_generated = """
USAGE: list-tree [-h|--help] (--workspace <workspace>...|--focused) [--json]
"""
let list_windows_help_generated = """
USAGE: list-windows [-h|--help] (--workspace <workspace>...|--monitor <monitor>...)
[--monitor <monitor>...] [--workspace <workspace>...]
Expand Down
64 changes: 64 additions & 0 deletions docs/aerospace-list-tree.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
= aerospace-list-tree(1)
include::util/man-attributes.adoc[]
:manname: aerospace-list-tree
// tag::purpose[]
:manpurpose: Print the tiling tree of the specified workspaces
// end::purpose[]

// =========================================================== Synopsis
== Synopsis
[verse]
// tag::synopsis[]
aerospace list-tree [-h|--help] (--workspace <workspace>...|--focused) [--json]

// end::synopsis[]

// =========================================================== Description
== Description

// tag::body[]
{manpurpose}

`list-windows` reports which layout a window's immediate parent has, but not how the
containers nest. This command prints the structure itself, which is what scripts need in
order to reason about the tree instead of rebuilding it blindly.

Only the tiling tree is printed. Floating, minimized and fullscreen windows are not part
of it, the same way they are not affected by
xref:aerospace-flatten-workspace-tree.adoc[`flatten-workspace-tree`].

Workspaces are printed in name order, each rooted at its own workspace line.

// =========================================================== Options
include::util/conditional-options-header.adoc[]

-h, --help:: Print help

--focused::
Print the tree of the focused workspace.
Incompatible with `--workspace`

--workspace <workspace>...::
Print the tree of either of the specified workspaces.
Incompatible with `--focused`

--json:: Output in JSON format.

// =========================================================== Examples
== Examples

[source,bash]
----
$ aerospace list-tree --focused
workspace 1
h_tiles
window 8412
v_tiles
window 8420
window 8503
----

// end::body[]

// =========================================================== Footer
include::util/man-footer.adoc[]
7 changes: 7 additions & 0 deletions docs/commands.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ include::aerospace-list-monitors.adoc[tags=synopsis]
include::aerospace-list-monitors.adoc[tags=purpose]
include::aerospace-list-monitors.adoc[tags=body]

== list-tree
----
include::aerospace-list-tree.adoc[tags=synopsis]
----
include::aerospace-list-tree.adoc[tags=purpose]
include::aerospace-list-tree.adoc[tags=body]

== list-windows
----
include::aerospace-list-windows.adoc[tags=synopsis]
Expand Down
2 changes: 2 additions & 0 deletions grammar/commands-bnf-grammar.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ aerospace -h;

| list-monitors [--focused [no] | --mouse [no] | --format <output_format> | --count | --json]...

| list-tree [--json]... (--workspace <workspace>...|--focused) [--json]...

| list-windows [<list_windows_filter_flag> | --format <output_format> | --count | --json]...
| list-windows [--format <output_format>|--count|--json]... (--all|--focused) [--format <output_format>|--count|--json]...

Expand Down
Loading