ace-tmux 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ace-defaults/tmux/config.yml +11 -0
- data/.ace-defaults/tmux/panes/claude.yml +4 -0
- data/.ace-defaults/tmux/panes/nvim.yml +4 -0
- data/.ace-defaults/tmux/panes/work-on-task-cc.yml +4 -0
- data/.ace-defaults/tmux/panes/work-on-task-status.yml +4 -0
- data/.ace-defaults/tmux/sessions/default.yml +5 -0
- data/.ace-defaults/tmux/windows/cc-ipad.yml +10 -0
- data/.ace-defaults/tmux/windows/cc.yml +14 -0
- data/.ace-defaults/tmux/windows/work-on-task.yml +16 -0
- data/CHANGELOG.md +240 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +12 -0
- data/docs/demo/ace-tmux-getting-started.gif +0 -0
- data/docs/demo/ace-tmux-getting-started.tape.yml +30 -0
- data/docs/getting-started.md +86 -0
- data/docs/handbook.md +31 -0
- data/docs/usage.md +197 -0
- data/exe/ace-tmux +17 -0
- data/lib/ace/tmux/atoms/layout_string_builder.rb +172 -0
- data/lib/ace/tmux/atoms/preset_resolver.rb +127 -0
- data/lib/ace/tmux/atoms/tmux_command_builder.rb +203 -0
- data/lib/ace/tmux/cli/commands/list.rb +85 -0
- data/lib/ace/tmux/cli/commands/start.rb +82 -0
- data/lib/ace/tmux/cli/commands/window.rb +75 -0
- data/lib/ace/tmux/cli.rb +64 -0
- data/lib/ace/tmux/models/layout_node.rb +53 -0
- data/lib/ace/tmux/models/pane.rb +37 -0
- data/lib/ace/tmux/models/session.rb +64 -0
- data/lib/ace/tmux/models/window.rb +50 -0
- data/lib/ace/tmux/molecules/config_loader.rb +46 -0
- data/lib/ace/tmux/molecules/preset_loader.rb +73 -0
- data/lib/ace/tmux/molecules/session_builder.rb +151 -0
- data/lib/ace/tmux/molecules/tmux_executor.rb +74 -0
- data/lib/ace/tmux/organisms/session_manager.rb +295 -0
- data/lib/ace/tmux/organisms/window_manager.rb +221 -0
- data/lib/ace/tmux/version.rb +7 -0
- data/lib/ace/tmux.rb +101 -0
- metadata +225 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Models
|
|
6
|
+
# Represents a tmux window configuration
|
|
7
|
+
class Window
|
|
8
|
+
attr_reader :name, :layout, :root, :panes, :pre_window, :focus, :options, :layout_tree
|
|
9
|
+
|
|
10
|
+
# @param name [String, nil] Window name
|
|
11
|
+
# @param layout [String, nil] tmux layout (e.g., "main-vertical", "tiled")
|
|
12
|
+
# @param root [String, nil] Working directory for the window
|
|
13
|
+
# @param panes [Array<Pane>] Pane configurations
|
|
14
|
+
# @param pre_window [String, nil] Command to run before each pane
|
|
15
|
+
# @param focus [Boolean] Whether this window should be focused
|
|
16
|
+
# @param options [Hash] Raw tmux window options (passed to set-window-option)
|
|
17
|
+
# @param layout_tree [Models::LayoutNode, nil] Nested layout tree (nil for flat layouts)
|
|
18
|
+
def initialize(name: nil, layout: nil, root: nil, panes: [], pre_window: nil, focus: false, options: {}, layout_tree: nil)
|
|
19
|
+
@name = name
|
|
20
|
+
@layout = layout
|
|
21
|
+
@root = root
|
|
22
|
+
@panes = panes
|
|
23
|
+
@pre_window = pre_window
|
|
24
|
+
@focus = focus
|
|
25
|
+
@options = options || {}
|
|
26
|
+
@layout_tree = layout_tree
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] true if this window uses a nested layout tree
|
|
30
|
+
def nested_layout?
|
|
31
|
+
!@layout_tree.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def focus?
|
|
35
|
+
@focus == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
hash = {"name" => @name, "panes" => @panes.map(&:to_h)}
|
|
40
|
+
hash["layout"] = @layout if @layout
|
|
41
|
+
hash["root"] = @root if @root
|
|
42
|
+
hash["pre_window"] = @pre_window if @pre_window
|
|
43
|
+
hash["focus"] = @focus if @focus
|
|
44
|
+
hash["options"] = @options unless @options.empty?
|
|
45
|
+
hash
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/config"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Tmux
|
|
7
|
+
module Molecules
|
|
8
|
+
# Loads general tmux configuration via the ACE config cascade
|
|
9
|
+
module ConfigLoader
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Load tmux config from cascade
|
|
13
|
+
#
|
|
14
|
+
# @param gem_root [String] Gem root directory
|
|
15
|
+
# @return [Hash] Merged configuration hash
|
|
16
|
+
def load(gem_root:)
|
|
17
|
+
resolver = Ace::Support::Config.create(
|
|
18
|
+
config_dir: ".ace",
|
|
19
|
+
defaults_dir: ".ace-defaults",
|
|
20
|
+
gem_path: gem_root
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
config = resolver.resolve_namespace("tmux")
|
|
24
|
+
config.data
|
|
25
|
+
rescue => e
|
|
26
|
+
warn "ace-tmux: Could not load config: #{e.class} - #{e.message}" if Tmux.debug?
|
|
27
|
+
load_fallback(gem_root)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Load gem defaults directly as fallback
|
|
31
|
+
#
|
|
32
|
+
# @param gem_root [String] Gem root directory
|
|
33
|
+
# @return [Hash] Defaults hash or empty hash
|
|
34
|
+
def load_fallback(gem_root)
|
|
35
|
+
defaults_path = File.join(gem_root, ".ace-defaults", "tmux", "config.yml")
|
|
36
|
+
return {} unless File.exist?(defaults_path)
|
|
37
|
+
|
|
38
|
+
require "yaml"
|
|
39
|
+
YAML.safe_load_file(defaults_path, permitted_classes: [Date], aliases: true) || {}
|
|
40
|
+
rescue
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "ace/support/config"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Tmux
|
|
8
|
+
module Molecules
|
|
9
|
+
# Finds and loads YAML presets across the ACE config cascade
|
|
10
|
+
#
|
|
11
|
+
# Uses VirtualConfigResolver to discover presets from:
|
|
12
|
+
# 1. Project .ace/tmux/ (highest priority)
|
|
13
|
+
# 2. User ~/.ace/tmux/
|
|
14
|
+
# 3. Gem .ace-defaults/tmux/ (lowest priority)
|
|
15
|
+
class PresetLoader
|
|
16
|
+
PRESET_TYPES = %w[sessions windows panes].freeze
|
|
17
|
+
|
|
18
|
+
# @param gem_root [String] Gem root directory for defaults
|
|
19
|
+
# @param start_path [String, nil] Starting path for cascade traversal
|
|
20
|
+
def initialize(gem_root:, start_path: nil)
|
|
21
|
+
@resolver = Ace::Support::Config.virtual_resolver(
|
|
22
|
+
config_dir: ".ace",
|
|
23
|
+
defaults_dir: ".ace-defaults",
|
|
24
|
+
start_path: start_path,
|
|
25
|
+
gem_path: gem_root
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Load a preset by type and name
|
|
30
|
+
#
|
|
31
|
+
# @param type [String] Preset type: "sessions", "windows", or "panes"
|
|
32
|
+
# @param name [String] Preset name (without .yml extension)
|
|
33
|
+
# @return [Hash, nil] Parsed YAML hash, or nil if not found
|
|
34
|
+
def load(type, name)
|
|
35
|
+
relative_path = "tmux/#{type}/#{name}.yml"
|
|
36
|
+
absolute_path = @resolver.resolve_path(relative_path)
|
|
37
|
+
return nil unless absolute_path && File.exist?(absolute_path)
|
|
38
|
+
|
|
39
|
+
YAML.safe_load_file(absolute_path, permitted_classes: [Date], aliases: true) || {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# List available presets for a type
|
|
43
|
+
#
|
|
44
|
+
# @param type [String] Preset type: "sessions", "windows", or "panes"
|
|
45
|
+
# @return [Array<String>] Preset names (without .yml extension)
|
|
46
|
+
def list(type)
|
|
47
|
+
pattern = "tmux/#{type}/*.yml"
|
|
48
|
+
@resolver.glob(pattern).keys.map do |relative_path|
|
|
49
|
+
File.basename(relative_path, ".yml")
|
|
50
|
+
end.sort.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# List all preset types and their presets
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash<String, Array<String>>] Map of type => preset names
|
|
56
|
+
def list_all
|
|
57
|
+
PRESET_TYPES.each_with_object({}) do |type, result|
|
|
58
|
+
presets = list(type)
|
|
59
|
+
result[type] = presets unless presets.empty?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Create a lookup proc for PresetResolver
|
|
64
|
+
#
|
|
65
|
+
# @param type [String] Preset type to look up
|
|
66
|
+
# @return [Proc] Proc that takes a name and returns a preset hash
|
|
67
|
+
def to_lookup(type)
|
|
68
|
+
->(name) { load(type, name) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Molecules
|
|
6
|
+
# Combines PresetLoader + PresetResolver to build fully resolved models
|
|
7
|
+
#
|
|
8
|
+
# Loads a session preset, resolves all nested window/pane presets,
|
|
9
|
+
# and constructs the model hierarchy (Session → Window → Pane).
|
|
10
|
+
class SessionBuilder
|
|
11
|
+
# @param preset_loader [PresetLoader] Loader for finding presets
|
|
12
|
+
def initialize(preset_loader:)
|
|
13
|
+
@preset_loader = preset_loader
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Build a fully resolved Session model from a preset name
|
|
17
|
+
#
|
|
18
|
+
# @param preset_name [String] Session preset name
|
|
19
|
+
# @return [Models::Session] Fully resolved session model
|
|
20
|
+
# @raise [PresetNotFoundError] If session preset doesn't exist
|
|
21
|
+
def build(preset_name)
|
|
22
|
+
raw = @preset_loader.load("sessions", preset_name)
|
|
23
|
+
raise PresetNotFoundError, "Session preset not found: #{preset_name}" unless raw
|
|
24
|
+
|
|
25
|
+
# Resolve all preset references
|
|
26
|
+
resolved = Atoms::PresetResolver.resolve_session(
|
|
27
|
+
raw,
|
|
28
|
+
window_lookup: @preset_loader.to_lookup("windows"),
|
|
29
|
+
pane_lookup: @preset_loader.to_lookup("panes")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
build_session_model(resolved)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build a fully resolved Window model from a preset name
|
|
36
|
+
#
|
|
37
|
+
# @param preset_name [String] Window preset name
|
|
38
|
+
# @return [Models::Window] Fully resolved window model
|
|
39
|
+
# @raise [PresetNotFoundError] If window preset doesn't exist
|
|
40
|
+
def build_window(preset_name)
|
|
41
|
+
raw = @preset_loader.load("windows", preset_name)
|
|
42
|
+
raise PresetNotFoundError, "Window preset not found: #{preset_name}" unless raw
|
|
43
|
+
|
|
44
|
+
resolved = Atoms::PresetResolver.resolve_window(
|
|
45
|
+
raw,
|
|
46
|
+
pane_lookup: @preset_loader.to_lookup("panes")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
build_window_model(resolved)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_session_model(hash)
|
|
55
|
+
windows = (hash["windows"] || []).map { |w| build_window_model(w) }
|
|
56
|
+
|
|
57
|
+
Models::Session.new(
|
|
58
|
+
name: hash["name"],
|
|
59
|
+
root: hash["root"],
|
|
60
|
+
windows: windows,
|
|
61
|
+
pre_window: hash["pre_window"],
|
|
62
|
+
startup_window: hash["startup_window"],
|
|
63
|
+
on_project_start: hash["on_project_start"],
|
|
64
|
+
on_project_exit: hash["on_project_exit"],
|
|
65
|
+
attach: hash.fetch("attach", true),
|
|
66
|
+
tmux_options: hash["tmux_options"]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_window_model(hash)
|
|
71
|
+
if nested_layout?(hash)
|
|
72
|
+
build_nested_window_model(hash)
|
|
73
|
+
else
|
|
74
|
+
build_flat_window_model(hash)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_flat_window_model(hash)
|
|
79
|
+
panes = (hash["panes"] || []).map { |p| build_pane_model(p) }
|
|
80
|
+
|
|
81
|
+
Models::Window.new(
|
|
82
|
+
name: hash["name"],
|
|
83
|
+
layout: hash["layout"],
|
|
84
|
+
root: hash["root"],
|
|
85
|
+
panes: panes,
|
|
86
|
+
pre_window: hash["pre_window"],
|
|
87
|
+
focus: hash["focus"] || false,
|
|
88
|
+
options: hash["options"] || {}
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_nested_window_model(hash)
|
|
93
|
+
direction = parse_direction(hash["direction"] || "horizontal")
|
|
94
|
+
tree = build_layout_tree(hash, direction: direction)
|
|
95
|
+
panes = tree.leaves.map(&:pane)
|
|
96
|
+
|
|
97
|
+
Models::Window.new(
|
|
98
|
+
name: hash["name"],
|
|
99
|
+
root: hash["root"],
|
|
100
|
+
panes: panes,
|
|
101
|
+
pre_window: hash["pre_window"],
|
|
102
|
+
focus: hash["focus"] || false,
|
|
103
|
+
options: hash["options"] || {},
|
|
104
|
+
layout_tree: tree
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_layout_tree(hash, direction:)
|
|
109
|
+
children = (hash["panes"] || []).map do |entry|
|
|
110
|
+
if entry.is_a?(Hash) && entry.key?("direction")
|
|
111
|
+
child_dir = parse_direction(entry["direction"])
|
|
112
|
+
child_tree = build_layout_tree(entry, direction: child_dir)
|
|
113
|
+
child_tree
|
|
114
|
+
else
|
|
115
|
+
pane = build_pane_model(entry)
|
|
116
|
+
Models::LayoutNode.new(pane: pane, size: entry.is_a?(Hash) ? entry["size"] : nil)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
size = hash.is_a?(Hash) ? hash["size"] : nil
|
|
121
|
+
Models::LayoutNode.new(direction: direction, children: children, size: size)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def nested_layout?(hash)
|
|
125
|
+
return true if hash.is_a?(Hash) && hash.key?("direction")
|
|
126
|
+
|
|
127
|
+
panes = hash["panes"]
|
|
128
|
+
return false unless panes.is_a?(Array)
|
|
129
|
+
|
|
130
|
+
panes.any? { |p| p.is_a?(Hash) && p.key?("direction") }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_direction(str)
|
|
134
|
+
(str.to_s == "vertical") ? :vertical : :horizontal
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_pane_model(hash)
|
|
138
|
+
hash = Atoms::PresetResolver.normalize_pane(hash)
|
|
139
|
+
|
|
140
|
+
Models::Pane.new(
|
|
141
|
+
commands: hash["commands"] || [],
|
|
142
|
+
focus: hash["focus"] || false,
|
|
143
|
+
root: hash["root"],
|
|
144
|
+
name: hash["name"],
|
|
145
|
+
options: hash["options"] || {}
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Tmux
|
|
7
|
+
module Molecules
|
|
8
|
+
# Executes tmux commands via different execution strategies
|
|
9
|
+
#
|
|
10
|
+
# Provides three modes:
|
|
11
|
+
# - capture: Run and capture stdout/stderr (for queries)
|
|
12
|
+
# - run: Run via system() (for mutations)
|
|
13
|
+
# - exec: Replace process via Kernel.exec (for attach)
|
|
14
|
+
class TmuxExecutor
|
|
15
|
+
# Run a command and capture output
|
|
16
|
+
#
|
|
17
|
+
# @param cmd [Array<String>] Command array
|
|
18
|
+
# @return [ExecutionResult] Result with stdout, stderr, success?
|
|
19
|
+
def capture(cmd)
|
|
20
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
21
|
+
ExecutionResult.new(
|
|
22
|
+
stdout: stdout.strip,
|
|
23
|
+
stderr: stderr.strip,
|
|
24
|
+
success: status.success?,
|
|
25
|
+
exit_code: status.exitstatus
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Run a command via system (fire and forget with status)
|
|
30
|
+
#
|
|
31
|
+
# @param cmd [Array<String>] Command array
|
|
32
|
+
# @return [Boolean] true if command succeeded
|
|
33
|
+
def run(cmd)
|
|
34
|
+
system(*cmd)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Replace current process with command (for tmux attach)
|
|
38
|
+
#
|
|
39
|
+
# @param cmd [Array<String>] Command array
|
|
40
|
+
# @return [void] Never returns (replaces process)
|
|
41
|
+
def exec(cmd)
|
|
42
|
+
Kernel.exec(*cmd)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if tmux is available
|
|
46
|
+
#
|
|
47
|
+
# @param tmux [String] tmux binary path
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def tmux_available?(tmux: "tmux")
|
|
50
|
+
result = capture([tmux, "-V"])
|
|
51
|
+
result.success?
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Immutable result of a tmux command execution
|
|
58
|
+
class ExecutionResult
|
|
59
|
+
attr_reader :stdout, :stderr, :exit_code
|
|
60
|
+
|
|
61
|
+
def initialize(stdout:, stderr:, success:, exit_code:)
|
|
62
|
+
@stdout = stdout
|
|
63
|
+
@stderr = stderr
|
|
64
|
+
@success = success
|
|
65
|
+
@exit_code = exit_code
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def success?
|
|
69
|
+
@success
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Organisms
|
|
6
|
+
# Orchestrates session creation from a preset
|
|
7
|
+
#
|
|
8
|
+
# Flow:
|
|
9
|
+
# 1. Check if session already exists
|
|
10
|
+
# 2. Run on_project_start hooks
|
|
11
|
+
# 3. Create session (first window implicitly created)
|
|
12
|
+
# 4. Set up additional windows and panes
|
|
13
|
+
# 5. Set layouts and focus
|
|
14
|
+
# 6. Select startup window
|
|
15
|
+
# 7. Attach (unless --detach)
|
|
16
|
+
class SessionManager
|
|
17
|
+
POLLUTING_ENV_VARS = %w[BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT RUBYLIB].freeze
|
|
18
|
+
|
|
19
|
+
# @param executor [Molecules::TmuxExecutor] Command executor
|
|
20
|
+
# @param session_builder [Molecules::SessionBuilder] Preset resolver/builder
|
|
21
|
+
# @param tmux [String] tmux binary path
|
|
22
|
+
def initialize(executor:, session_builder:, tmux: "tmux")
|
|
23
|
+
@executor = executor
|
|
24
|
+
@session_builder = session_builder
|
|
25
|
+
@tmux = tmux
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Start a session from a preset
|
|
29
|
+
#
|
|
30
|
+
# @param preset_name [String] Session preset name
|
|
31
|
+
# @param detach [Boolean] Skip attach after creation
|
|
32
|
+
# @param force [Boolean] Kill existing session and recreate
|
|
33
|
+
# @param root [String, nil] Override working directory for the session
|
|
34
|
+
# @return [void]
|
|
35
|
+
def start(preset_name, detach: false, force: false, root: nil)
|
|
36
|
+
session = @session_builder.build(preset_name)
|
|
37
|
+
session.root = root if root
|
|
38
|
+
|
|
39
|
+
if session_exists?(session.name)
|
|
40
|
+
if force
|
|
41
|
+
kill_session(session.name)
|
|
42
|
+
else
|
|
43
|
+
attach_session(session) unless detach
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
run_hooks(session.on_project_start)
|
|
49
|
+
first_window_target = create_session(session, root_override: root)
|
|
50
|
+
clean_environment(session)
|
|
51
|
+
setup_windows(session)
|
|
52
|
+
select_startup_window(session, first_window_target: first_window_target)
|
|
53
|
+
attach_session(session) unless detach
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Derive the first window name from the effective working directory.
|
|
59
|
+
# Mirrors WindowManager#resolve_window_name: root basename wins over preset name.
|
|
60
|
+
def resolve_first_window_name(preset_window_name, root_override, session_root)
|
|
61
|
+
effective_root = root_override || session_root || Dir.pwd
|
|
62
|
+
File.basename(effective_root)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def session_exists?(name)
|
|
66
|
+
cmd = Atoms::TmuxCommandBuilder.has_session(name, tmux: @tmux)
|
|
67
|
+
result = @executor.capture(cmd)
|
|
68
|
+
result.success?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def kill_session(name)
|
|
72
|
+
cmd = Atoms::TmuxCommandBuilder.kill_session(name, tmux: @tmux)
|
|
73
|
+
@executor.run(cmd)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_session(session, root_override: nil)
|
|
77
|
+
first_window = session.windows.first
|
|
78
|
+
first_window_name = resolve_first_window_name(first_window&.name, root_override, session.root)
|
|
79
|
+
cmd = Atoms::TmuxCommandBuilder.new_session(
|
|
80
|
+
session.name,
|
|
81
|
+
root: session.root,
|
|
82
|
+
window_name: first_window_name,
|
|
83
|
+
tmux_options: session.tmux_options,
|
|
84
|
+
print_format: '#{window_id}',
|
|
85
|
+
tmux: @tmux
|
|
86
|
+
)
|
|
87
|
+
result = @executor.capture(cmd)
|
|
88
|
+
window_target = result.stdout.strip
|
|
89
|
+
|
|
90
|
+
# Set up panes for the first window (it was created with the session)
|
|
91
|
+
setup_panes(session, first_window, window_target) if first_window
|
|
92
|
+
window_target
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def setup_windows(session)
|
|
96
|
+
# Skip first window (already created with session)
|
|
97
|
+
session.windows.drop(1).each do |window|
|
|
98
|
+
window_root = window.root || session.root
|
|
99
|
+
cmd = Atoms::TmuxCommandBuilder.new_window(
|
|
100
|
+
session.name,
|
|
101
|
+
name: window.name,
|
|
102
|
+
root: window_root,
|
|
103
|
+
print_format: '#{window_id}',
|
|
104
|
+
tmux: @tmux
|
|
105
|
+
)
|
|
106
|
+
result = @executor.capture(cmd)
|
|
107
|
+
window_target = result.stdout.strip
|
|
108
|
+
setup_panes(session, window, window_target)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def setup_panes(session, window, window_target)
|
|
113
|
+
return unless window.panes.any?
|
|
114
|
+
|
|
115
|
+
if window.nested_layout?
|
|
116
|
+
setup_nested_panes(session, window, window_target)
|
|
117
|
+
else
|
|
118
|
+
setup_flat_panes(session, window, window_target)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def setup_flat_panes(session, window, window_target)
|
|
123
|
+
# Create additional panes via split (first pane already exists)
|
|
124
|
+
window.panes.drop(1).each do |pane|
|
|
125
|
+
pane_root = pane.root || window.root || session.root
|
|
126
|
+
cmd = Atoms::TmuxCommandBuilder.split_window(
|
|
127
|
+
window_target,
|
|
128
|
+
root: pane_root,
|
|
129
|
+
tmux: @tmux
|
|
130
|
+
)
|
|
131
|
+
@executor.run(cmd)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Apply window options before layout (e.g., main-pane-width)
|
|
135
|
+
apply_window_options(window, window_target)
|
|
136
|
+
|
|
137
|
+
# Apply layout after all panes are created
|
|
138
|
+
if window.layout
|
|
139
|
+
cmd = Atoms::TmuxCommandBuilder.select_layout(window_target, window.layout, tmux: @tmux)
|
|
140
|
+
@executor.run(cmd)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Send commands to all panes (after layout is stable)
|
|
144
|
+
window.panes.each_with_index do |pane, idx|
|
|
145
|
+
send_pane_commands(session, window, pane, "#{window_target}.#{idx}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Focus the appropriate pane
|
|
149
|
+
focus_pane = window.panes.index(&:focus?)
|
|
150
|
+
if focus_pane
|
|
151
|
+
cmd = Atoms::TmuxCommandBuilder.select_pane("#{window_target}.#{focus_pane}", tmux: @tmux)
|
|
152
|
+
@executor.run(cmd)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def setup_nested_panes(session, window, window_target)
|
|
157
|
+
tree = window.layout_tree
|
|
158
|
+
leaves = tree.leaves
|
|
159
|
+
leaves.length
|
|
160
|
+
|
|
161
|
+
# Create leaf_count - 1 additional panes via flat splits
|
|
162
|
+
# Use per-leaf root when available, falling back to window/session root
|
|
163
|
+
leaves.drop(1).each do |leaf|
|
|
164
|
+
pane_root = leaf.pane.root || window.root || session.root
|
|
165
|
+
cmd = Atoms::TmuxCommandBuilder.split_window(
|
|
166
|
+
window_target,
|
|
167
|
+
root: pane_root,
|
|
168
|
+
tmux: @tmux
|
|
169
|
+
)
|
|
170
|
+
@executor.run(cmd)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get pane IDs
|
|
174
|
+
pane_ids = query_pane_ids(window_target)
|
|
175
|
+
|
|
176
|
+
# Get window dimensions
|
|
177
|
+
width, height = query_window_dimensions(window_target, pane_ids.first || 0)
|
|
178
|
+
|
|
179
|
+
# Build and apply custom layout string
|
|
180
|
+
layout_string = Atoms::LayoutStringBuilder.build(
|
|
181
|
+
tree, width: width, height: height, pane_ids: pane_ids
|
|
182
|
+
)
|
|
183
|
+
cmd = Atoms::TmuxCommandBuilder.select_layout(window_target, layout_string, tmux: @tmux)
|
|
184
|
+
@executor.run(cmd)
|
|
185
|
+
|
|
186
|
+
# Apply window options
|
|
187
|
+
apply_window_options(window, window_target)
|
|
188
|
+
|
|
189
|
+
# Send commands to each leaf pane
|
|
190
|
+
# First leaf was created with window/session root; cd if it has its own root
|
|
191
|
+
first_leaf = leaves.first
|
|
192
|
+
leaves.each do |leaf|
|
|
193
|
+
pane_target = "#{window_target}.#{leaf.pane_id}"
|
|
194
|
+
if leaf == first_leaf && leaf.pane.root
|
|
195
|
+
cd_cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, "cd #{leaf.pane.root}", tmux: @tmux)
|
|
196
|
+
@executor.run(cd_cmd)
|
|
197
|
+
end
|
|
198
|
+
send_pane_commands(session, window, leaf.pane, pane_target)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Focus the appropriate leaf pane
|
|
202
|
+
focus_leaf = leaves.find { |l| l.pane.focus? }
|
|
203
|
+
if focus_leaf
|
|
204
|
+
cmd = Atoms::TmuxCommandBuilder.select_pane("#{window_target}.#{focus_leaf.pane_id}", tmux: @tmux)
|
|
205
|
+
@executor.run(cmd)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def query_pane_ids(window_target)
|
|
210
|
+
cmd = Atoms::TmuxCommandBuilder.list_panes(window_target, format: '#{pane_index}', tmux: @tmux)
|
|
211
|
+
result = @executor.capture(cmd)
|
|
212
|
+
return [] unless result.success?
|
|
213
|
+
|
|
214
|
+
result.stdout.split("\n").map(&:to_i)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def query_window_dimensions(window_target, pane_id)
|
|
218
|
+
cmd = Atoms::TmuxCommandBuilder.display_message_target(
|
|
219
|
+
"#{window_target}.#{pane_id}",
|
|
220
|
+
'#{window_width}x#{window_height}',
|
|
221
|
+
tmux: @tmux
|
|
222
|
+
)
|
|
223
|
+
result = @executor.capture(cmd)
|
|
224
|
+
return [200, 50] unless result.success?
|
|
225
|
+
|
|
226
|
+
parts = result.stdout.strip.split("x")
|
|
227
|
+
[parts[0].to_i, parts[1].to_i]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def send_pane_commands(session, window, pane, pane_target)
|
|
231
|
+
# Send pre_window command if set (session-level, then window-level)
|
|
232
|
+
pre_window = window.pre_window || session.pre_window
|
|
233
|
+
if pre_window
|
|
234
|
+
cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, pre_window, tmux: @tmux)
|
|
235
|
+
@executor.run(cmd)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Apply pane options
|
|
239
|
+
apply_pane_options(pane, pane_target)
|
|
240
|
+
|
|
241
|
+
# Send pane commands
|
|
242
|
+
pane.commands.each do |command|
|
|
243
|
+
cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, command, tmux: @tmux)
|
|
244
|
+
@executor.run(cmd)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def apply_window_options(window, target)
|
|
249
|
+
window.options.each do |option, value|
|
|
250
|
+
cmd = Atoms::TmuxCommandBuilder.set_window_option(target, option, value, tmux: @tmux)
|
|
251
|
+
@executor.run(cmd)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def apply_pane_options(pane, target)
|
|
256
|
+
pane.options.each do |option, value|
|
|
257
|
+
cmd = Atoms::TmuxCommandBuilder.set_pane_option(target, option, value, tmux: @tmux)
|
|
258
|
+
@executor.run(cmd)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def select_startup_window(session, first_window_target: nil)
|
|
263
|
+
target = if session.startup_window
|
|
264
|
+
"#{session.name}:#{session.startup_window}"
|
|
265
|
+
else
|
|
266
|
+
first_window_target || "#{session.name}:#{session.windows.first&.name || 0}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
cmd = Atoms::TmuxCommandBuilder.select_window(target, tmux: @tmux)
|
|
270
|
+
@executor.run(cmd)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def attach_session(session)
|
|
274
|
+
cmd = Atoms::TmuxCommandBuilder.attach_session(session.name, tmux: @tmux)
|
|
275
|
+
@executor.exec(cmd)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def clean_environment(session)
|
|
279
|
+
POLLUTING_ENV_VARS.each do |var|
|
|
280
|
+
cmd = Atoms::TmuxCommandBuilder.set_environment(session.name, var, unset: true, tmux: @tmux)
|
|
281
|
+
@executor.run(cmd)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def run_hooks(commands)
|
|
286
|
+
return if commands.nil? || commands.empty?
|
|
287
|
+
|
|
288
|
+
commands.each do |command|
|
|
289
|
+
system(command)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|