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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +395 -0
- data/bin/mxup +10 -0
- data/examples/myapp-dev.yml +110 -0
- data/lib/mxup/cli.rb +170 -0
- data/lib/mxup/config.rb +240 -0
- data/lib/mxup/exec_runner.rb +178 -0
- data/lib/mxup/graceful_stop.rb +72 -0
- data/lib/mxup/launcher.rb +135 -0
- data/lib/mxup/layout_manager.rb +145 -0
- data/lib/mxup/pane_resolver.rb +70 -0
- data/lib/mxup/process_probe.rb +27 -0
- data/lib/mxup/reconciler.rb +240 -0
- data/lib/mxup/runner.rb +253 -0
- data/lib/mxup/status_view.rb +149 -0
- data/lib/mxup/tmux.rb +143 -0
- data/lib/mxup/version.rb +5 -0
- data/lib/mxup.rb +43 -0
- metadata +99 -0
|
@@ -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
|