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.
@@ -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