muxr 0.1.10 → 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 +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/layout_manager.rb
CHANGED
|
@@ -10,22 +10,41 @@ module Muxr
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle].freeze
|
|
13
|
+
LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle auto].freeze
|
|
14
|
+
|
|
15
|
+
AUTO_SPIRAL_MIN_COLS = 180
|
|
16
|
+
AUTO_SPIRAL_MIN_ROWS = 30
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_writer :auto_spiral_min_cols, :auto_spiral_min_rows
|
|
20
|
+
|
|
21
|
+
def auto_spiral_min_cols
|
|
22
|
+
@auto_spiral_min_cols || AUTO_SPIRAL_MIN_COLS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def auto_spiral_min_rows
|
|
26
|
+
@auto_spiral_min_rows || AUTO_SPIRAL_MIN_ROWS
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT_RATIO = 0.5
|
|
31
|
+
RATIO_BOUNDS = (0.1..0.9)
|
|
14
32
|
|
|
15
33
|
module_function
|
|
16
34
|
|
|
17
|
-
def compute(layout, count, area, focused_index: 0, master_index: 0)
|
|
35
|
+
def compute(layout, count, area, focused_index: 0, master_index: 0, ratio: DEFAULT_RATIO, nmaster: 1)
|
|
18
36
|
return [] if count <= 0
|
|
19
37
|
master_index = master_index.clamp(0, count - 1)
|
|
20
38
|
focused_index = focused_index.clamp(0, count - 1)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
when :
|
|
39
|
+
ratio = ratio.to_f.clamp(RATIO_BOUNDS)
|
|
40
|
+
case resolve(layout, area)
|
|
41
|
+
when :tall then tall(count, area, master_index, ratio, nmaster)
|
|
42
|
+
when :wide then wide(count, area, master_index, ratio, nmaster)
|
|
24
43
|
when :columns then columns(count, area)
|
|
25
44
|
when :rows then rows(count, area)
|
|
26
45
|
when :grid then grid(count, area)
|
|
27
46
|
when :spiral then spiral(count, area)
|
|
28
|
-
when :centered then centered(count, area, master_index)
|
|
47
|
+
when :centered then centered(count, area, master_index, ratio, nmaster)
|
|
29
48
|
when :stack then stack(count, area, focused_index)
|
|
30
49
|
when :monocle then monocle(count, area, focused_index)
|
|
31
50
|
else
|
|
@@ -33,54 +52,52 @@ module Muxr
|
|
|
33
52
|
end
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
55
|
+
def resolve(layout, area)
|
|
56
|
+
return layout unless layout == :auto
|
|
57
|
+
spiral_fits?(area) ? :spiral : :stack
|
|
58
|
+
end
|
|
41
59
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
slave_count = others.length
|
|
46
|
-
base_h = area.h / slave_count
|
|
47
|
-
remainder = area.h - base_h * slave_count
|
|
60
|
+
def spiral_fits?(area)
|
|
61
|
+
area.w >= LayoutManager.auto_spiral_min_cols && area.h >= LayoutManager.auto_spiral_min_rows
|
|
62
|
+
end
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
def split_masters(count, master_index, nmaster)
|
|
65
|
+
ordered = [master_index] + ((0...count).to_a - [master_index])
|
|
66
|
+
n = nmaster.to_i.clamp(1, count)
|
|
67
|
+
[ordered.first(n), ordered.drop(n)]
|
|
68
|
+
end
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
h = base_h + (i < remainder ? 1 : 0)
|
|
55
|
-
rects[idx] = Rect.new(area.x + master_w, y, stack_w, h)
|
|
56
|
-
y += h
|
|
57
|
-
end
|
|
58
|
-
rects
|
|
70
|
+
def master_extent(total, ratio)
|
|
71
|
+
(total * ratio).floor.clamp(1, [total - 1, 1].max)
|
|
59
72
|
end
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
# half; remaining panes sit side-by-side in the bottom half, dividing the
|
|
63
|
-
# remaining width evenly.
|
|
64
|
-
def wide(count, area, master_index = 0)
|
|
74
|
+
def tall(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
65
75
|
master_index = master_index.clamp(0, count - 1)
|
|
66
|
-
|
|
76
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
77
|
+
rects = Array.new(count)
|
|
78
|
+
if others.empty?
|
|
79
|
+
stack_column(rects, masters, area.x, area.y, area.w, area.h)
|
|
80
|
+
return rects
|
|
81
|
+
end
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
others
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
remainder = area.w - base_w * slave_count
|
|
83
|
+
master_w = master_extent(area.w, ratio)
|
|
84
|
+
stack_column(rects, masters, area.x, area.y, master_w, area.h)
|
|
85
|
+
stack_column(rects, others, area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
|
|
86
|
+
rects
|
|
87
|
+
end
|
|
74
88
|
|
|
89
|
+
def wide(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
90
|
+
master_index = master_index.clamp(0, count - 1)
|
|
91
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
75
92
|
rects = Array.new(count)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
others.each_with_index do |idx, i|
|
|
80
|
-
w = base_w + (i < remainder ? 1 : 0)
|
|
81
|
-
rects[idx] = Rect.new(x, area.y + master_h, w, stack_h)
|
|
82
|
-
x += w
|
|
93
|
+
if others.empty?
|
|
94
|
+
spread_row(rects, masters, area.x, area.y, area.w, area.h)
|
|
95
|
+
return rects
|
|
83
96
|
end
|
|
97
|
+
|
|
98
|
+
master_h = master_extent(area.h, ratio)
|
|
99
|
+
spread_row(rects, masters, area.x, area.y, area.w, master_h)
|
|
100
|
+
spread_row(rects, others, area.x, area.y + master_h, area.w, [area.h - master_h, 1].max)
|
|
84
101
|
rects
|
|
85
102
|
end
|
|
86
103
|
|
|
@@ -136,30 +153,27 @@ module Muxr
|
|
|
136
153
|
rects
|
|
137
154
|
end
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
# remaining panes are dealt alternately to a left and a right column and
|
|
141
|
-
# stacked within each. With a single slave there is no symmetry to keep, so
|
|
142
|
-
# it falls back to a simple master/slave vertical split (like `tall`).
|
|
143
|
-
def centered(count, area, master_index = 0)
|
|
156
|
+
def centered(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
144
157
|
master_index = master_index.clamp(0, count - 1)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
others
|
|
148
|
-
|
|
158
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
159
|
+
rects = Array.new(count)
|
|
160
|
+
if others.empty?
|
|
161
|
+
stack_column(rects, masters, area.x, area.y, area.w, area.h)
|
|
162
|
+
return rects
|
|
163
|
+
end
|
|
149
164
|
|
|
165
|
+
master_w = master_extent(area.w, ratio)
|
|
150
166
|
if others.length == 1
|
|
151
|
-
|
|
152
|
-
rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
|
|
167
|
+
stack_column(rects, masters, area.x, area.y, master_w, area.h)
|
|
153
168
|
rects[others[0]] = Rect.new(area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
|
|
154
169
|
return rects
|
|
155
170
|
end
|
|
156
171
|
|
|
157
|
-
master_w = [area.w / 2, 1].max
|
|
158
172
|
side_w = area.w - master_w
|
|
159
173
|
left_w = [side_w / 2, 1].max
|
|
160
174
|
right_w = [side_w - left_w, 1].max
|
|
161
175
|
|
|
162
|
-
rects
|
|
176
|
+
stack_column(rects, masters, area.x + left_w, area.y, master_w, area.h)
|
|
163
177
|
left = others.select.with_index { |_, i| i.even? }
|
|
164
178
|
right = others.select.with_index { |_, i| i.odd? }
|
|
165
179
|
stack_column(rects, left, area.x, area.y, left_w, area.h)
|
|
@@ -205,6 +219,18 @@ module Muxr
|
|
|
205
219
|
end
|
|
206
220
|
end
|
|
207
221
|
|
|
222
|
+
def spread_row(rects, indices, x, y, total_w, h)
|
|
223
|
+
return if indices.empty?
|
|
224
|
+
base_w = total_w / indices.length
|
|
225
|
+
rem = total_w - base_w * indices.length
|
|
226
|
+
cx = x
|
|
227
|
+
indices.each_with_index do |idx, i|
|
|
228
|
+
w = base_w + (i < rem ? 1 : 0)
|
|
229
|
+
rects[idx] = Rect.new(cx, y, w, h)
|
|
230
|
+
cx += w
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
208
234
|
# Roughly square grid. Each row stretches its panes to fill the full width
|
|
209
235
|
# so an underfull bottom row doesn't leave gaps.
|
|
210
236
|
def grid(count, area)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
module MouseReport
|
|
3
|
+
WHEEL_UP = 64
|
|
4
|
+
WHEEL_DOWN = 65
|
|
5
|
+
X10_COORD_MAX = 223
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def wheel(direction, row:, col:, encoding: :sgr)
|
|
10
|
+
button = direction == :up ? WHEEL_UP : WHEEL_DOWN
|
|
11
|
+
encoding == :sgr ? sgr(button, row, col) : x10(button, row, col)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sgr(button, row, col)
|
|
15
|
+
"\e[<#{button};#{[col, 1].max};#{[row, 1].max}M".b
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def x10(button, row, col)
|
|
19
|
+
c = col.clamp(1, X10_COORD_MAX)
|
|
20
|
+
r = row.clamp(1, X10_COORD_MAX)
|
|
21
|
+
"\e[M#{(32 + button).chr}#{(32 + c).chr}#{(32 + r).chr}".b
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -14,6 +14,12 @@ module Muxr
|
|
|
14
14
|
class Pane
|
|
15
15
|
attr_reader :id, :terminal, :process
|
|
16
16
|
attr_accessor :rect
|
|
17
|
+
# "<session>:<pane id>" when this pane mirrors a pane owned by another muxr
|
|
18
|
+
# server; nil for a locally-owned PTY.
|
|
19
|
+
attr_accessor :origin
|
|
20
|
+
# Smallest viewport any remote mirror is showing this pane through, as
|
|
21
|
+
# [rows, cols], or nil when nobody is mirroring.
|
|
22
|
+
attr_accessor :mirror_size
|
|
17
23
|
# Last value written by Application's foreground poller thread. nil when
|
|
18
24
|
# the shell itself is foreground (the common empty-prompt case) or when
|
|
19
25
|
# the lookup hasn't run / couldn't read. Renderer surfaces this in the
|
|
@@ -36,6 +42,17 @@ module Muxr
|
|
|
36
42
|
@initial_cwd = cwd || @process.cwd
|
|
37
43
|
@private_flag = false
|
|
38
44
|
@foreground_command = nil
|
|
45
|
+
@origin = nil
|
|
46
|
+
@mirror_size = nil
|
|
47
|
+
@activity = false
|
|
48
|
+
@bell = false
|
|
49
|
+
@silent = false
|
|
50
|
+
@silence_after = nil
|
|
51
|
+
hush!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mirror?
|
|
55
|
+
@process.respond_to?(:mirror?) && @process.mirror?
|
|
39
56
|
end
|
|
40
57
|
|
|
41
58
|
def pid
|
|
@@ -89,6 +106,77 @@ module Muxr
|
|
|
89
106
|
# etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
|
|
90
107
|
# frames and is the main source of in-pane flicker. Bounded by a byte cap
|
|
91
108
|
# so a runaway producer can't starve other panes on a single tick.
|
|
109
|
+
ATTENTION_GRACE = 1.5
|
|
110
|
+
NAME_MAX = 24
|
|
111
|
+
|
|
112
|
+
attr_reader :name
|
|
113
|
+
|
|
114
|
+
def name=(value)
|
|
115
|
+
text = value.to_s.gsub(/[[:cntrl:]]/, "").strip[0, NAME_MAX]
|
|
116
|
+
@name = text.empty? ? nil : text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def label
|
|
120
|
+
@name || @id.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.now
|
|
124
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def activity?
|
|
128
|
+
@activity
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bell?
|
|
132
|
+
@bell
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def silent?
|
|
136
|
+
@silent
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
attr_reader :silence_after
|
|
140
|
+
|
|
141
|
+
def note_output(now = Pane.now, attended: false)
|
|
142
|
+
return if now < @quiet_until
|
|
143
|
+
@last_output_at = now
|
|
144
|
+
@silent = false
|
|
145
|
+
@silence_reported = false
|
|
146
|
+
@activity = true unless attended
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def watch_silence(seconds, now = Pane.now)
|
|
150
|
+
@silence_after = seconds
|
|
151
|
+
@last_output_at = now
|
|
152
|
+
@silent = false
|
|
153
|
+
@silence_reported = false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def silence_due?(now = Pane.now)
|
|
157
|
+
return false unless @silence_after
|
|
158
|
+
!@silence_reported && now - @last_output_at >= @silence_after
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def note_silence!
|
|
162
|
+
@silent = true
|
|
163
|
+
@silence_reported = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def note_bell
|
|
167
|
+
@bell = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clear_attention!
|
|
171
|
+
@activity = false
|
|
172
|
+
@bell = false
|
|
173
|
+
@silent = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def hush!(now = Pane.now)
|
|
177
|
+
@quiet_until = now + ATTENTION_GRACE
|
|
178
|
+
end
|
|
179
|
+
|
|
92
180
|
READ_BUDGET = 1 << 20 # 1 MiB
|
|
93
181
|
def read_from_pty
|
|
94
182
|
total = 0
|
|
@@ -96,13 +184,16 @@ module Muxr
|
|
|
96
184
|
chunk = @process.read_nonblock
|
|
97
185
|
break unless chunk
|
|
98
186
|
@terminal.feed(chunk)
|
|
187
|
+
yield chunk if block_given?
|
|
99
188
|
total += chunk.bytesize
|
|
100
189
|
end
|
|
101
190
|
# The emulator may owe the inner program a reply (DSR / CPR — see
|
|
102
191
|
# Terminal#take_pending_replies!). Ship it back through the PTY's
|
|
103
192
|
# input side as if it had been typed. Failure here is non-fatal: the
|
|
104
|
-
# process can have exited between read and write.
|
|
105
|
-
|
|
193
|
+
# process can have exited between read and write. A mirror stays quiet:
|
|
194
|
+
# the pane's owner already answered, and a second answer would reach the
|
|
195
|
+
# program as spurious keystrokes.
|
|
196
|
+
if (reply = @terminal.take_pending_replies!) && !mirror?
|
|
106
197
|
begin
|
|
107
198
|
@process.write(reply)
|
|
108
199
|
rescue Errno::EIO, Errno::EPIPE
|
|
@@ -111,12 +202,43 @@ module Muxr
|
|
|
111
202
|
total.positive? ? total : nil
|
|
112
203
|
end
|
|
113
204
|
|
|
205
|
+
# A mirror's grid is sized by the pane's owner: the relayed stream carries
|
|
206
|
+
# absolute cursor addresses for *that* geometry. We only forward the
|
|
207
|
+
# viewport we can offer and wait for the size the owner settles on.
|
|
114
208
|
def resize(rows, cols)
|
|
209
|
+
if mirror?
|
|
210
|
+
hush! unless rows == @terminal.rows && cols == @terminal.cols
|
|
211
|
+
return @process.resize(rows, cols)
|
|
212
|
+
end
|
|
213
|
+
rows, cols = fit_to_mirrors(rows, cols)
|
|
115
214
|
return if rows == @terminal.rows && cols == @terminal.cols
|
|
215
|
+
hush!
|
|
116
216
|
@terminal.resize(rows, cols)
|
|
117
217
|
@process.resize(rows, cols)
|
|
118
218
|
end
|
|
119
219
|
|
|
220
|
+
def fit_to_mirrors(rows, cols)
|
|
221
|
+
return [rows, cols] unless @mirror_size
|
|
222
|
+
[[rows, @mirror_size[0]].min.clamp(1, rows), [cols, @mirror_size[1]].min.clamp(1, cols)]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Shrink to fit a mirror that just arrived, without waiting for a render —
|
|
226
|
+
# this session may be detached, in which case no render is coming. Growing
|
|
227
|
+
# back is the layout's call on the next frame it draws.
|
|
228
|
+
def clamp_to_mirrors!
|
|
229
|
+
rows, cols = fit_to_mirrors(@terminal.rows, @terminal.cols)
|
|
230
|
+
return if rows == @terminal.rows && cols == @terminal.cols
|
|
231
|
+
hush!
|
|
232
|
+
@terminal.resize(rows, cols)
|
|
233
|
+
@process.resize(rows, cols)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Force the inner program to repaint itself (see PTYProcess#nudge_redraw).
|
|
237
|
+
# Used by the refresh keybinding to recover from emulation drift.
|
|
238
|
+
def request_redraw
|
|
239
|
+
@process.nudge_redraw
|
|
240
|
+
end
|
|
241
|
+
|
|
120
242
|
def alive?
|
|
121
243
|
@process.alive?
|
|
122
244
|
end
|
|
@@ -125,6 +247,10 @@ module Muxr
|
|
|
125
247
|
@process.cwd || @initial_cwd
|
|
126
248
|
end
|
|
127
249
|
|
|
250
|
+
def relinquish!
|
|
251
|
+
@process.relinquish! if @process.respond_to?(:relinquish!)
|
|
252
|
+
end
|
|
253
|
+
|
|
128
254
|
def close
|
|
129
255
|
@process.close
|
|
130
256
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Selection state for the attach overlay: a flat, navigable list of every
|
|
3
|
+
# pane offered by the other muxr servers on this machine, with session
|
|
4
|
+
# headings interleaved so the list reads as a grouped tree while the cursor
|
|
5
|
+
# only ever lands on something selectable.
|
|
6
|
+
class PanePicker
|
|
7
|
+
Row = Struct.new(:kind, :session, :entry) do
|
|
8
|
+
def selectable?
|
|
9
|
+
kind == :pane
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :rows, :index
|
|
14
|
+
|
|
15
|
+
def initialize(entries)
|
|
16
|
+
@rows = build_rows(entries)
|
|
17
|
+
@index = @rows.index(&:selectable?) || 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def empty?
|
|
21
|
+
@rows.none?(&:selectable?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def selected
|
|
25
|
+
row = @rows[@index]
|
|
26
|
+
row&.selectable? ? row.entry : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def move(delta)
|
|
30
|
+
return if empty?
|
|
31
|
+
i = @index
|
|
32
|
+
@rows.length.times do
|
|
33
|
+
i = (i + delta) % @rows.length
|
|
34
|
+
next unless @rows[i].selectable?
|
|
35
|
+
@index = i
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_rows(entries)
|
|
43
|
+
entries.group_by(&:session).flat_map do |session, panes|
|
|
44
|
+
[Row.new(:session, session, nil)] +
|
|
45
|
+
panes.map { |entry| Row.new(:pane, session, entry) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# Takes a pane away from another muxr server for good. Where RemotePane
|
|
6
|
+
# borrows a pane by relaying its bytes, this moves the pane itself: the
|
|
7
|
+
# master pty file descriptor crosses the Unix socket via SCM_RIGHTS, so the
|
|
8
|
+
# shell that was running keeps running, with its jobs, its environment and
|
|
9
|
+
# whatever it had on screen, and simply belongs to us afterwards.
|
|
10
|
+
#
|
|
11
|
+
# Two things shape the wire order. The fd has to arrive *before* the bulky
|
|
12
|
+
# emulator state, because pulling it out of the socket needs recvmsg and any
|
|
13
|
+
# plain read that swallows the carrier byte first drops the fd on the floor.
|
|
14
|
+
# And a short preamble has to arrive before *that*, because a refusal (the
|
|
15
|
+
# pane is private, or the last one in its session) has no fd to send and we
|
|
16
|
+
# would otherwise block forever waiting for one. So: preamble, fd, state.
|
|
17
|
+
#
|
|
18
|
+
# The move is two-phase. Nothing is torn down on the far side until we have
|
|
19
|
+
# a working pane here and say so, and a failure anywhere leaves the pane
|
|
20
|
+
# exactly where it was rather than dropping a live shell between servers.
|
|
21
|
+
module PaneTransfer
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
TIMEOUT = 5.0
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:pane, :session, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
def self.claim(socket_path:, pane_id:)
|
|
29
|
+
socket = connect(socket_path)
|
|
30
|
+
begin
|
|
31
|
+
request(socket, 1, "pane.move", "pane" => pane_id)
|
|
32
|
+
await_preamble(socket)
|
|
33
|
+
io = receive_fd(socket)
|
|
34
|
+
begin
|
|
35
|
+
state = await_state(socket)
|
|
36
|
+
pane = build_pane(io, state)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
io.close rescue nil
|
|
39
|
+
notify(socket, "pane.move_abort")
|
|
40
|
+
raise e.is_a?(Error) ? e : Error.new(e.message)
|
|
41
|
+
end
|
|
42
|
+
request(socket, 2, "pane.move_commit")
|
|
43
|
+
await_result(socket, 2)
|
|
44
|
+
Result.new(pane: pane, session: state["session"].to_s)
|
|
45
|
+
ensure
|
|
46
|
+
socket.close rescue nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.build_pane(io, state)
|
|
51
|
+
rows = state["rows"].to_i
|
|
52
|
+
cols = state["cols"].to_i
|
|
53
|
+
raise Error, "owner sent a nonsense pane size (#{rows}x#{cols})" unless rows.positive? && cols.positive?
|
|
54
|
+
process = PTYProcess.new(
|
|
55
|
+
rows: rows, cols: cols,
|
|
56
|
+
adopt_io: io, adopt_pid: state["pid"]
|
|
57
|
+
)
|
|
58
|
+
pane = Pane.new(id: state["pane"].to_s, rows: rows, cols: cols, cwd: state["cwd"], process: process)
|
|
59
|
+
pane.terminal.restore_transfer!(state)
|
|
60
|
+
pane.name = state["name"] if state["name"]
|
|
61
|
+
pane
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.connect(socket_path)
|
|
65
|
+
UNIXSocket.new(socket_path)
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
raise Error, "cannot reach session socket: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.request(socket, id, method, params = {})
|
|
71
|
+
socket.write(JSON.generate("id" => id, "method" => method, "params" => params) + "\n")
|
|
72
|
+
rescue SystemCallError, IOError => e
|
|
73
|
+
raise Error, e.message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.notify(socket, method, params = {})
|
|
77
|
+
socket.write(JSON.generate("method" => method, "params" => params) + "\n")
|
|
78
|
+
rescue SystemCallError, IOError
|
|
79
|
+
# Owner will time the half-finished move out on its own.
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Read the preamble a byte at a time. Reading ahead here would be a bug,
|
|
83
|
+
# not an optimization: the very next thing on the socket is the fd, and a
|
|
84
|
+
# buffered read past the newline would consume its carrier byte.
|
|
85
|
+
def self.await_preamble(socket)
|
|
86
|
+
line = +""
|
|
87
|
+
deadline = now + TIMEOUT
|
|
88
|
+
until line.end_with?("\n")
|
|
89
|
+
raise Error, "timed out waiting for the owning session" unless wait_readable(socket, deadline)
|
|
90
|
+
byte = begin
|
|
91
|
+
socket.read_nonblock(1)
|
|
92
|
+
rescue IO::WaitReadable
|
|
93
|
+
next
|
|
94
|
+
rescue EOFError, SystemCallError, IOError
|
|
95
|
+
raise Error, "owning session closed the connection"
|
|
96
|
+
end
|
|
97
|
+
line << byte
|
|
98
|
+
raise Error, "owning session sent an oversized reply" if line.bytesize > 8192
|
|
99
|
+
end
|
|
100
|
+
check(parse(line))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.receive_fd(socket)
|
|
104
|
+
raise Error, "timed out waiting for the pane's terminal" unless wait_readable(socket, now + TIMEOUT)
|
|
105
|
+
socket.recv_io(IO, "r+")
|
|
106
|
+
rescue SystemCallError, IOError => e
|
|
107
|
+
raise Error, "could not take over the pane's terminal: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Safe to buffer freely now — the fd is out of the socket and the state
|
|
111
|
+
# line is the last thing the owner sends before we commit.
|
|
112
|
+
def self.await_state(socket)
|
|
113
|
+
buffer = +""
|
|
114
|
+
deadline = now + TIMEOUT
|
|
115
|
+
until (nl = buffer.index("\n"))
|
|
116
|
+
raise Error, "timed out waiting for the pane's contents" unless wait_readable(socket, deadline)
|
|
117
|
+
begin
|
|
118
|
+
buffer << socket.read_nonblock(READ_CHUNK)
|
|
119
|
+
rescue IO::WaitReadable
|
|
120
|
+
next
|
|
121
|
+
rescue EOFError, SystemCallError, IOError
|
|
122
|
+
raise Error, "owning session closed the connection"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
check(parse(buffer.byteslice(0, nl + 1)))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.await_result(socket, id)
|
|
129
|
+
buffer = +""
|
|
130
|
+
deadline = now + TIMEOUT
|
|
131
|
+
loop do
|
|
132
|
+
while (nl = buffer.index("\n"))
|
|
133
|
+
line = buffer.slice!(0..nl)
|
|
134
|
+
msg = parse(line)
|
|
135
|
+
next unless msg["id"] == id
|
|
136
|
+
return check(msg)
|
|
137
|
+
end
|
|
138
|
+
raise Error, "timed out completing the move" unless wait_readable(socket, deadline)
|
|
139
|
+
begin
|
|
140
|
+
buffer << socket.read_nonblock(READ_CHUNK)
|
|
141
|
+
rescue IO::WaitReadable
|
|
142
|
+
next
|
|
143
|
+
rescue EOFError, SystemCallError, IOError
|
|
144
|
+
raise Error, "owning session closed the connection"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
READ_CHUNK = 64 * 1024
|
|
150
|
+
|
|
151
|
+
def self.check(msg)
|
|
152
|
+
raise Error, msg["error"]["message"].to_s if msg["error"]
|
|
153
|
+
msg["result"] || {}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.parse(line)
|
|
157
|
+
JSON.parse(line)
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
raise Error, "owning session sent something that isn't JSON"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.wait_readable(socket, deadline)
|
|
163
|
+
remaining = deadline - now
|
|
164
|
+
return false if remaining <= 0
|
|
165
|
+
!!IO.select([socket], nil, nil, remaining)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.now
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|