mxup 0.2.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+
6
+ module Mxup
7
+ # Generates and manages per-window launcher scripts.
8
+ #
9
+ # Each declared window with non-trivial content (setup snippet, wait_for,
10
+ # env vars, or a command) gets a launcher script at
11
+ # ~/.local/share/mxup/<session>/<window>_launcher.sh
12
+ #
13
+ # The keys sent to tmux are simply ". <path>", so we can rewrite the script
14
+ # on every `mxup up` without having to re-send anything to healthy panes.
15
+ class Launcher
16
+ def initialize(config, runtime_root: RUNTIME_DIR)
17
+ @config = config
18
+ @runtime_root = runtime_root
19
+ end
20
+
21
+ def session_runtime_dir
22
+ File.join(@runtime_root, @config.session)
23
+ end
24
+
25
+ def script_path(window_name)
26
+ File.join(session_runtime_dir, "#{window_name}_launcher.sh")
27
+ end
28
+
29
+ # Returns the shell keys to send to a fresh pane for this window. Empty
30
+ # string for a window that has nothing to execute (bare interactive shell).
31
+ def command_for(window)
32
+ content?(window) ? ". #{script_path(window.name)}" : ''
33
+ end
34
+
35
+ # Materialise launcher scripts for every window that needs one. Safe to
36
+ # call repeatedly; overwrites existing files.
37
+ def write_all
38
+ FileUtils.mkdir_p(session_runtime_dir)
39
+ @config.windows.each do |win|
40
+ next unless content?(win)
41
+ path = script_path(win.name)
42
+ File.write(path, build_script(win))
43
+ File.chmod(0o755, path)
44
+ end
45
+ end
46
+
47
+ def cleanup
48
+ dir = session_runtime_dir
49
+ FileUtils.rm_rf(dir) if Dir.exist?(dir)
50
+ end
51
+
52
+ # Build (but don't write) the shell text for a window's launcher script.
53
+ # Exposed for inspection/tests; production callers use #write_all.
54
+ def build_script(window)
55
+ sections = []
56
+ sections << "# mxup launcher for #{window.name}"
57
+ sections << "cd #{Shellwords.escape(window.root)}"
58
+
59
+ if @config.setup && !@config.setup.empty?
60
+ sections << ''
61
+ @config.setup.split("\n").each { |l| sections << l }
62
+ end
63
+
64
+ if window.wait_for
65
+ sections << ''
66
+ sections.concat(build_wait_block(window.wait_for))
67
+ end
68
+
69
+ if window.env.any?
70
+ sections << ''
71
+ window.env.each { |k, v| sections << "export #{k}=#{Shellwords.escape(v)}" }
72
+ end
73
+
74
+ if window.command && !window.command.empty?
75
+ sections << ''
76
+ sections << window.command
77
+ end
78
+
79
+ sections.join("\n") + "\n"
80
+ end
81
+
82
+ # Shell snippet (array of lines) implementing a wait_for check.
83
+ def build_wait_block(spec)
84
+ check = probe_for(spec)
85
+ lines = []
86
+ lines << "echo 'Waiting for #{spec.label}...'"
87
+ lines << "_mxup_wait_start=$(date +%s)"
88
+ lines << ''
89
+
90
+ if spec.timeout
91
+ lines << "until #{check}; do"
92
+ lines << " _mxup_elapsed=$(($(date +%s) - _mxup_wait_start))"
93
+ lines << " if [ \"$_mxup_elapsed\" -ge #{spec.timeout.to_i} ]; then"
94
+ lines << " echo 'Timed out waiting for #{spec.label} after #{spec.timeout.to_i}s' >&2"
95
+ lines << ' break'
96
+ lines << ' fi'
97
+ lines << " echo \" still waiting... (${_mxup_elapsed}s of #{spec.timeout.to_i}s)\""
98
+ lines << " sleep #{spec.interval}"
99
+ lines << 'done'
100
+ else
101
+ lines << "until #{check}; do"
102
+ lines << " echo \" still waiting... ($(($(date +%s) - _mxup_wait_start))s)\""
103
+ lines << " sleep #{spec.interval}"
104
+ lines << 'done'
105
+ end
106
+
107
+ lines << ''
108
+ lines << "echo '#{spec.label} is ready.'"
109
+ lines
110
+ end
111
+
112
+ private
113
+
114
+ def content?(window)
115
+ (@config.setup && !@config.setup.empty?) ||
116
+ window.wait_for ||
117
+ window.env.any? ||
118
+ (window.command && !window.command.empty?)
119
+ end
120
+
121
+ def probe_for(spec)
122
+ case spec.type
123
+ when :tcp
124
+ host, port = spec.target.split(':', 2)
125
+ "nc -z #{host} #{port} 2>/dev/null"
126
+ when :http
127
+ "curl -sf #{Shellwords.escape(spec.target)} >/dev/null 2>&1"
128
+ when :path
129
+ "[ -e #{Shellwords.escape(spec.target)} ]"
130
+ when :script
131
+ spec.target
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Applies / switches tmux layouts without killing processes.
5
+ #
6
+ # switch_layout is a three-step operation:
7
+ # 1. Flatten any multi-pane windows back to standalone windows (break-pane)
8
+ # 2. Re-group windows per the target layout (join-pane + select-layout)
9
+ # 3. Reorder the windows to match config order
10
+ class LayoutManager
11
+ def initialize(config, session: config.session, dry_run: false,
12
+ resolver: PaneResolver.new(config, session: session),
13
+ out: nil)
14
+ @config = config
15
+ @session = session
16
+ @dry_run = dry_run
17
+ @resolver = resolver
18
+ @out_override = out
19
+ end
20
+
21
+ def out
22
+ @out_override || $stdout
23
+ end
24
+
25
+ def show(active: @resolver.stored_layout)
26
+ if @config.layout_names.empty?
27
+ out.puts 'No layouts defined in config.'
28
+ return
29
+ end
30
+
31
+ out.puts 'Available layouts:'
32
+ @config.layout_names.each do |name|
33
+ marker = name == active ? ' (active)' : ''
34
+ groups = @config.groups_for(name)
35
+ if groups.empty?
36
+ out.puts " #{name}#{marker}: flat (all standalone)"
37
+ else
38
+ desc = groups.map { |g| "#{g.name}=[#{g.window_names.join(',')}]" }.join(', ')
39
+ out.puts " #{name}#{marker}: #{desc}"
40
+ end
41
+ end
42
+ end
43
+
44
+ def switch(target_layout)
45
+ unless Tmux.has_session?(@session)
46
+ abort "Session #{@session} is not running."
47
+ end
48
+ unless @config.layout_names.include?(target_layout)
49
+ abort "Layout '#{target_layout}' not found in config. " \
50
+ "Available: #{@config.layout_names.join(', ')}"
51
+ end
52
+
53
+ current = @resolver.stored_layout
54
+ if current == target_layout
55
+ out.puts "Already using layout '#{target_layout}'."
56
+ return
57
+ end
58
+
59
+ if @dry_run
60
+ out.puts "[dry-run] Would switch layout from '#{current}' to '#{target_layout}'"
61
+ return
62
+ end
63
+
64
+ out.puts "Switching layout: #{current || 'none'} → #{target_layout}..."
65
+ flatten_to_standalone
66
+ apply(target_layout)
67
+ reorder(target_layout)
68
+ Tmux.set_environment(@session, 'MXUP_LAYOUT', target_layout)
69
+ out.puts "Layout switched to '#{target_layout}'."
70
+ end
71
+
72
+ # Collapse every multi-pane window into individual standalone windows.
73
+ # Run before applying a new layout to start from a clean state.
74
+ def flatten_to_standalone
75
+ multi = Tmux.list_panes(@session)
76
+ .group_by { |p| p[:name] }
77
+ .select { |_, ps| ps.size > 1 }
78
+
79
+ multi.each do |win_name, panes|
80
+ sorted = panes.sort_by { |p| p[:pane_index] }.reverse
81
+
82
+ sorted.each do |pane|
83
+ next if pane[:pane_index].zero?
84
+ logical = pane[:title].to_s.empty? \
85
+ ? "#{win_name}_#{pane[:pane_index]}" \
86
+ : pane[:title]
87
+ Tmux.break_pane(@session, win_name, pane[:pane_index], logical)
88
+ end
89
+
90
+ first = sorted.last
91
+ first_logical = first[:title].to_s.empty? ? win_name : first[:title]
92
+ Tmux.rename_window(@session, win_name, first_logical) if first_logical != win_name
93
+ end
94
+ end
95
+
96
+ # Apply a named layout to a session whose windows are currently all standalone.
97
+ def apply(layout_name)
98
+ groups = @config.groups_for(layout_name)
99
+
100
+ groups.each do |group|
101
+ next if group.window_names.size < 2
102
+
103
+ first_wn = group.window_names.first
104
+ Tmux.rename_window(@session, first_wn, group.name)
105
+
106
+ group.window_names.drop(1).each do |wn|
107
+ Tmux.join_pane(@session, wn, group.name)
108
+ end
109
+
110
+ group.window_names.each_with_index do |wn, idx|
111
+ Tmux.set_pane_title(@session, group.name, idx, wn)
112
+ end
113
+ Tmux.select_layout(@session, group.name, group.split)
114
+ end
115
+
116
+ # Single-window "groups" still get their window renamed to the group name
117
+ # so that subsequent lookups (status, target) find them.
118
+ groups.select { |g| g.window_names.size == 1 }.each do |group|
119
+ wn = group.window_names.first
120
+ Tmux.rename_window(@session, wn, group.name)
121
+ Tmux.set_pane_title(@session, group.name, 0, wn)
122
+ end
123
+ end
124
+
125
+ # Move windows so their tmux indices match the order declared in config.
126
+ # Two-phase to avoid collisions: first park everyone in a high-index scratch
127
+ # zone, then move them into their final slots.
128
+ def reorder(layout_name = @resolver.active_layout)
129
+ return if @dry_run
130
+
131
+ order = @config.effective_window_order(layout_name)
132
+ names = order.map { |e| e[:name] }
133
+
134
+ names.each_with_index do |name, i|
135
+ cur = Tmux.list_windows(@session).find { |w| w[:name] == name }
136
+ next unless cur
137
+ Tmux.move_window(@session, name, 900 + i)
138
+ end
139
+
140
+ names.each_with_index do |name, target_idx|
141
+ Tmux.move_window(@session, name, target_idx)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Answers the question "where is the logical window <foo> actually living
5
+ # in tmux right now?" given the config + active layout + live tmux state.
6
+ #
7
+ # Separating this from Runner means Reconciler, LayoutManager, ExecRunner,
8
+ # StatusView and Target can all share a single source of truth.
9
+ class PaneResolver
10
+ def initialize(config, session: config.session, layout_override: nil)
11
+ @config = config
12
+ @session = session
13
+ @layout_override = layout_override
14
+ end
15
+
16
+ # The layout the caller asked for, or the one persisted in the session's
17
+ # tmux environment, or the config's default. May be nil if no layouts at all.
18
+ def active_layout
19
+ return @layout_override if @layout_override
20
+ Tmux.has_session?(@session) ? stored_layout : @config.default_layout
21
+ end
22
+
23
+ # Same as #active_layout but ignores any --layout override — used when we
24
+ # need to know what's really in the session right now (e.g. to decide
25
+ # whether a layout switch is needed).
26
+ def stored_layout
27
+ if Tmux.has_session?(@session)
28
+ stored = Tmux.show_environment(@session, 'MXUP_LAYOUT')
29
+ return stored if stored && @config.layout_names.include?(stored)
30
+ end
31
+ @config.default_layout
32
+ end
33
+
34
+ # Resolve a logical window name to a tmux target string (either "window"
35
+ # for a standalone, or "group.pane_index" for a grouped window). Returns
36
+ # nil if the window isn't currently in the session.
37
+ def pane_target(window_name, layout = active_layout)
38
+ result = @config.find_group_for_window(layout, window_name)
39
+
40
+ if result
41
+ group, cfg_idx = result
42
+ return nil unless window_exists?(group.name)
43
+
44
+ pane = Tmux.list_panes(@session)
45
+ .find { |p| p[:name] == group.name && p[:title] == window_name }
46
+ actual_idx = pane ? pane[:pane_index] : cfg_idx
47
+ Tmux.pane_target(group.name, actual_idx)
48
+ else
49
+ window_exists?(window_name) ? window_name : nil
50
+ end
51
+ end
52
+
53
+ # Look up the pane metadata hash for an already-resolved tmux target.
54
+ def pane_for(target)
55
+ if target =~ /\A(.+)\.(\d+)\z/
56
+ win_name = Regexp.last_match(1)
57
+ pane_idx = Regexp.last_match(2).to_i
58
+ Tmux.list_panes(@session)
59
+ .find { |p| p[:name] == win_name && p[:pane_index] == pane_idx }
60
+ else
61
+ Tmux.list_panes(@session)
62
+ .find { |p| p[:name] == target && p[:pane_index] == 0 }
63
+ end
64
+ end
65
+
66
+ def window_exists?(name)
67
+ Tmux.list_windows(@session).any? { |w| w[:name] == name }
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Walks the process tree from a pane PID down to the interesting leaf, and
5
+ # reads `ps` fields from an arbitrary pid. Used by StatusView to show the
6
+ # actual command the user cares about (not the `zsh -c` wrapping it).
7
+ module ProcessProbe
8
+ module_function
9
+
10
+ # Follow pgrep -P down the descendant tree until a leaf (no children) is
11
+ # reached. Falls back to the starting pid if pgrep is unavailable.
12
+ def leaf_pid(pid)
13
+ current = pid
14
+ loop do
15
+ child = `pgrep -P #{current} 2>/dev/null`.strip.split("\n").first
16
+ break if child.nil? || child.empty?
17
+ current = child.to_i
18
+ end
19
+ current
20
+ end
21
+
22
+ def ps_field(pid, field)
23
+ val = `ps -p #{pid} -o #{field}= 2>/dev/null`.strip
24
+ val.empty? ? nil : val
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Drives `mxup up`: creates a session from scratch when missing, or brings
5
+ # the existing session in line with the config.
6
+ #
7
+ # Reconciliation rules:
8
+ # - missing windows → create + start command
9
+ # - extra windows → kill (with warning)
10
+ # - idle/crashed → re-send the command
11
+ # - running healthy → leave alone
12
+ # - layout changed → regroup panes without killing PIDs
13
+ class Reconciler
14
+ def initialize(config, launcher:, resolver:, layout_manager:,
15
+ dry_run: false, out: nil, err: nil)
16
+ @config = config
17
+ @session = config.session
18
+ @launcher = launcher
19
+ @resolver = resolver
20
+ @layout_manager = layout_manager
21
+ @dry_run = dry_run
22
+ @out_override = out
23
+ @err_override = err
24
+ end
25
+
26
+ def out
27
+ @out_override || $stdout
28
+ end
29
+
30
+ def err
31
+ @err_override || $stderr
32
+ end
33
+
34
+ def up
35
+ if Tmux.has_session?(@session)
36
+ reconcile
37
+ else
38
+ create_fresh
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def create_fresh
45
+ out.puts "Creating session #{@session}..."
46
+ @launcher.write_all unless @dry_run
47
+
48
+ layout = @resolver.active_layout
49
+ order = @config.effective_window_order(layout)
50
+
51
+ if @dry_run
52
+ order.each do |entry|
53
+ if entry[:type] == :group
54
+ names = entry[:group].window_names.join(', ')
55
+ out.puts "[dry-run] Would create pane group: #{entry[:name]} (#{names})"
56
+ else
57
+ out.puts "[dry-run] Would create window: #{entry[:name]}"
58
+ end
59
+ end
60
+ return
61
+ end
62
+
63
+ create_first_entry(order.first)
64
+ order.drop(1).each { |entry| create_entry(entry) }
65
+
66
+ Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout
67
+ Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profile) if @config.profile
68
+ out.puts "Session #{@session} is up (#{@config.windows.size} windows)."
69
+ end
70
+
71
+ def create_first_entry(entry)
72
+ if entry[:type] == :group
73
+ group = entry[:group]
74
+ first_win = @config.window_by_name(group.window_names.first)
75
+ Tmux.new_session(@session, group.name, first_win.root)
76
+ populate_pane_group_in_existing_window(group)
77
+ out.puts " #{group.name}: created (#{group.window_names.join(', ')})"
78
+ else
79
+ win = @config.window_by_name(entry[:name])
80
+ Tmux.new_session(@session, win.name, win.root)
81
+ Tmux.set_pane_title(@session, win.name, 0, win.name)
82
+ Tmux.send_keys(@session, win.name, @launcher.command_for(win))
83
+ out.puts " #{win.name}: created"
84
+ end
85
+ end
86
+
87
+ def create_entry(entry)
88
+ if entry[:type] == :group
89
+ create_pane_group(entry[:group])
90
+ out.puts " #{entry[:name]}: created (#{entry[:group].window_names.join(', ')})"
91
+ else
92
+ win = @config.window_by_name(entry[:name])
93
+ create_standalone(win)
94
+ out.puts " #{win.name}: created"
95
+ end
96
+ end
97
+
98
+ def reconcile
99
+ out.puts "Reconciling session #{@session}..."
100
+ @launcher.write_all unless @dry_run
101
+
102
+ layout = @resolver.active_layout
103
+ stored = Tmux.show_environment(@session, 'MXUP_LAYOUT')
104
+
105
+ @layout_manager.switch(layout) if layout && stored && stored != layout
106
+
107
+ existing_panes = Tmux.list_panes(@session)
108
+ existing_windows = Tmux.list_windows(@session).map { |w| w[:name] }
109
+ order = @config.effective_window_order(layout)
110
+
111
+ remove_extras(existing_windows, order)
112
+ order.each { |entry| reconcile_entry(entry, existing_panes, existing_windows) }
113
+
114
+ @layout_manager.reorder(layout) unless @dry_run
115
+ Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout && !@dry_run
116
+ Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profile) if @config.profile && !@dry_run
117
+ out.puts 'Reconciliation complete.'
118
+ end
119
+
120
+ def remove_extras(existing_windows, order)
121
+ expected = order.map { |e| e[:name] }.to_set
122
+ existing_windows.reject { |n| expected.include?(n) }.each do |name|
123
+ if @dry_run
124
+ out.puts " [dry-run] Would remove extra window: #{name}"
125
+ else
126
+ err.puts " #{name}: not in config — removing"
127
+ Tmux.kill_window(@session, name)
128
+ end
129
+ end
130
+ end
131
+
132
+ def reconcile_entry(entry, panes, windows)
133
+ if entry[:type] == :group
134
+ reconcile_group(entry[:group], panes, windows)
135
+ else
136
+ reconcile_standalone(entry[:name], panes)
137
+ end
138
+ end
139
+
140
+ def reconcile_group(group, panes, windows)
141
+ unless windows.include?(group.name)
142
+ if @dry_run
143
+ out.puts " [dry-run] Would create pane group: #{group.name}"
144
+ else
145
+ create_pane_group(group)
146
+ out.puts " #{group.name}: created (#{group.window_names.join(', ')})"
147
+ end
148
+ return
149
+ end
150
+
151
+ expected = group.window_names.size
152
+ current = Tmux.pane_count(@session, group.name)
153
+ if current != expected
154
+ if @dry_run
155
+ out.puts " [dry-run] Would recreate pane group: #{group.name} " \
156
+ "(pane count #{current} != #{expected})"
157
+ else
158
+ Tmux.kill_window(@session, group.name)
159
+ create_pane_group(group)
160
+ out.puts " #{group.name}: recreated (pane count changed)"
161
+ end
162
+ return
163
+ end
164
+
165
+ group_panes = panes.select { |p| p[:name] == group.name }
166
+ .sort_by { |p| p[:pane_index] }
167
+
168
+ group.window_names.each_with_index do |wn, idx|
169
+ win = @config.window_by_name(wn)
170
+ pane = group_panes.find { |p| p[:title] == wn } ||
171
+ group_panes.find { |p| p[:pane_index] == idx }
172
+ next unless pane
173
+
174
+ actual_idx = pane[:pane_index]
175
+ target = Tmux.pane_target(group.name, actual_idx)
176
+
177
+ if SHELLS.include?(pane[:fg_cmd])
178
+ if @dry_run
179
+ out.puts " [dry-run] Would restart idle pane: #{wn} in #{group.name}"
180
+ else
181
+ Tmux.send_keys(@session, target, @launcher.command_for(win))
182
+ out.puts " #{wn}: restarted (was idle) [#{group.name}.#{actual_idx}]"
183
+ end
184
+ else
185
+ out.puts " #{wn}: running (#{pane[:fg_cmd]}) — ok [#{group.name}.#{actual_idx}]"
186
+ end
187
+ end
188
+ end
189
+
190
+ def reconcile_standalone(window_name, panes)
191
+ win = @config.window_by_name(window_name)
192
+ pane = panes.find { |p| p[:name] == window_name }
193
+
194
+ if pane.nil?
195
+ if @dry_run
196
+ out.puts " [dry-run] Would create missing window: #{window_name}"
197
+ else
198
+ create_standalone(win)
199
+ out.puts " #{window_name}: created (was missing)"
200
+ end
201
+ elsif SHELLS.include?(pane[:fg_cmd])
202
+ if @dry_run
203
+ out.puts " [dry-run] Would restart idle window: #{window_name}"
204
+ else
205
+ Tmux.send_keys(@session, window_name, @launcher.command_for(win))
206
+ out.puts " #{window_name}: restarted (was idle)"
207
+ end
208
+ else
209
+ out.puts " #{window_name}: running (#{pane[:fg_cmd]}) — ok"
210
+ end
211
+ end
212
+
213
+ def create_pane_group(group)
214
+ first_win = @config.window_by_name(group.window_names.first)
215
+ Tmux.new_window(@session, group.name, first_win.root)
216
+ populate_pane_group_in_existing_window(group)
217
+ end
218
+
219
+ def populate_pane_group_in_existing_window(group)
220
+ group.window_names.drop(1).each_with_index do |wn, i|
221
+ win = @config.window_by_name(wn)
222
+ Tmux.split_window(@session, group.name, win.root, target_pane: i)
223
+ end
224
+
225
+ Tmux.select_layout(@session, group.name, group.split)
226
+
227
+ group.window_names.each_with_index do |wn, idx|
228
+ win = @config.window_by_name(wn)
229
+ Tmux.set_pane_title(@session, group.name, idx, win.name)
230
+ Tmux.send_keys(@session, Tmux.pane_target(group.name, idx), @launcher.command_for(win))
231
+ end
232
+ end
233
+
234
+ def create_standalone(win)
235
+ Tmux.new_window(@session, win.name, win.root)
236
+ Tmux.set_pane_title(@session, win.name, 0, win.name)
237
+ Tmux.send_keys(@session, win.name, @launcher.command_for(win))
238
+ end
239
+ end
240
+ end