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,92 @@
1
+ require "pty"
2
+
3
+ module Muxr
4
+ # Owns a single pseudo-terminal pair plus the child shell process attached to
5
+ # the slave side. The parent side is exposed via #io / #read_nonblock / #write.
6
+ class PTYProcess
7
+ attr_reader :pid, :io, :rows, :cols
8
+
9
+ def initialize(command: nil, rows: 24, cols: 80, cwd: nil, env_overrides: {})
10
+ @rows = rows
11
+ @cols = cols
12
+ @exited = false
13
+
14
+ shell = command || ENV["SHELL"] || "/bin/sh"
15
+ env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
16
+ env["LINES"] = rows.to_s
17
+ env["COLUMNS"] = cols.to_s
18
+
19
+ chdir = (cwd && File.directory?(cwd)) ? cwd : Dir.pwd
20
+
21
+ @reader, @writer, @pid = PTY.spawn(env, shell, chdir: chdir)
22
+ @io = @reader
23
+ resize(rows, cols)
24
+ end
25
+
26
+ def write(data)
27
+ @writer.write(data)
28
+ @writer.flush
29
+ rescue Errno::EIO, IOError, Errno::EPIPE
30
+ @exited = true
31
+ end
32
+
33
+ def read_nonblock(max = 8192)
34
+ @reader.read_nonblock(max)
35
+ rescue IO::WaitReadable
36
+ nil
37
+ rescue EOFError, Errno::EIO
38
+ @exited = true
39
+ nil
40
+ end
41
+
42
+ def resize(rows, cols)
43
+ @rows = rows
44
+ @cols = cols
45
+ begin
46
+ @reader.winsize = [rows, cols, 0, 0]
47
+ rescue StandardError
48
+ # Some platforms reject zero pixel sizes; ignore.
49
+ end
50
+ end
51
+
52
+ def alive?
53
+ return false if @exited
54
+ Process.kill(0, @pid)
55
+ true
56
+ rescue Errno::ESRCH, Errno::EPERM
57
+ @exited = true
58
+ false
59
+ end
60
+
61
+ def reap
62
+ Process.waitpid(@pid, Process::WNOHANG)
63
+ rescue Errno::ECHILD
64
+ nil
65
+ end
66
+
67
+ def close
68
+ reap
69
+ Process.kill("TERM", @pid) if alive?
70
+ @reader.close unless @reader.closed?
71
+ @writer.close if @writer != @reader && !@writer.closed?
72
+ rescue Errno::ESRCH, Errno::EBADF, IOError
73
+ # already gone
74
+ end
75
+
76
+ # Best-effort cwd of the child process. Used to inherit cwd when opening
77
+ # the drawer or for session save/restore. Falls back to nil if the system
78
+ # doesn't expose the information.
79
+ def cwd
80
+ if File.directory?("/proc/#{@pid}")
81
+ File.readlink("/proc/#{@pid}/cwd")
82
+ else
83
+ # macOS / BSD fallback via lsof.
84
+ out = `lsof -a -p #{@pid} -d cwd -Fn 2>/dev/null`
85
+ line = out.lines.find { |l| l.start_with?("n/") }
86
+ line && line[1..].strip
87
+ end
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,468 @@
1
+ module Muxr
2
+ # The Renderer composites the Session into a single grid of cells and then
3
+ # emits ANSI escape sequences to write that grid to STDOUT. It compares the
4
+ # new frame against the previous one and only repositions/redraws cells
5
+ # whose contents changed, keeping output volume low between ticks.
6
+ class Renderer
7
+ BORDER_FOCUSED = [:c256, 11].freeze # yellow
8
+ BORDER_UNFOCUSED = [:c256, 8].freeze # grey
9
+ BORDER_DRAWER_FOCUS = [:c256, 13].freeze # magenta
10
+ BORDER_DRAWER_IDLE = [:c256, 5].freeze # dark magenta
11
+ STATUS_BG = [:c256, 236].freeze
12
+ STATUS_FG = [:c256, 252].freeze
13
+
14
+ HORIZONTAL = "─".freeze
15
+ VERTICAL = "│".freeze
16
+ TL = "┌".freeze
17
+ TR = "┐".freeze
18
+ BL = "└".freeze
19
+ BR = "┘".freeze
20
+
21
+ Cell = Struct.new(:char, :fg, :bg, :attrs) do
22
+ def ==(other)
23
+ other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
24
+ end
25
+ end
26
+
27
+ def initialize(out: $stdout)
28
+ @out = out
29
+ @prev = nil
30
+ @prev_w = 0
31
+ @prev_h = 0
32
+ end
33
+
34
+ def enter_alt_screen
35
+ @out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
36
+ @out.flush
37
+ @prev = nil
38
+ end
39
+
40
+ def exit_alt_screen
41
+ @out.write("\e[0m\e[?25h\e[?1049l")
42
+ @out.flush
43
+ end
44
+
45
+ def reset_frame!
46
+ @prev = nil
47
+ end
48
+
49
+ def render(session, input_state: :idle, command_buffer: "", message: nil, help: false)
50
+ w = session.width
51
+ h = session.height
52
+ return if w < 4 || h < 3
53
+
54
+ frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
55
+
56
+ compose_panes(frame, session)
57
+ compose_drawer(frame, session) if session.drawer&.visible?
58
+ compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
59
+ compose_help(frame, session) if help
60
+
61
+ emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
62
+ end
63
+
64
+ private
65
+
66
+ def compose_panes(frame, session)
67
+ win = session.window
68
+ content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
69
+ rects = LayoutManager.compute(
70
+ win.layout,
71
+ win.panes.length,
72
+ content_area,
73
+ focused_index: win.focused_index,
74
+ master_index: win.master_index
75
+ )
76
+
77
+ monocle = win.layout == :monocle
78
+
79
+ win.panes.each_with_index do |pane, i|
80
+ rect = rects[i]
81
+ pane.rect = rect
82
+ next unless rect && rect.w >= 3 && rect.h >= 3
83
+
84
+ inner_w = rect.w - 2
85
+ inner_h = rect.h - 2
86
+ pane.resize(inner_h, inner_w)
87
+
88
+ # In monocle every rect is identical, so drawing all panes would just
89
+ # stack them and let the last-in-array win. Only composite the focused
90
+ # pane; the others stay resized so their PTYs are ready when focus moves.
91
+ next if monocle && i != win.focused_index
92
+
93
+ focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
94
+ title = "##{i + 1}"
95
+ title += "/#{win.panes.length}" if monocle
96
+ title += " ★" if i == win.master_index
97
+ title += " (" + win.layout.to_s + ")" if i == win.focused_index
98
+ if pane.terminal.scrolled_back?
99
+ title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
100
+ end
101
+ draw_box(frame, rect,
102
+ border: focused ? BORDER_FOCUSED : BORDER_UNFOCUSED,
103
+ bold_border: focused,
104
+ title: title,
105
+ title_focused: focused)
106
+ copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
107
+ end
108
+ end
109
+
110
+ def compose_drawer(frame, session)
111
+ drawer = session.drawer
112
+ return unless drawer&.pane
113
+
114
+ w = session.width
115
+ h = session.height
116
+ dh = (h * 0.35).round.clamp(5, h - 2)
117
+ dy = h - 1 - dh
118
+ rect = LayoutManager::Rect.new(0, dy, w, dh)
119
+ drawer.pane.rect = rect
120
+ inner_w = rect.w - 2
121
+ inner_h = rect.h - 2
122
+ drawer.pane.resize(inner_h, inner_w)
123
+
124
+ # Wipe the area under the drawer so panes don't bleed through.
125
+ (rect.y...(rect.y + rect.h)).each do |y|
126
+ (rect.x...(rect.x + rect.w)).each do |x|
127
+ c = frame[y][x]
128
+ c.char = " "
129
+ c.fg = nil
130
+ c.bg = nil
131
+ c.attrs = 0
132
+ end
133
+ end
134
+
135
+ focused = session.focus_drawer
136
+ title = focused ? "Drawer" : "Drawer (hidden focus)"
137
+ term = drawer.pane.terminal
138
+ title += " [scrollback #{term.view_offset}/#{term.scrollback_size}]" if term.scrolled_back?
139
+ draw_box(frame, rect,
140
+ border: focused ? BORDER_DRAWER_FOCUS : BORDER_DRAWER_IDLE,
141
+ bold_border: true,
142
+ title: title,
143
+ title_focused: focused)
144
+ copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
145
+ end
146
+
147
+ def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
148
+ y = session.height - 1
149
+ w = session.width
150
+ win = session.window
151
+ drawer_state =
152
+ if session.drawer.nil? then "off"
153
+ elsif session.drawer.visible? then "shown"
154
+ else "hidden"
155
+ end
156
+
157
+ left = " [#{session.name}]"
158
+ left << " panes:#{win.panes.length}"
159
+ left << " layout:#{win.layout}"
160
+ focused_label =
161
+ if session.focus_drawer && session.drawer&.visible?
162
+ "drawer"
163
+ elsif win.panes.empty?
164
+ "-"
165
+ else
166
+ "##{win.focused_index + 1}"
167
+ end
168
+ left << " focused:#{focused_label}"
169
+ left << " drawer:#{drawer_state} "
170
+
171
+ right = " muxr ^a ? "
172
+
173
+ bar = (left + " " * w)[0, w - right.length] + right
174
+ bar = bar[0, w]
175
+
176
+ bar.each_char.with_index do |ch, x|
177
+ c = frame[y][x]
178
+ c.char = ch
179
+ c.fg = STATUS_FG
180
+ c.bg = STATUS_BG
181
+ c.attrs = 0
182
+ end
183
+
184
+ if input_state == :command
185
+ overlay = ":#{command_buffer}"
186
+ overlay = overlay[0, w]
187
+ overlay.each_char.with_index do |ch, x|
188
+ c = frame[y][x]
189
+ c.char = ch
190
+ c.fg = [:c256, 232]
191
+ c.bg = [:c256, 226]
192
+ c.attrs = Terminal::BOLD
193
+ end
194
+ (overlay.length...w).each do |x|
195
+ c = frame[y][x]
196
+ c.char = " "
197
+ c.fg = nil
198
+ c.bg = [:c256, 226]
199
+ c.attrs = 0
200
+ end
201
+ elsif input_state == :scrollback
202
+ overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
203
+ overlay = overlay[0, w]
204
+ overlay.each_char.with_index do |ch, x|
205
+ c = frame[y][x]
206
+ c.char = ch
207
+ c.fg = [:c256, 232]
208
+ c.bg = [:c256, 214]
209
+ c.attrs = Terminal::BOLD
210
+ end
211
+ (overlay.length...w).each do |x|
212
+ c = frame[y][x]
213
+ c.char = " "
214
+ c.fg = nil
215
+ c.bg = [:c256, 214]
216
+ c.attrs = 0
217
+ end
218
+ elsif input_state == :selection
219
+ mode = nil
220
+ focused = session.window.focused_pane
221
+ if focused && focused.terminal.selection_active?
222
+ mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
223
+ end
224
+ label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
225
+ overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
226
+ overlay = overlay[0, w]
227
+ overlay.each_char.with_index do |ch, x|
228
+ c = frame[y][x]
229
+ c.char = ch
230
+ c.fg = [:c256, 232]
231
+ c.bg = [:c256, 156]
232
+ c.attrs = Terminal::BOLD
233
+ end
234
+ (overlay.length...w).each do |x|
235
+ c = frame[y][x]
236
+ c.char = " "
237
+ c.fg = nil
238
+ c.bg = [:c256, 156]
239
+ c.attrs = 0
240
+ end
241
+ elsif message
242
+ msg = " #{message} "
243
+ start = [w - msg.length, 0].max
244
+ msg.each_char.with_index do |ch, i|
245
+ x = start + i
246
+ next unless x < w
247
+ c = frame[y][x]
248
+ c.char = ch
249
+ c.fg = [:c256, 15]
250
+ c.bg = [:c256, 28]
251
+ c.attrs = Terminal::BOLD
252
+ end
253
+ end
254
+ end
255
+
256
+ HELP_LINES = [
257
+ "muxr — keybindings",
258
+ "",
259
+ " C-a c new pane",
260
+ " C-a n / p next / prev pane",
261
+ " C-a a toggle to previously focused pane",
262
+ " C-a 1..9 jump to pane by number",
263
+ " C-a k close focused pane",
264
+ " C-a Tab cycle layout (tall → grid → monocle)",
265
+ " C-a Enter promote focused pane to master",
266
+ " C-a ~ toggle drawer",
267
+ " C-a [ enter scrollback (j/k d/u f/b g/G; v→cursor, q quits)",
268
+ " in cursor mode: v char-select, C-v block-select, y yank",
269
+ " C-a ] paste internal copy buffer",
270
+ " C-a d detach (server keeps running)",
271
+ " C-a q kill session (asks y/n)",
272
+ " C-a : command prompt",
273
+ " C-a ? toggle this help",
274
+ " C-a C-a send literal C-a",
275
+ "",
276
+ "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
277
+ " save, restore, sessions, quit, new, close, next, prev",
278
+ "",
279
+ "press any key to dismiss"
280
+ ].freeze
281
+
282
+ def compose_help(frame, session)
283
+ w = session.width
284
+ h = session.height
285
+ max_len = HELP_LINES.map(&:length).max
286
+ box_w = [max_len + 4, w - 4].min
287
+ box_h = [HELP_LINES.length + 2, h - 4].min
288
+ x = (w - box_w) / 2
289
+ y = (h - box_h) / 2
290
+ rect = LayoutManager::Rect.new(x, y, box_w, box_h)
291
+
292
+ (rect.y...(rect.y + rect.h)).each do |yy|
293
+ (rect.x...(rect.x + rect.w)).each do |xx|
294
+ c = frame[yy][xx]
295
+ c.char = " "
296
+ c.fg = [:c256, 252]
297
+ c.bg = [:c256, 236]
298
+ c.attrs = 0
299
+ end
300
+ end
301
+ draw_box(frame, rect, border: [:c256, 51], bold_border: true, title: "Help", title_focused: true)
302
+
303
+ HELP_LINES.first(box_h - 2).each_with_index do |line, i|
304
+ line[0, box_w - 4].chars.each_with_index do |ch, j|
305
+ c = frame[rect.y + 1 + i][rect.x + 2 + j]
306
+ c.char = ch
307
+ c.fg = [:c256, 252]
308
+ c.bg = [:c256, 236]
309
+ c.attrs = i == 0 ? Terminal::BOLD : 0
310
+ end
311
+ end
312
+ end
313
+
314
+ def draw_box(frame, rect, border:, bold_border:, title: nil, title_focused: false)
315
+ attrs = bold_border ? Terminal::BOLD : 0
316
+ x2 = rect.x + rect.w - 1
317
+ y2 = rect.y + rect.h - 1
318
+ (rect.x..x2).each do |x|
319
+ set_cell(frame, rect.y, x, HORIZONTAL, fg: border, attrs: attrs)
320
+ set_cell(frame, y2, x, HORIZONTAL, fg: border, attrs: attrs)
321
+ end
322
+ (rect.y..y2).each do |y|
323
+ set_cell(frame, y, rect.x, VERTICAL, fg: border, attrs: attrs)
324
+ set_cell(frame, y, x2, VERTICAL, fg: border, attrs: attrs)
325
+ end
326
+ set_cell(frame, rect.y, rect.x, TL, fg: border, attrs: attrs)
327
+ set_cell(frame, rect.y, x2, TR, fg: border, attrs: attrs)
328
+ set_cell(frame, y2, rect.x, BL, fg: border, attrs: attrs)
329
+ set_cell(frame, y2, x2, BR, fg: border, attrs: attrs)
330
+
331
+ if title && rect.w >= title.length + 4
332
+ text = " #{title} "
333
+ title_attrs = title_focused ? Terminal::BOLD : 0
334
+ text.each_char.with_index do |ch, i|
335
+ set_cell(frame, rect.y, rect.x + 2 + i, ch, fg: border, attrs: title_attrs)
336
+ end
337
+ end
338
+ end
339
+
340
+ def copy_terminal(frame, pane, dst_x, dst_y)
341
+ term = pane.terminal
342
+ rows = term.rows
343
+ cols = term.cols
344
+ selection = term.selection_active?
345
+ rows.times do |r|
346
+ fy = dst_y + r
347
+ next if fy < 0 || fy >= frame.length
348
+ cols.times do |c|
349
+ fx = dst_x + c
350
+ next if fx < 0 || fx >= frame[0].length
351
+ src = term.visible_cell(r, c)
352
+ dst = frame[fy][fx]
353
+ dst.char = src.char
354
+ dst.fg = src.fg
355
+ dst.bg = src.bg
356
+ dst.attrs = src.attrs
357
+ dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
358
+ end
359
+ end
360
+ end
361
+
362
+ def set_cell(frame, y, x, char, fg: nil, bg: nil, attrs: 0)
363
+ return if y < 0 || y >= frame.length
364
+ return if x < 0 || x >= frame[0].length
365
+ c = frame[y][x]
366
+ c.char = char
367
+ c.fg = fg
368
+ c.bg = bg
369
+ c.attrs = attrs
370
+ end
371
+
372
+ def emit_frame(frame, session, input_state:, command_buffer:)
373
+ out = String.new("\e[0m")
374
+ same_size = @prev && @prev_w == frame[0].length && @prev_h == frame.length
375
+ cur_fg = :unset
376
+ cur_bg = :unset
377
+ cur_attrs = :unset
378
+ last_y = nil
379
+ last_x = nil
380
+
381
+ frame.each_with_index do |row, y|
382
+ row.each_with_index do |cell, x|
383
+ if same_size && @prev[y][x] == cell
384
+ next
385
+ end
386
+ if last_y != y || last_x != x
387
+ out << "\e[#{y + 1};#{x + 1}H"
388
+ end
389
+ unless cell.fg == cur_fg && cell.bg == cur_bg && cell.attrs == cur_attrs
390
+ out << sgr(cell)
391
+ cur_fg = cell.fg
392
+ cur_bg = cell.bg
393
+ cur_attrs = cell.attrs
394
+ end
395
+ out << cell.char
396
+ last_y = y
397
+ last_x = x + cell.char.length
398
+ end
399
+ end
400
+ out << "\e[0m"
401
+ out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
402
+ @out.write(out)
403
+ @out.flush
404
+ @prev = frame.map { |row| row.map(&:dup) }
405
+ @prev_w = frame[0].length
406
+ @prev_h = frame.length
407
+ end
408
+
409
+ def cursor_position(session, input_state:, command_buffer:)
410
+ if input_state == :command
411
+ col = 1 + command_buffer.length + 1 # ':' + buffer
412
+ return "\e[#{session.height};#{col}H\e[?25h"
413
+ end
414
+
415
+ target =
416
+ if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
417
+ session.drawer.pane
418
+ else
419
+ session.window.focused_pane
420
+ end
421
+ return "\e[?25l" unless target&.rect
422
+
423
+ term = target.terminal
424
+ rect = target.rect
425
+ if input_state == :selection
426
+ pos = term.selection_cursor_visible
427
+ return "\e[?25l" unless pos
428
+ row = rect.y + 1 + pos[0] + 1
429
+ col = rect.x + 1 + pos[1] + 1
430
+ return "\e[#{row};#{col}H\e[?25h"
431
+ end
432
+ return "\e[?25l" if term.scrolled_back?
433
+ row = rect.y + 1 + term.cursor_row + 1
434
+ col = rect.x + 1 + term.cursor_col + 1
435
+ "\e[#{row};#{col}H\e[?25h"
436
+ end
437
+
438
+ def sgr(cell)
439
+ parts = ["0"]
440
+ attrs = cell.attrs.to_i
441
+ parts << "1" if (attrs & Terminal::BOLD) != 0
442
+ parts << "4" if (attrs & Terminal::UNDERLINE) != 0
443
+ parts << "7" if (attrs & Terminal::REVERSE) != 0
444
+ append_color(parts, cell.fg, true)
445
+ append_color(parts, cell.bg, false)
446
+ "\e[#{parts.join(';')}m"
447
+ end
448
+
449
+ def append_color(parts, color, fg)
450
+ return if color.nil?
451
+ case color
452
+ when Integer
453
+ if color < 8
454
+ parts << ((fg ? 30 : 40) + color).to_s
455
+ else
456
+ parts << ((fg ? 90 : 100) + (color - 8)).to_s
457
+ end
458
+ when Array
459
+ case color[0]
460
+ when :c256
461
+ parts << "#{fg ? 38 : 48};5;#{color[1]}"
462
+ when :rgb
463
+ parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,87 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Muxr
5
+ # A Session bundles the user's Window + Drawer plus a snapshot of the
6
+ # terminal dimensions. It is responsible for persisting/restoring its own
7
+ # state as JSON on disk (~/.muxr/sessions/<name>.json). Only the shape of
8
+ # the session (pane count, layout, cwds, drawer state) is persisted — the
9
+ # live shell history is not.
10
+ class Session
11
+ SESSIONS_DIR = File.join(Dir.home, ".muxr", "sessions").freeze
12
+
13
+ attr_accessor :width, :height, :window, :drawer, :focus_drawer
14
+ attr_reader :name
15
+
16
+ def initialize(name: "default", width: 80, height: 24)
17
+ @name = name
18
+ @width = width
19
+ @height = height
20
+ @window = Window.new(name: name)
21
+ @drawer = nil
22
+ @focus_drawer = false
23
+ end
24
+
25
+ def save_path
26
+ File.join(SESSIONS_DIR, "#{@name}.json")
27
+ end
28
+
29
+ def self.save_path_for(name)
30
+ File.join(SESSIONS_DIR, "#{name}.json")
31
+ end
32
+
33
+ def save
34
+ FileUtils.mkdir_p(SESSIONS_DIR)
35
+ File.write(save_path, JSON.pretty_generate(serialize))
36
+ save_path
37
+ end
38
+
39
+ def serialize
40
+ {
41
+ "name" => @name,
42
+ "width" => @width,
43
+ "height" => @height,
44
+ "layout" => @window.layout.to_s,
45
+ "focused_index" => @window.focused_index,
46
+ "master_index" => @window.master_index,
47
+ "focus_drawer" => @focus_drawer,
48
+ "panes" => @window.panes.map { |p| { "cwd" => safe_cwd(p) } },
49
+ "drawer" => serialize_drawer
50
+ }
51
+ end
52
+
53
+ def self.load(name)
54
+ path = save_path_for(name)
55
+ return nil unless File.exist?(path)
56
+ JSON.parse(File.read(path))
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+
61
+ def self.exists?(name)
62
+ File.exist?(save_path_for(name))
63
+ end
64
+
65
+ def self.list
66
+ return [] unless File.directory?(SESSIONS_DIR)
67
+ Dir.children(SESSIONS_DIR).filter_map do |entry|
68
+ next unless entry.end_with?(".json")
69
+ File.basename(entry, ".json")
70
+ end.sort
71
+ end
72
+
73
+ private
74
+
75
+ def safe_cwd(pane)
76
+ pane.respond_to?(:cwd) ? pane.cwd : nil
77
+ end
78
+
79
+ def serialize_drawer
80
+ return nil unless @drawer
81
+ {
82
+ "visible" => @drawer.visible?,
83
+ "cwd" => @drawer.cwd
84
+ }
85
+ end
86
+ end
87
+ end