muxr 0.1.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/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/bin/muxr +137 -0
- data/lib/muxr/application.rb +669 -0
- data/lib/muxr/client.rb +145 -0
- data/lib/muxr/command_dispatcher.rb +65 -0
- data/lib/muxr/drawer.rb +44 -0
- data/lib/muxr/input_handler.rb +218 -0
- data/lib/muxr/layout_manager.rb +91 -0
- data/lib/muxr/pane.rb +52 -0
- data/lib/muxr/protocol.rb +73 -0
- data/lib/muxr/pty_process.rb +92 -0
- data/lib/muxr/renderer.rb +468 -0
- data/lib/muxr/session.rb +87 -0
- data/lib/muxr/terminal.rb +817 -0
- data/lib/muxr/version.rb +3 -0
- data/lib/muxr/window.rb +110 -0
- data/lib/muxr.rb +18 -0
- data/muxr.gemspec +42 -0
- metadata +99 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# The Application is the muxr server. It owns the Session, panes, Renderer,
|
|
6
|
+
# and InputHandler, and listens on a Unix socket at
|
|
7
|
+
# ~/.muxr/sockets/<name>.sock for a Client to attach. Shells and other PTY
|
|
8
|
+
# processes survive client detach/reattach — only the listening socket and
|
|
9
|
+
# the one currently-attached client come and go.
|
|
10
|
+
#
|
|
11
|
+
# The Renderer's output sink is a small adapter that frames its bytes into
|
|
12
|
+
# OUTPUT messages on the attached client; when no client is attached the
|
|
13
|
+
# bytes are silently dropped (we also skip the render entirely in that
|
|
14
|
+
# case). PTY data still gets drained even with no client, so the in-memory
|
|
15
|
+
# Terminal grids stay up to date and are repainted in full on the next
|
|
16
|
+
# attach via Renderer#reset_frame!.
|
|
17
|
+
class Application
|
|
18
|
+
SELECT_TIMEOUT = 0.05
|
|
19
|
+
SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
|
|
20
|
+
DEFAULT_WIDTH = 80
|
|
21
|
+
DEFAULT_HEIGHT = 24
|
|
22
|
+
|
|
23
|
+
attr_reader :session, :renderer, :input, :session_name
|
|
24
|
+
|
|
25
|
+
def self.socket_path_for(name)
|
|
26
|
+
File.join(SOCKETS_DIR, "#{name}.sock")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(argv = [])
|
|
30
|
+
@argv = argv
|
|
31
|
+
@session_name = parse_session_name(argv)
|
|
32
|
+
@running = false
|
|
33
|
+
@needs_render = true
|
|
34
|
+
@message = nil
|
|
35
|
+
@message_expires = nil
|
|
36
|
+
@help_visible = false
|
|
37
|
+
@next_pane_id = 0
|
|
38
|
+
@current_client = nil
|
|
39
|
+
@listening_socket = nil
|
|
40
|
+
@socket_path = self.class.socket_path_for(@session_name)
|
|
41
|
+
@paste_buffer = +""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :paste_buffer
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
setup
|
|
48
|
+
begin
|
|
49
|
+
loop_forever
|
|
50
|
+
ensure
|
|
51
|
+
teardown
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ---------- public action API (called from InputHandler / CommandDispatcher) ----------
|
|
56
|
+
|
|
57
|
+
def send_to_focused(data)
|
|
58
|
+
target = focused_target
|
|
59
|
+
target&.write(data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def new_pane
|
|
63
|
+
cwd = focused_pane&.cwd
|
|
64
|
+
@session.window.add_pane(make_pane(cwd: cwd))
|
|
65
|
+
@session.focus_drawer = false
|
|
66
|
+
@session.window.focused_index = @session.window.panes.length - 1
|
|
67
|
+
invalidate
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def focus_next
|
|
71
|
+
return if @session.window.panes.empty?
|
|
72
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
73
|
+
@session.focus_drawer = false
|
|
74
|
+
else
|
|
75
|
+
@session.window.focus_next
|
|
76
|
+
end
|
|
77
|
+
invalidate
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def focus_prev
|
|
81
|
+
return if @session.window.panes.empty?
|
|
82
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
83
|
+
@session.focus_drawer = false
|
|
84
|
+
else
|
|
85
|
+
@session.window.focus_prev
|
|
86
|
+
end
|
|
87
|
+
invalidate
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def focus_last
|
|
91
|
+
return if @session.window.panes.empty?
|
|
92
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
93
|
+
@session.focus_drawer = false
|
|
94
|
+
else
|
|
95
|
+
@session.window.focus_last
|
|
96
|
+
end
|
|
97
|
+
invalidate
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def focus_pane_number(n)
|
|
101
|
+
return if @session.window.panes.empty?
|
|
102
|
+
idx = n - 1
|
|
103
|
+
return unless idx >= 0 && idx < @session.window.panes.length
|
|
104
|
+
@session.focus_drawer = false
|
|
105
|
+
@session.window.focus_index(idx)
|
|
106
|
+
invalidate
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def close_focused
|
|
110
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
111
|
+
hide_drawer
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
pane = focused_pane
|
|
115
|
+
return unless pane
|
|
116
|
+
@session.window.remove_pane(pane)
|
|
117
|
+
invalidate
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cycle_layout
|
|
121
|
+
@session.window.cycle_layout
|
|
122
|
+
flash("layout: #{@session.window.layout}")
|
|
123
|
+
invalidate
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def promote_master
|
|
127
|
+
@session.window.promote_to_master
|
|
128
|
+
invalidate
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def toggle_drawer
|
|
132
|
+
ensure_drawer
|
|
133
|
+
@session.drawer.toggle!
|
|
134
|
+
@session.focus_drawer = @session.drawer.visible?
|
|
135
|
+
@session.focus_drawer = false unless @session.drawer.visible?
|
|
136
|
+
renderer.reset_frame!
|
|
137
|
+
invalidate
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def show_drawer
|
|
141
|
+
ensure_drawer
|
|
142
|
+
@session.drawer.show!
|
|
143
|
+
@session.focus_drawer = true
|
|
144
|
+
renderer.reset_frame!
|
|
145
|
+
invalidate
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def hide_drawer
|
|
149
|
+
return unless @session.drawer&.visible?
|
|
150
|
+
@session.drawer.hide!
|
|
151
|
+
@session.focus_drawer = false
|
|
152
|
+
renderer.reset_frame!
|
|
153
|
+
invalidate
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def reset_drawer
|
|
157
|
+
if @session.drawer
|
|
158
|
+
@session.drawer.close
|
|
159
|
+
@session.drawer = nil
|
|
160
|
+
end
|
|
161
|
+
@session.focus_drawer = false
|
|
162
|
+
renderer.reset_frame!
|
|
163
|
+
flash("drawer reset")
|
|
164
|
+
invalidate
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def detach
|
|
168
|
+
flash("detached")
|
|
169
|
+
disconnect_client(reason: "detached")
|
|
170
|
+
# Server keeps running. Next `bin/muxr <name>` invocation will re-attach.
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Both Ctrl-a q and :quit funnel through here. We don't kill the server
|
|
174
|
+
# immediately — InputHandler enters a confirmation state and the user
|
|
175
|
+
# has to press 'y' to actually shut down (see :request_quit_confirmed).
|
|
176
|
+
def quit
|
|
177
|
+
request_quit
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def quit_immediate
|
|
181
|
+
request_quit
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def request_quit
|
|
185
|
+
return if @input.state == :confirm_quit
|
|
186
|
+
@input.enter_confirm_quit
|
|
187
|
+
flash("kill session? (y/n)")
|
|
188
|
+
invalidate
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def confirm_quit
|
|
192
|
+
shutdown_server
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def cancel_quit
|
|
196
|
+
@message = nil
|
|
197
|
+
@message_expires = nil
|
|
198
|
+
flash("cancelled")
|
|
199
|
+
invalidate
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def run_command(cmd_line)
|
|
203
|
+
CommandDispatcher.new(self).dispatch(cmd_line)
|
|
204
|
+
invalidate
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def show_help
|
|
208
|
+
@help_visible = true
|
|
209
|
+
@input.enter_help_mode
|
|
210
|
+
invalidate
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dismiss_help
|
|
214
|
+
@help_visible = false
|
|
215
|
+
invalidate
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def enter_scrollback
|
|
219
|
+
target = focused_target
|
|
220
|
+
return unless target
|
|
221
|
+
@input.enter_scrollback_mode
|
|
222
|
+
@renderer.reset_frame!
|
|
223
|
+
invalidate
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def exit_scrollback
|
|
227
|
+
target = focused_target
|
|
228
|
+
target&.terminal&.clear_selection
|
|
229
|
+
target&.terminal&.scroll_to_bottom
|
|
230
|
+
@renderer.reset_frame!
|
|
231
|
+
invalidate
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def scroll_focused(action)
|
|
235
|
+
target = focused_target
|
|
236
|
+
return unless target
|
|
237
|
+
term = target.terminal
|
|
238
|
+
rows = term.rows
|
|
239
|
+
case action
|
|
240
|
+
when :line_back then term.scroll_back(1)
|
|
241
|
+
when :line_forward then term.scroll_forward(1)
|
|
242
|
+
when :half_back then term.scroll_back([rows / 2, 1].max)
|
|
243
|
+
when :half_forward then term.scroll_forward([rows / 2, 1].max)
|
|
244
|
+
when :full_back then term.scroll_back([rows - 1, 1].max)
|
|
245
|
+
when :full_forward then term.scroll_forward([rows - 1, 1].max)
|
|
246
|
+
when :top then term.scroll_to_top
|
|
247
|
+
when :bottom then term.scroll_to_bottom
|
|
248
|
+
end
|
|
249
|
+
invalidate
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def enter_selection
|
|
253
|
+
target = focused_target
|
|
254
|
+
return unless target
|
|
255
|
+
# Vim-style: drop the user at a movable cursor with NO selection yet.
|
|
256
|
+
# They navigate with h/j/k/l, then press v (linear) or C-v (block) to
|
|
257
|
+
# anchor.
|
|
258
|
+
target.terminal.place_selection_cursor(0, 0)
|
|
259
|
+
@input.enter_selection_mode
|
|
260
|
+
@renderer.reset_frame!
|
|
261
|
+
invalidate
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def toggle_selection(mode)
|
|
265
|
+
target = focused_target
|
|
266
|
+
return unless target
|
|
267
|
+
term = target.terminal
|
|
268
|
+
if term.selection_active? && term.selection_mode == mode
|
|
269
|
+
# Same mode pressed again — drop the anchor, return to navigation.
|
|
270
|
+
term.clear_anchor!
|
|
271
|
+
else
|
|
272
|
+
# No anchor, or switching between linear/block — anchor at the
|
|
273
|
+
# current cursor in the requested mode (vim keeps the visual range
|
|
274
|
+
# when switching shapes, and we mirror that by not moving the
|
|
275
|
+
# cursor).
|
|
276
|
+
term.anchor_selection!(mode: mode)
|
|
277
|
+
end
|
|
278
|
+
invalidate
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def exit_selection(yank:)
|
|
282
|
+
target = focused_target
|
|
283
|
+
term = target&.terminal
|
|
284
|
+
if yank
|
|
285
|
+
# No anchor → no-op. User is still positioning; they can press v
|
|
286
|
+
# first, then yank. Esc/q is the way to exit from navigation.
|
|
287
|
+
return unless term&.selection_active?
|
|
288
|
+
text = term.extract_selection_text
|
|
289
|
+
unless text.empty?
|
|
290
|
+
@paste_buffer = text
|
|
291
|
+
spawn_pbcopy(text)
|
|
292
|
+
flash("yanked #{text.bytesize} bytes")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
term&.clear_selection
|
|
296
|
+
@input.enter_scrollback_mode
|
|
297
|
+
@renderer.reset_frame!
|
|
298
|
+
invalidate
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def move_selection(action)
|
|
302
|
+
target = focused_target
|
|
303
|
+
return unless target
|
|
304
|
+
term = target.terminal
|
|
305
|
+
rows = term.rows
|
|
306
|
+
cols = term.cols
|
|
307
|
+
case action
|
|
308
|
+
when :left then term.move_selection_cursor_by(0, -1)
|
|
309
|
+
when :right then term.move_selection_cursor_by(0, 1)
|
|
310
|
+
when :up then term.move_selection_cursor_by(-1, 0)
|
|
311
|
+
when :down then term.move_selection_cursor_by(1, 0)
|
|
312
|
+
when :half_up then term.move_selection_cursor_by(-[rows / 2, 1].max, 0)
|
|
313
|
+
when :half_down then term.move_selection_cursor_by([rows / 2, 1].max, 0)
|
|
314
|
+
when :full_up then term.move_selection_cursor_by(-[rows - 1, 1].max, 0)
|
|
315
|
+
when :full_down then term.move_selection_cursor_by([rows - 1, 1].max, 0)
|
|
316
|
+
when :line_start then term.selection_cursor_to_line_start
|
|
317
|
+
when :line_end then term.selection_cursor_to_line_end
|
|
318
|
+
when :top then term.selection_cursor_to_top
|
|
319
|
+
when :bottom then term.selection_cursor_to_bottom
|
|
320
|
+
end
|
|
321
|
+
invalidate
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def paste_from_buffer
|
|
325
|
+
return if @paste_buffer.nil? || @paste_buffer.empty?
|
|
326
|
+
target = focused_target
|
|
327
|
+
target&.write(@paste_buffer)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def flash(msg)
|
|
331
|
+
@message = msg
|
|
332
|
+
@message_expires = Time.now + 2.5
|
|
333
|
+
invalidate
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def invalidate
|
|
337
|
+
@needs_render = true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def save_session
|
|
341
|
+
path = @session.save
|
|
342
|
+
flash("saved: #{path}")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def restore_session
|
|
346
|
+
data = Session.load(@session_name)
|
|
347
|
+
if data
|
|
348
|
+
flash("session file: #{Session.save_path_for(@session_name)}")
|
|
349
|
+
else
|
|
350
|
+
flash("no saved session")
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def list_sessions
|
|
355
|
+
names = Session.list
|
|
356
|
+
if names.empty?
|
|
357
|
+
flash("no saved sessions")
|
|
358
|
+
else
|
|
359
|
+
marker = ->(n) { n == @session_name ? "*#{n}" : n }
|
|
360
|
+
flash("sessions: #{names.map(&marker).join(", ")}")
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Called by the FramedOutput adapter; ships one OUTPUT frame to the
|
|
365
|
+
# currently attached client. No-op when nobody is attached.
|
|
366
|
+
def deliver_output(bytes)
|
|
367
|
+
sock = @current_client
|
|
368
|
+
return unless sock
|
|
369
|
+
Protocol.write(sock, Protocol::OUTPUT, bytes)
|
|
370
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
371
|
+
drop_client_silently
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# ---------- internals ----------
|
|
375
|
+
|
|
376
|
+
private
|
|
377
|
+
|
|
378
|
+
def existing_server_alive?
|
|
379
|
+
s = UNIXSocket.new(@socket_path)
|
|
380
|
+
s.close
|
|
381
|
+
true
|
|
382
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
383
|
+
false
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def parse_session_name(argv)
|
|
387
|
+
idx = argv.index("-s") || argv.index("--session")
|
|
388
|
+
if idx && argv[idx + 1]
|
|
389
|
+
argv[idx + 1]
|
|
390
|
+
else
|
|
391
|
+
argv.find { |a| !a.start_with?("-") } || "default"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def focused_target
|
|
396
|
+
if @session.focus_drawer && @session.drawer&.visible? && @session.drawer.pane
|
|
397
|
+
@session.drawer.pane
|
|
398
|
+
else
|
|
399
|
+
focused_pane
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def focused_pane
|
|
404
|
+
@session.window.focused_pane
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def setup
|
|
408
|
+
FileUtils.mkdir_p(SOCKETS_DIR)
|
|
409
|
+
if File.exist?(@socket_path) && existing_server_alive?
|
|
410
|
+
raise "muxr server already running for session '#{@session_name}'"
|
|
411
|
+
end
|
|
412
|
+
File.unlink(@socket_path) if File.exist?(@socket_path)
|
|
413
|
+
@listening_socket = UNIXServer.new(@socket_path)
|
|
414
|
+
File.chmod(0o600, @socket_path) rescue nil
|
|
415
|
+
|
|
416
|
+
@session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
417
|
+
@renderer = Renderer.new(out: FramedOutput.new(self))
|
|
418
|
+
@input = InputHandler.new(self)
|
|
419
|
+
|
|
420
|
+
first_pane = make_pane
|
|
421
|
+
@session.window.add_pane(first_pane)
|
|
422
|
+
|
|
423
|
+
restore_panes_if_saved
|
|
424
|
+
|
|
425
|
+
@running = true
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def teardown
|
|
429
|
+
disconnect_client
|
|
430
|
+
if @listening_socket
|
|
431
|
+
@listening_socket.close rescue nil
|
|
432
|
+
end
|
|
433
|
+
if @socket_path && File.exist?(@socket_path)
|
|
434
|
+
File.unlink(@socket_path) rescue nil
|
|
435
|
+
end
|
|
436
|
+
@session&.window&.panes&.each(&:close)
|
|
437
|
+
@session&.drawer&.close
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def loop_forever
|
|
441
|
+
while @running
|
|
442
|
+
ready_ios = [@listening_socket]
|
|
443
|
+
ready_ios << @current_client if @current_client
|
|
444
|
+
@session.window.panes.each { |p| ready_ios << p.io if p.alive? }
|
|
445
|
+
if @session.drawer&.pane && @session.drawer.pane.alive?
|
|
446
|
+
ready_ios << @session.drawer.pane.io
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
450
|
+
ready, = IO.select(ready_ios, nil, nil, timeout)
|
|
451
|
+
|
|
452
|
+
if ready
|
|
453
|
+
ready.each do |io|
|
|
454
|
+
if io == @listening_socket
|
|
455
|
+
accept_client
|
|
456
|
+
elsif io == @current_client
|
|
457
|
+
consume_client_frame
|
|
458
|
+
else
|
|
459
|
+
consume_pane_io(io)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
prune_dead_panes
|
|
465
|
+
expire_message
|
|
466
|
+
|
|
467
|
+
if @session.window.panes.empty?
|
|
468
|
+
@running = false
|
|
469
|
+
break
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
if @current_client && @needs_render
|
|
473
|
+
render
|
|
474
|
+
@needs_render = false
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def accept_client
|
|
480
|
+
sock = @listening_socket.accept
|
|
481
|
+
if @current_client
|
|
482
|
+
# Single attached client at a time. Reject newcomers politely.
|
|
483
|
+
safe_protocol_write(sock, Protocol::BYE, "busy")
|
|
484
|
+
sock.close rescue nil
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
type, payload = Protocol.read(sock)
|
|
489
|
+
unless type == Protocol::HELLO
|
|
490
|
+
safe_protocol_write(sock, Protocol::BYE, "expected HELLO")
|
|
491
|
+
sock.close rescue nil
|
|
492
|
+
return
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
size = Protocol.decode_size(payload)
|
|
496
|
+
apply_size(*size) if size
|
|
497
|
+
|
|
498
|
+
@current_client = sock
|
|
499
|
+
@renderer.reset_frame!
|
|
500
|
+
invalidate
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def consume_client_frame
|
|
504
|
+
type, payload = Protocol.read(@current_client)
|
|
505
|
+
if type.nil?
|
|
506
|
+
drop_client_silently
|
|
507
|
+
return
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
case type
|
|
511
|
+
when Protocol::INPUT
|
|
512
|
+
@input.feed(payload)
|
|
513
|
+
invalidate
|
|
514
|
+
when Protocol::RESIZE
|
|
515
|
+
size = Protocol.decode_size(payload)
|
|
516
|
+
if size
|
|
517
|
+
apply_size(*size)
|
|
518
|
+
@renderer.reset_frame!
|
|
519
|
+
invalidate
|
|
520
|
+
end
|
|
521
|
+
when Protocol::BYE
|
|
522
|
+
drop_client_silently
|
|
523
|
+
else
|
|
524
|
+
# Unknown frame type — ignore quietly.
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def consume_pane_io(io)
|
|
529
|
+
pane = pane_for_io(io)
|
|
530
|
+
return unless pane
|
|
531
|
+
data = pane.read_from_pty
|
|
532
|
+
invalidate if data
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def pane_for_io(io)
|
|
536
|
+
pane = @session.window.panes.find { |p| p.io == io }
|
|
537
|
+
return pane if pane
|
|
538
|
+
return @session.drawer.pane if @session.drawer&.pane && @session.drawer.pane.io == io
|
|
539
|
+
nil
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def prune_dead_panes
|
|
543
|
+
dead = @session.window.panes.reject(&:alive?)
|
|
544
|
+
return if dead.empty?
|
|
545
|
+
dead.each { |p| @session.window.remove_pane(p) }
|
|
546
|
+
invalidate
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def expire_message
|
|
550
|
+
return unless @message_expires
|
|
551
|
+
if Time.now >= @message_expires
|
|
552
|
+
@message = nil
|
|
553
|
+
@message_expires = nil
|
|
554
|
+
invalidate
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def apply_size(rows, cols)
|
|
559
|
+
@session.width = cols
|
|
560
|
+
@session.height = rows
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def render
|
|
564
|
+
@renderer.render(
|
|
565
|
+
@session,
|
|
566
|
+
input_state: @input.state,
|
|
567
|
+
command_buffer: @input.command_buffer,
|
|
568
|
+
message: @message,
|
|
569
|
+
help: @help_visible
|
|
570
|
+
)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def disconnect_client(reason: nil)
|
|
574
|
+
return unless @current_client
|
|
575
|
+
safe_protocol_write(@current_client, Protocol::BYE, reason || "")
|
|
576
|
+
@current_client.close rescue nil
|
|
577
|
+
@current_client = nil
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def drop_client_silently
|
|
581
|
+
return unless @current_client
|
|
582
|
+
@current_client.close rescue nil
|
|
583
|
+
@current_client = nil
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def safe_protocol_write(io, type, payload = "")
|
|
587
|
+
Protocol.write(io, type, payload)
|
|
588
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
589
|
+
# peer gone; nothing to do.
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def shutdown_server
|
|
593
|
+
flash("bye")
|
|
594
|
+
disconnect_client(reason: "shutdown")
|
|
595
|
+
@running = false
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Fire-and-forget pipe to pbcopy. Runs on its own thread so even a slow
|
|
599
|
+
# macOS pbcopy doesn't stall the event loop. Silent when pbcopy is absent
|
|
600
|
+
# (Linux/headless) — selection still goes to the internal buffer.
|
|
601
|
+
def spawn_pbcopy(text)
|
|
602
|
+
Thread.new do
|
|
603
|
+
IO.popen("pbcopy", "w") { |io| io.write(text) }
|
|
604
|
+
rescue Errno::ENOENT, Errno::EPIPE, IOError, StandardError
|
|
605
|
+
# pbcopy unavailable or pipe broken — selection still lives in
|
|
606
|
+
# @paste_buffer.
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def make_pane(cwd: nil)
|
|
611
|
+
@next_pane_id += 1
|
|
612
|
+
Pane.new(id: @next_pane_id, rows: 24, cols: 80, cwd: cwd)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def ensure_drawer
|
|
616
|
+
return if @session.drawer
|
|
617
|
+
cwd = focused_pane&.cwd
|
|
618
|
+
pane = Pane.new(id: :drawer, rows: 10, cols: 80, cwd: cwd)
|
|
619
|
+
@session.drawer = Drawer.new(pane: pane, origin_cwd: cwd)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def restore_panes_if_saved
|
|
623
|
+
data = Session.load(@session_name)
|
|
624
|
+
return unless data
|
|
625
|
+
|
|
626
|
+
if data["layout"] && Window::LAYOUTS.include?(data["layout"].to_sym)
|
|
627
|
+
@session.window.set_layout(data["layout"].to_sym)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
panes_data = data["panes"] || []
|
|
631
|
+
panes_data[1..]&.each do |entry|
|
|
632
|
+
cwd = entry["cwd"]
|
|
633
|
+
@session.window.add_pane(make_pane(cwd: cwd))
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
if data["drawer"]
|
|
637
|
+
cwd = data["drawer"]["cwd"]
|
|
638
|
+
pane = Pane.new(id: :drawer, rows: 10, cols: 80, cwd: cwd)
|
|
639
|
+
drawer = Drawer.new(pane: pane, origin_cwd: cwd)
|
|
640
|
+
drawer.visible = !!data["drawer"]["visible"]
|
|
641
|
+
@session.drawer = drawer
|
|
642
|
+
@session.focus_drawer = drawer.visible?
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
@session.window.focused_index = (data["focused_index"] || 0).clamp(0, @session.window.panes.length - 1)
|
|
646
|
+
@session.window.master_index = (data["master_index"] || 0).clamp(0, @session.window.panes.length - 1)
|
|
647
|
+
flash("session restored")
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Renderer expects an IO-ish sink with #write and #flush. We frame every
|
|
651
|
+
# write as one OUTPUT message on the attached client; nobody attached =
|
|
652
|
+
# bytes go nowhere (and Application skips render entirely in that case,
|
|
653
|
+
# so this path is rarely exercised).
|
|
654
|
+
class FramedOutput
|
|
655
|
+
def initialize(app)
|
|
656
|
+
@app = app
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def write(bytes)
|
|
660
|
+
@app.deliver_output(bytes)
|
|
661
|
+
bytes.bytesize
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def flush
|
|
665
|
+
# Unix sockets do not need a Ruby-level flush.
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
end
|