muxr 0.1.11 → 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 +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- 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 +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -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 +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- 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/client.rb
CHANGED
|
@@ -29,11 +29,10 @@ module Muxr
|
|
|
29
29
|
|
|
30
30
|
# Opens the socket. Returns true on success. Raises Errno::ENOENT /
|
|
31
31
|
# Errno::ECONNREFUSED to the caller, which is bin/muxr's job to handle by
|
|
32
|
-
# spawning a server.
|
|
32
|
+
# spawning a server. HELLO is deferred to #run: it now carries the width
|
|
33
|
+
# probe's verdict, and the probe needs the TTY in raw mode first.
|
|
33
34
|
def connect
|
|
34
35
|
@sock = UNIXSocket.new(@socket_path)
|
|
35
|
-
rows, cols = terminal_size
|
|
36
|
-
Protocol.write(@sock, Protocol::HELLO, Protocol.encode_size(rows, cols))
|
|
37
36
|
true
|
|
38
37
|
end
|
|
39
38
|
|
|
@@ -41,8 +40,9 @@ module Muxr
|
|
|
41
40
|
raise "must call #connect first" unless @sock
|
|
42
41
|
|
|
43
42
|
enter_terminal_mode
|
|
44
|
-
install_winch_trap
|
|
45
43
|
@running = true
|
|
44
|
+
send_hello # may clear @running if the socket is already gone
|
|
45
|
+
install_winch_trap
|
|
46
46
|
|
|
47
47
|
begin
|
|
48
48
|
loop_forever
|
|
@@ -139,6 +139,28 @@ module Muxr
|
|
|
139
139
|
@running = false
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# The opening handshake. Sent after the TTY is in raw mode so the width
|
|
143
|
+
# probe can read the terminal's CPR replies; its verdict (e.g. whether the
|
|
144
|
+
# terminal draws ambiguous-width glyphs wide) rides along in the payload so
|
|
145
|
+
# the server can align its emulator/Renderer with this exact terminal.
|
|
146
|
+
def send_hello
|
|
147
|
+
rows, cols = terminal_size
|
|
148
|
+
Protocol.write(@sock, Protocol::HELLO, Protocol.encode_size(rows, cols, probe_caps))
|
|
149
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
150
|
+
@running = false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Run the width probe against the controlling terminal. Skipped (→ no caps,
|
|
154
|
+
# server keeps its defaults) when STDIN/STDOUT aren't a TTY — tests, pipes,
|
|
155
|
+
# CI — or if the probe raises for any reason; a failed probe must never stop
|
|
156
|
+
# the client from attaching.
|
|
157
|
+
def probe_caps
|
|
158
|
+
return {} unless STDIN.tty? && STDOUT.tty?
|
|
159
|
+
WidthProbe.run(out: STDOUT, input: STDIN)
|
|
160
|
+
rescue StandardError
|
|
161
|
+
{}
|
|
162
|
+
end
|
|
163
|
+
|
|
142
164
|
def terminal_size
|
|
143
165
|
IO.console.winsize
|
|
144
166
|
rescue StandardError
|
|
@@ -4,6 +4,66 @@ module Muxr
|
|
|
4
4
|
# rather than a hard error so the user never gets dropped out of the
|
|
5
5
|
# multiplexer for a typo.
|
|
6
6
|
class CommandDispatcher
|
|
7
|
+
# Command names offered by Tab-completion. Deliberately the canonical
|
|
8
|
+
# names only — the terse aliases (ls/q/exit/c/k/kill) still *work* when
|
|
9
|
+
# typed, but completing to a shorter alias would be surprising, and
|
|
10
|
+
# offering both halves would clutter the ambiguity list.
|
|
11
|
+
COMPLETIONS = %w[
|
|
12
|
+
layout drawer claude private save restore sessions attach
|
|
13
|
+
new close next prev master detach help quit silence ratio masters zoom sync rename reload capture
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Argument candidates for the commands that take a fixed vocabulary.
|
|
17
|
+
DRAWER_ARGS = %w[toggle show hide reset].freeze
|
|
18
|
+
|
|
19
|
+
# Tab-completion entry point. Given the current command buffer, returns
|
|
20
|
+
# [completed_line, candidates]: `completed_line` extends the active token
|
|
21
|
+
# to the longest common prefix of its matches (plus a trailing space on a
|
|
22
|
+
# unique match), and `candidates` is the full match list so the caller can
|
|
23
|
+
# show it when the completion is ambiguous. A no-match returns the line
|
|
24
|
+
# unchanged with an empty candidate list.
|
|
25
|
+
def self.complete(line)
|
|
26
|
+
line = line.to_s
|
|
27
|
+
words = line.split(/\s+/)
|
|
28
|
+
words.shift if words.first == "" # leading whitespace → drop the empty
|
|
29
|
+
at_new_token = !!(line =~ /\s\z/) || line.empty?
|
|
30
|
+
|
|
31
|
+
index = at_new_token ? words.length : words.length - 1
|
|
32
|
+
prefix = at_new_token ? "" : (words.last || "")
|
|
33
|
+
|
|
34
|
+
matches = candidates_for(index, words).select { |c| c.start_with?(prefix) }.sort
|
|
35
|
+
return [line, []] if matches.empty?
|
|
36
|
+
|
|
37
|
+
head = at_new_token ? words : words[0...index]
|
|
38
|
+
completed = common_prefix(matches)
|
|
39
|
+
new_line = (head + [completed]).join(" ")
|
|
40
|
+
new_line += " " if matches.length == 1
|
|
41
|
+
[new_line, matches]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Candidate list for the token at `index` given the words typed so far.
|
|
45
|
+
# index 0 is the command name; index 1 is an argument keyed off the command.
|
|
46
|
+
def self.candidates_for(index, words)
|
|
47
|
+
return COMPLETIONS if index.zero?
|
|
48
|
+
|
|
49
|
+
case words[0]
|
|
50
|
+
when "layout" then Window::LAYOUTS.map(&:to_s)
|
|
51
|
+
when "drawer" then DRAWER_ARGS
|
|
52
|
+
when "silence" then %w[off]
|
|
53
|
+
when "sync" then %w[on off]
|
|
54
|
+
else []
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.common_prefix(strings)
|
|
59
|
+
ref = strings.min_by(&:length) || ""
|
|
60
|
+
ref.length.downto(0) do |len|
|
|
61
|
+
pre = ref[0, len]
|
|
62
|
+
return pre if strings.all? { |s| s.start_with?(pre) }
|
|
63
|
+
end
|
|
64
|
+
""
|
|
65
|
+
end
|
|
66
|
+
|
|
7
67
|
def initialize(app)
|
|
8
68
|
@app = app
|
|
9
69
|
end
|
|
@@ -21,6 +81,7 @@ module Muxr
|
|
|
21
81
|
when "save" then @app.save_session
|
|
22
82
|
when "restore" then @app.restore_session
|
|
23
83
|
when "sessions", "ls" then @app.list_sessions
|
|
84
|
+
when "attach" then @app.open_pane_picker
|
|
24
85
|
when "quit", "q", "exit"
|
|
25
86
|
@app.quit
|
|
26
87
|
when "new", "c"
|
|
@@ -32,6 +93,14 @@ module Muxr
|
|
|
32
93
|
when "master" then @app.promote_master
|
|
33
94
|
when "help" then @app.show_help
|
|
34
95
|
when "detach" then @app.detach
|
|
96
|
+
when "silence" then @app.monitor_silence(args[0])
|
|
97
|
+
when "zoom" then @app.toggle_zoom
|
|
98
|
+
when "sync" then @app.set_sync(args[0])
|
|
99
|
+
when "reload" then @app.reload_config
|
|
100
|
+
when "capture" then @app.capture_focused(args.join(" "))
|
|
101
|
+
when "rename" then @app.rename_focused(args.join(" "))
|
|
102
|
+
when "ratio" then @app.set_master_ratio(args[0])
|
|
103
|
+
when "masters" then @app.set_master_count(args[0])
|
|
35
104
|
else
|
|
36
105
|
@app.flash("unknown command: #{cmd}")
|
|
37
106
|
end
|
data/lib/muxr/config.rb
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Muxr
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULT_PATH = File.join(Dir.home, ".muxr", "config.json").freeze
|
|
6
|
+
|
|
7
|
+
KEY_NAMES = {
|
|
8
|
+
"Tab" => "\t",
|
|
9
|
+
"Enter" => "\r",
|
|
10
|
+
"Space" => " ",
|
|
11
|
+
"Esc" => "\e"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
RESERVED_NORMAL_KEYS = (["i", ":"] + ("1".."9").to_a).freeze
|
|
15
|
+
RESERVED_PREFIX_KEYS = (["\e", ":"] + ("1".."9").to_a).freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :path, :errors, :layout, :scrollback, :master_ratio, :master_count,
|
|
18
|
+
:auto_spiral_min_cols, :auto_spiral_min_rows, :prefix,
|
|
19
|
+
:normal_keys, :prefix_keys
|
|
20
|
+
|
|
21
|
+
def self.path_from_env
|
|
22
|
+
path = ENV["MUXR_CONFIG"]
|
|
23
|
+
path.nil? || path.empty? ? DEFAULT_PATH : path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.load(path = path_from_env)
|
|
27
|
+
return new({}, path: path) unless File.exist?(path)
|
|
28
|
+
data = JSON.parse(File.read(path))
|
|
29
|
+
return new({}, path: path, errors: ["top level must be an object"]) unless data.is_a?(Hash)
|
|
30
|
+
new(data, path: path)
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
new({}, path: path, errors: ["not valid JSON: #{e.message.lines.first.strip}"])
|
|
33
|
+
rescue SystemCallError => e
|
|
34
|
+
new({}, path: path, errors: ["cannot read: #{e.message}"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.key_from_name(name)
|
|
38
|
+
return nil unless name.is_a?(String) && !name.empty?
|
|
39
|
+
return KEY_NAMES[name] if KEY_NAMES.key?(name)
|
|
40
|
+
return (name[2].downcase.ord & 0x1f).chr if name.match?(/\AC-[a-zA-Z]\z/)
|
|
41
|
+
name if name.length == 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.action_name(binding)
|
|
45
|
+
binding.is_a?(Array) ? binding.join(":") : binding.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.actions
|
|
49
|
+
@actions ||= (InputHandler::NORMAL_BINDINGS.values + InputHandler::PREFIX_BINDINGS.values)
|
|
50
|
+
.uniq.to_h { |binding| [action_name(binding), binding] }.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(data, path: DEFAULT_PATH, errors: [])
|
|
54
|
+
@path = path
|
|
55
|
+
@errors = errors.dup
|
|
56
|
+
@normal_keys = {}
|
|
57
|
+
@prefix_keys = {}
|
|
58
|
+
@prefix = InputHandler::PREFIX
|
|
59
|
+
parse(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def empty?
|
|
63
|
+
@layout.nil? && @scrollback.nil? && @master_ratio.nil? && @master_count.nil? &&
|
|
64
|
+
@auto_spiral_min_cols.nil? && @auto_spiral_min_rows.nil? &&
|
|
65
|
+
@prefix == InputHandler::PREFIX && @normal_keys.empty? && @prefix_keys.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
KNOWN = %w[layout scrollback master_ratio master_count auto_spiral_min prefix keys].freeze
|
|
71
|
+
|
|
72
|
+
def parse(data)
|
|
73
|
+
(data.keys - KNOWN).each { |k| @errors << "unknown setting #{k.inspect}" }
|
|
74
|
+
parse_layout(data["layout"]) if data.key?("layout")
|
|
75
|
+
@scrollback = positive_integer("scrollback", data["scrollback"]) if data.key?("scrollback")
|
|
76
|
+
parse_ratio(data["master_ratio"]) if data.key?("master_ratio")
|
|
77
|
+
@master_count = positive_integer("master_count", data["master_count"]) if data.key?("master_count")
|
|
78
|
+
parse_auto(data["auto_spiral_min"]) if data.key?("auto_spiral_min")
|
|
79
|
+
parse_prefix(data["prefix"]) if data.key?("prefix")
|
|
80
|
+
parse_keys(data["keys"]) if data.key?("keys")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_layout(value)
|
|
84
|
+
if value.is_a?(String) && LayoutManager::LAYOUTS.include?(value.to_sym)
|
|
85
|
+
@layout = value.to_sym
|
|
86
|
+
else
|
|
87
|
+
@errors << "layout: #{value.inspect} is not one of #{LayoutManager::LAYOUTS.join(", ")}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_ratio(value)
|
|
92
|
+
if value.is_a?(Numeric) && LayoutManager::RATIO_BOUNDS.cover?(value)
|
|
93
|
+
@master_ratio = value.to_f
|
|
94
|
+
else
|
|
95
|
+
@errors << "master_ratio: expected a number from #{LayoutManager::RATIO_BOUNDS.min} to #{LayoutManager::RATIO_BOUNDS.max}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_auto(value)
|
|
100
|
+
unless value.is_a?(Hash)
|
|
101
|
+
@errors << "auto_spiral_min: expected {\"cols\": 180, \"rows\": 30}"
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
@auto_spiral_min_cols = positive_integer("auto_spiral_min.cols", value["cols"]) if value.key?("cols")
|
|
105
|
+
@auto_spiral_min_rows = positive_integer("auto_spiral_min.rows", value["rows"]) if value.key?("rows")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_prefix(value)
|
|
109
|
+
key = self.class.key_from_name(value)
|
|
110
|
+
if key && key.ord < 0x20 && key != "\e"
|
|
111
|
+
@prefix = key
|
|
112
|
+
else
|
|
113
|
+
@errors << "prefix: #{value.inspect} must be a control key like \"C-b\""
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_keys(value)
|
|
118
|
+
unless value.is_a?(Hash)
|
|
119
|
+
@errors << "keys: expected {\"normal\": {...}, \"prefix\": {...}}"
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
(value.keys - %w[normal prefix]).each { |k| @errors << "keys: unknown mode #{k.inspect}" }
|
|
123
|
+
parse_key_table("normal", value["normal"], @normal_keys, RESERVED_NORMAL_KEYS) if value.key?("normal")
|
|
124
|
+
parse_key_table("prefix", value["prefix"], @prefix_keys, RESERVED_PREFIX_KEYS + [@prefix]) if value.key?("prefix")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_key_table(mode, table, into, reserved)
|
|
128
|
+
unless table.is_a?(Hash)
|
|
129
|
+
@errors << "keys.#{mode}: expected an object of key => action"
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
table.each do |name, action|
|
|
133
|
+
key = self.class.key_from_name(name)
|
|
134
|
+
if key.nil?
|
|
135
|
+
@errors << "keys.#{mode}: #{name.inspect} is not a key (use one character, C-x, Tab, Enter, Space or Esc)"
|
|
136
|
+
elsif reserved.include?(key)
|
|
137
|
+
@errors << "keys.#{mode}: #{name.inspect} is reserved"
|
|
138
|
+
elsif action.nil?
|
|
139
|
+
into[key] = nil
|
|
140
|
+
elsif action.is_a?(String) && action.start_with?(":") && action.length > 1
|
|
141
|
+
into[key] = [:run_command, action[1..]]
|
|
142
|
+
elsif self.class.actions.key?(action)
|
|
143
|
+
into[key] = self.class.actions[action]
|
|
144
|
+
else
|
|
145
|
+
@errors << "keys.#{mode}.#{name}: unknown action #{action.inspect}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def positive_integer(name, value)
|
|
151
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
152
|
+
@errors << "#{name}: expected a positive whole number"
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/muxr/control_server.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "base64"
|
|
1
2
|
require "json"
|
|
2
3
|
require "socket"
|
|
3
4
|
require "set"
|
|
@@ -33,6 +34,10 @@ module Muxr
|
|
|
33
34
|
INTERNAL_ERROR = -32603
|
|
34
35
|
|
|
35
36
|
READ_CHUNK = 64 * 1024
|
|
37
|
+
# How long a half-finished pane move may hold a pane's pty paused before we
|
|
38
|
+
# assume the receiver is never coming back and resume reading it.
|
|
39
|
+
HANDOFF_TIMEOUT = 10.0
|
|
40
|
+
FLUSH_TIMEOUT = 2.0
|
|
36
41
|
|
|
37
42
|
def initialize(app, socket_path)
|
|
38
43
|
@app = app
|
|
@@ -41,6 +46,9 @@ module Muxr
|
|
|
41
46
|
@clients = {} # io => { read_buffer:, write_buffer: }
|
|
42
47
|
@subscriptions = {} # io => Set[pane_id] (populated in step 3)
|
|
43
48
|
@pending_runs = [] # in-flight pane.run waiters (populated in step 3)
|
|
49
|
+
@mirrors = {} # pane_id => { io => [rows, cols] }
|
|
50
|
+
@mirror_geometry = {} # pane_id => [rows, cols] last announced to mirrors
|
|
51
|
+
@handoffs = {} # io => { pane:, deadline_at: } in-flight pane moves
|
|
44
52
|
@dispatcher = Dispatcher.new(app, self)
|
|
45
53
|
end
|
|
46
54
|
|
|
@@ -57,6 +65,9 @@ module Muxr
|
|
|
57
65
|
@clients.clear
|
|
58
66
|
@subscriptions.clear
|
|
59
67
|
@pending_runs.clear
|
|
68
|
+
@mirrors.clear
|
|
69
|
+
@handoffs.each_key { |io| abort_handoff(io) }
|
|
70
|
+
@mirror_geometry.clear
|
|
60
71
|
if @server
|
|
61
72
|
@server.close rescue nil
|
|
62
73
|
@server = nil
|
|
@@ -120,9 +131,172 @@ module Muxr
|
|
|
120
131
|
end
|
|
121
132
|
end
|
|
122
133
|
|
|
134
|
+
def mirrored?(pane_id)
|
|
135
|
+
@mirrors.key?(pane_id.to_s)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Relay one chunk of a pane's raw PTY output to every muxr server mirroring
|
|
139
|
+
# it. Base64 because NDJSON can't carry arbitrary binary; the receiving
|
|
140
|
+
# server feeds the decoded bytes straight into its replica emulator, so the
|
|
141
|
+
# mirror is a byte-exact copy rather than a text snapshot.
|
|
142
|
+
def on_pane_raw(pane_id, chunk)
|
|
143
|
+
viewers = @mirrors[pane_id.to_s]
|
|
144
|
+
return if viewers.nil? || viewers.empty?
|
|
145
|
+
encoded = Base64.strict_encode64(chunk)
|
|
146
|
+
viewers.each_key do |io|
|
|
147
|
+
emit_event(io, "event.pane.mirror", { "pane" => pane_id.to_s, "data" => encoded })
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_mirror(io, pane, rows, cols)
|
|
152
|
+
id = pane.id.to_s
|
|
153
|
+
(@mirrors[id] ||= {})[io] = [rows, cols]
|
|
154
|
+
apply_mirror_constraint(pane)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def update_mirror(io, pane, rows, cols)
|
|
158
|
+
viewers = @mirrors[pane.id.to_s]
|
|
159
|
+
return false unless viewers&.key?(io)
|
|
160
|
+
viewers[io] = [rows, cols]
|
|
161
|
+
apply_mirror_constraint(pane)
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def remove_mirror(io, pane_id)
|
|
166
|
+
viewers = @mirrors[pane_id.to_s]
|
|
167
|
+
return false unless viewers&.delete(io)
|
|
168
|
+
if viewers.empty?
|
|
169
|
+
@mirrors.delete(pane_id.to_s)
|
|
170
|
+
@mirror_geometry.delete(pane_id.to_s)
|
|
171
|
+
end
|
|
172
|
+
pane = pane_by_id(pane_id)
|
|
173
|
+
apply_mirror_constraint(pane) if pane
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The PTY has to fit inside every viewport looking at it — this server's own
|
|
178
|
+
# layout and each mirroring server's — so the owner runs it at the smallest.
|
|
179
|
+
# nil once the last mirror leaves, which hands the pane back to the local
|
|
180
|
+
# layout at full size.
|
|
181
|
+
def apply_mirror_constraint(pane)
|
|
182
|
+
viewers = @mirrors[pane.id.to_s]
|
|
183
|
+
if viewers.nil? || viewers.empty?
|
|
184
|
+
pane.mirror_size = nil
|
|
185
|
+
else
|
|
186
|
+
pane.mirror_size = [viewers.values.map(&:first).min, viewers.values.map(&:last).min]
|
|
187
|
+
end
|
|
188
|
+
pane.clamp_to_mirrors!
|
|
189
|
+
@app.invalidate
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def mirror_geometry_event(pane)
|
|
193
|
+
term = pane.terminal
|
|
194
|
+
{
|
|
195
|
+
"pane" => pane.id.to_s,
|
|
196
|
+
"rows" => term.rows,
|
|
197
|
+
"cols" => term.cols,
|
|
198
|
+
"snapshot" => Base64.strict_encode64(term.dump_ansi)
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A mirror's replica emulator must match the owner's geometry exactly — the
|
|
203
|
+
# relayed byte stream addresses absolute rows and columns. Whenever the
|
|
204
|
+
# owner's grid changes shape we push the new size along with a full repaint
|
|
205
|
+
# so the replica can resize and resync in one step.
|
|
206
|
+
def push_mirror_geometry
|
|
207
|
+
return if @mirrors.empty?
|
|
208
|
+
gone = []
|
|
209
|
+
@mirrors.each do |pane_id, viewers|
|
|
210
|
+
pane = pane_by_id(pane_id)
|
|
211
|
+
unless pane
|
|
212
|
+
viewers.each_key { |io| emit_event(io, "event.pane.gone", { "pane" => pane_id }) }
|
|
213
|
+
gone << pane_id
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
size = [pane.terminal.rows, pane.terminal.cols]
|
|
217
|
+
next if @mirror_geometry[pane_id] == size
|
|
218
|
+
@mirror_geometry[pane_id] = size
|
|
219
|
+
payload = mirror_geometry_event(pane)
|
|
220
|
+
viewers.each_key { |io| emit_event(io, "event.pane.geometry", payload) }
|
|
221
|
+
end
|
|
222
|
+
gone.each do |pane_id|
|
|
223
|
+
@mirrors.delete(pane_id)
|
|
224
|
+
@mirror_geometry.delete(pane_id)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
123
228
|
# Called once per IO.select tick. Resolves any pane.run waiters whose
|
|
124
229
|
# idle window has elapsed or whose timeout has fired.
|
|
230
|
+
# A pane whose fd has been sent to another server but whose move hasn't
|
|
231
|
+
# been committed yet. It stays in the layout and keeps its Terminal, but we
|
|
232
|
+
# stop reading its pty: two servers reading one master would split the byte
|
|
233
|
+
# stream between them.
|
|
234
|
+
def handing_off?(pane)
|
|
235
|
+
@handoffs.any? { |_io, h| h[:pane].equal?(pane) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def handoff_count
|
|
239
|
+
@handoffs.length
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Phase one of a move. The fd goes across first and the emulator state
|
|
243
|
+
# follows, because the receiver has to take the fd out of the socket with
|
|
244
|
+
# recvmsg before any plain read swallows the byte carrying it. Nothing is
|
|
245
|
+
# torn down here — until the far side commits, the pane is still ours and
|
|
246
|
+
# #abort_handoff puts it straight back to work.
|
|
247
|
+
def begin_handoff(io, request_id, pane)
|
|
248
|
+
state = pane.terminal.dump_transfer.merge(
|
|
249
|
+
"pane" => pane.id.to_s,
|
|
250
|
+
"pid" => pane.pid,
|
|
251
|
+
"cwd" => safe_pane_cwd(pane),
|
|
252
|
+
"name" => (pane.name if pane.respond_to?(:name)),
|
|
253
|
+
"session" => @app.session.name
|
|
254
|
+
)
|
|
255
|
+
respond_result(io, id: request_id, result: { "ready" => true })
|
|
256
|
+
return unless flush_blocking(io)
|
|
257
|
+
io.send_io(pane.io)
|
|
258
|
+
@handoffs[io] = { pane: pane, deadline_at: monotonic_now + HANDOFF_TIMEOUT }
|
|
259
|
+
write_json(io, { "id" => request_id, "result" => state })
|
|
260
|
+
@app.invalidate
|
|
261
|
+
true
|
|
262
|
+
rescue SystemCallError, IOError
|
|
263
|
+
abort_handoff(io)
|
|
264
|
+
false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Phase two: the receiver has the fd and a working pane, so let go for
|
|
268
|
+
# real. The pty is closed here and the child is detached rather than
|
|
269
|
+
# killed — it belongs to the other server now.
|
|
270
|
+
def commit_handoff(io)
|
|
271
|
+
handoff = @handoffs.delete(io)
|
|
272
|
+
return nil unless handoff
|
|
273
|
+
pane = handoff[:pane]
|
|
274
|
+
pane.relinquish!
|
|
275
|
+
@app.session.window.remove_pane(pane)
|
|
276
|
+
@app.renderer.reset_frame! if @app.respond_to?(:renderer) && @app.renderer
|
|
277
|
+
@app.invalidate
|
|
278
|
+
pane
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The move fell through (receiver died, gave up, or never answered). We
|
|
282
|
+
# never stopped owning the pane, so there is nothing to undo but the
|
|
283
|
+
# read pause.
|
|
284
|
+
def abort_handoff(io)
|
|
285
|
+
handoff = @handoffs.delete(io)
|
|
286
|
+
return false unless handoff
|
|
287
|
+
@app.invalidate
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def expire_handoffs
|
|
292
|
+
return if @handoffs.empty?
|
|
293
|
+
now = monotonic_now
|
|
294
|
+
@handoffs.select { |_io, h| now >= h[:deadline_at] }.each_key { |io| abort_handoff(io) }
|
|
295
|
+
end
|
|
296
|
+
|
|
125
297
|
def tick
|
|
298
|
+
expire_handoffs
|
|
299
|
+
push_mirror_geometry
|
|
126
300
|
return if @pending_runs.empty?
|
|
127
301
|
now = monotonic_now
|
|
128
302
|
completed = []
|
|
@@ -222,8 +396,12 @@ module Muxr
|
|
|
222
396
|
end
|
|
223
397
|
|
|
224
398
|
def drop_client(io)
|
|
399
|
+
abort_handoff(io)
|
|
225
400
|
@clients.delete(io)
|
|
226
401
|
@subscriptions.delete(io)
|
|
402
|
+
# A mirroring server that went away releases its claim on pane geometry:
|
|
403
|
+
# the pane snaps back to the owner's own layout size.
|
|
404
|
+
@mirrors.keys.each { |pane_id| remove_mirror(io, pane_id) }
|
|
227
405
|
# Any pane.run waiters owned by this client are silently abandoned —
|
|
228
406
|
# there's nobody to respond to.
|
|
229
407
|
@pending_runs.reject! { |r| r[:client_io] == io } unless @pending_runs.empty?
|
|
@@ -299,6 +477,28 @@ module Muxr
|
|
|
299
477
|
write_json(io, { "method" => method, "params" => params })
|
|
300
478
|
end
|
|
301
479
|
|
|
480
|
+
# Push everything queued for +io+ out now. Only used ahead of send_io,
|
|
481
|
+
# where the fd has to land after bytes the receiver has already been told
|
|
482
|
+
# to expect; everywhere else the event loop's buffered drain is right.
|
|
483
|
+
def flush_blocking(io)
|
|
484
|
+
state = @clients[io]
|
|
485
|
+
return false unless state
|
|
486
|
+
deadline = monotonic_now + FLUSH_TIMEOUT
|
|
487
|
+
until state[:write_buffer].empty?
|
|
488
|
+
return false if monotonic_now >= deadline
|
|
489
|
+
drain_client(io)
|
|
490
|
+
next if state[:write_buffer].empty?
|
|
491
|
+
IO.select(nil, [io], nil, deadline - monotonic_now)
|
|
492
|
+
end
|
|
493
|
+
true
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def safe_pane_cwd(pane)
|
|
497
|
+
pane.respond_to?(:cwd) ? pane.cwd : nil
|
|
498
|
+
rescue StandardError
|
|
499
|
+
nil
|
|
500
|
+
end
|
|
501
|
+
|
|
302
502
|
def write_json(io, hash)
|
|
303
503
|
return unless @clients.key?(io)
|
|
304
504
|
line = JSON.generate(hash) + "\n"
|
|
@@ -347,6 +547,13 @@ module Muxr
|
|
|
347
547
|
when "pane.run" then pane_run(params, client_io, request_id)
|
|
348
548
|
when "pane.subscribe" then pane_subscribe(params, client_io)
|
|
349
549
|
when "pane.unsubscribe" then pane_unsubscribe(params, client_io)
|
|
550
|
+
when "pane.mirror" then pane_mirror(params, client_io)
|
|
551
|
+
when "pane.mirror_resize" then pane_mirror_resize(params, client_io)
|
|
552
|
+
when "pane.unmirror" then pane_unmirror(params, client_io)
|
|
553
|
+
when "pane.redraw" then pane_redraw(params)
|
|
554
|
+
when "pane.move" then pane_move(params, client_io, request_id)
|
|
555
|
+
when "pane.move_commit" then pane_move_commit(client_io)
|
|
556
|
+
when "pane.move_abort" then pane_move_abort(client_io)
|
|
350
557
|
when "layout.set" then layout_set(params)
|
|
351
558
|
when "layout.cycle" then layout_cycle
|
|
352
559
|
when "drawer.toggle" then drawer_action(:toggle_drawer)
|
|
@@ -406,6 +613,11 @@ module Muxr
|
|
|
406
613
|
entry["cwd"] = safe_cwd(pane)
|
|
407
614
|
entry["rows"] = pane.terminal.rows
|
|
408
615
|
entry["cols"] = pane.terminal.cols
|
|
616
|
+
# Present on a pane borrowed from another session: "<session>:<id>"
|
|
617
|
+
# there. Reads and input work as usual and reach the real shell, but
|
|
618
|
+
# pane.kill only drops the mirror.
|
|
619
|
+
entry["origin"] = pane.origin if pane.respond_to?(:origin) && pane.origin
|
|
620
|
+
entry["name"] = pane.name if pane.respond_to?(:name) && pane.name
|
|
409
621
|
end
|
|
410
622
|
entry
|
|
411
623
|
end
|
|
@@ -536,6 +748,79 @@ module Muxr
|
|
|
536
748
|
{ "pane" => pane.id.to_s, "subscribed" => false, "was_subscribed" => removed }
|
|
537
749
|
end
|
|
538
750
|
|
|
751
|
+
# Hand a pane to another muxr server as a live mirror: it gets a full ANSI
|
|
752
|
+
# snapshot of the grid now and every subsequent raw PTY byte as it arrives,
|
|
753
|
+
# while the pane itself stays right here, running and usable. Input the
|
|
754
|
+
# mirror collects comes back through pane.send_input.
|
|
755
|
+
def pane_mirror(params, client_io)
|
|
756
|
+
pane = find_pane(params)
|
|
757
|
+
ensure_not_private!(pane, "pane.mirror")
|
|
758
|
+
rows = clamp_int(params["rows"], min: 1, max: 1000, default: pane.terminal.rows)
|
|
759
|
+
cols = clamp_int(params["cols"], min: 1, max: 1000, default: pane.terminal.cols)
|
|
760
|
+
@server.add_mirror(client_io, pane, rows, cols)
|
|
761
|
+
@app.invalidate
|
|
762
|
+
@server.mirror_geometry_event(pane).merge(
|
|
763
|
+
"session" => @app.session.name,
|
|
764
|
+
"cwd" => safe_cwd(pane)
|
|
765
|
+
)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def pane_mirror_resize(params, client_io)
|
|
769
|
+
pane = find_pane(params)
|
|
770
|
+
rows = clamp_int(params["rows"], min: 1, max: 1000, default: pane.terminal.rows)
|
|
771
|
+
cols = clamp_int(params["cols"], min: 1, max: 1000, default: pane.terminal.cols)
|
|
772
|
+
unless @server.update_mirror(client_io, pane, rows, cols)
|
|
773
|
+
raise Error.new("pane.mirror_resize: not mirroring pane #{pane.id}")
|
|
774
|
+
end
|
|
775
|
+
{ "pane" => pane.id.to_s, "rows" => rows, "cols" => cols }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def pane_unmirror(params, client_io)
|
|
779
|
+
pane = find_pane(params)
|
|
780
|
+
removed = @server.remove_mirror(client_io, pane.id)
|
|
781
|
+
@app.invalidate
|
|
782
|
+
{ "pane" => pane.id.to_s, "mirrored" => false, "was_mirrored" => removed }
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Hand the pane over for good: the pty fd itself crosses the socket, so the
|
|
786
|
+
# shell keeps running with all its state and simply belongs to the other
|
|
787
|
+
# server afterwards. Deferred because the reply, the fd and the emulator
|
|
788
|
+
# state have to be interleaved in one exact order — see #begin_handoff.
|
|
789
|
+
def pane_move(params, client_io, request_id)
|
|
790
|
+
raise Error.new("pane.move: missing request id") unless request_id
|
|
791
|
+
pane = find_pane(params)
|
|
792
|
+
ensure_not_private!(pane, "pane.move")
|
|
793
|
+
if pane.respond_to?(:mirror?) && pane.mirror?
|
|
794
|
+
raise Error.new("pane.move: pane #{pane.id} is itself borrowed from #{pane.origin}; move it from there")
|
|
795
|
+
end
|
|
796
|
+
# Panes already promised to another server don't count as ours: two moves
|
|
797
|
+
# in flight at once must not be able to empty the session between them,
|
|
798
|
+
# which would shut the server down out from under the user.
|
|
799
|
+
if @app.session.window.panes.length - @server.handoff_count <= 1
|
|
800
|
+
raise Error.new("pane.move: pane #{pane.id} is the last pane in session #{@app.session.name}; moving it would end that session")
|
|
801
|
+
end
|
|
802
|
+
raise Error.new("pane.move: pane #{pane.id} is already being moved") if @server.handing_off?(pane)
|
|
803
|
+
@server.begin_handoff(client_io, request_id, pane)
|
|
804
|
+
:deferred
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def pane_move_commit(client_io)
|
|
808
|
+
pane = @server.commit_handoff(client_io)
|
|
809
|
+
raise Error.new("pane.move_commit: no move in flight") unless pane
|
|
810
|
+
{ "pane" => pane.id.to_s, "moved" => true }
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def pane_move_abort(client_io)
|
|
814
|
+
{ "aborted" => @server.abort_handoff(client_io) }
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def pane_redraw(params)
|
|
818
|
+
pane = find_pane(params)
|
|
819
|
+
ensure_not_private!(pane, "pane.redraw")
|
|
820
|
+
pane.request_redraw
|
|
821
|
+
{ "pane" => pane.id.to_s }
|
|
822
|
+
end
|
|
823
|
+
|
|
539
824
|
def layout_set(params)
|
|
540
825
|
name = params["layout"].to_s
|
|
541
826
|
sym = name.to_sym
|
|
@@ -602,7 +887,7 @@ module Muxr
|
|
|
602
887
|
end
|
|
603
888
|
[raw, wire]
|
|
604
889
|
elsif params[text_key].is_a?(String)
|
|
605
|
-
text = params[text_key]
|
|
890
|
+
text = decode_text(params[text_key], params["base64"])
|
|
606
891
|
[text.b, wrap_bracketed(text, bracketed)]
|
|
607
892
|
elsif required
|
|
608
893
|
raise Error.new("missing #{text_key} (or `keys`)")
|
|
@@ -611,6 +896,16 @@ module Muxr
|
|
|
611
896
|
end
|
|
612
897
|
end
|
|
613
898
|
|
|
899
|
+
# Keystrokes relayed from a mirroring server arrive base64-encoded: input
|
|
900
|
+
# bytes are not always valid UTF-8 (Alt- sequences, mouse reports) and JSON
|
|
901
|
+
# strings must be.
|
|
902
|
+
def decode_text(text, base64)
|
|
903
|
+
return text unless base64
|
|
904
|
+
Base64.strict_decode64(text)
|
|
905
|
+
rescue ArgumentError
|
|
906
|
+
raise Error.new("data: malformed base64")
|
|
907
|
+
end
|
|
908
|
+
|
|
614
909
|
def clamp_int(value, min:, max:, default:)
|
|
615
910
|
v = value.is_a?(Integer) ? value : default
|
|
616
911
|
v.clamp(min, max)
|
|
@@ -632,8 +927,11 @@ module Muxr
|
|
|
632
927
|
else
|
|
633
928
|
id = id_or_slot.to_s
|
|
634
929
|
pane = win.panes.find { |p| p.id.to_s == id }
|
|
635
|
-
|
|
636
|
-
|
|
930
|
+
return pane if pane
|
|
931
|
+
named = win.panes.select { |p| p.respond_to?(:name) && p.name == id }
|
|
932
|
+
raise Error.new("pane: #{named.length} panes are named #{id.inspect}; use an id") if named.length > 1
|
|
933
|
+
raise Error.new("pane: no pane with id or name #{id.inspect}") if named.empty?
|
|
934
|
+
named.first
|
|
637
935
|
end
|
|
638
936
|
end
|
|
639
937
|
|