tty-command-window 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 +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/lib/tty/command/window/ansi.rb +112 -0
- data/lib/tty/command/window/block.rb +184 -0
- data/lib/tty/command/window/child_session.rb +161 -0
- data/lib/tty/command/window/coordinator.rb +315 -0
- data/lib/tty/command/window/emulator.rb +613 -0
- data/lib/tty/command/window/input_router.rb +249 -0
- data/lib/tty/command/window/integration.rb +195 -0
- data/lib/tty/command/window/runner.rb +245 -0
- data/lib/tty/command/window/spinner.rb +30 -0
- data/lib/tty/command/window/trap_manager.rb +117 -0
- data/lib/tty/command/window/version.rb +9 -0
- data/lib/tty/command/window/window_options.rb +76 -0
- data/lib/tty/command/window.rb +110 -0
- data/lib/tty-command-window.rb +3 -0
- metadata +116 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# Handle to the child process spawned inside the PTY.
|
|
9
|
+
#
|
|
10
|
+
# Owns the process id and the PTY master file descriptor and
|
|
11
|
+
# exposes the operations the rest of the gem needs to perform
|
|
12
|
+
# against a live child: write user input, resize the terminal,
|
|
13
|
+
# forward a signal to its process group, reap the child, and
|
|
14
|
+
# close the master fd.
|
|
15
|
+
#
|
|
16
|
+
# **Orphan protection.** Every live session is tracked in a
|
|
17
|
+
# class-level set. A one-time +at_exit+ hook SIGKILLs the process
|
|
18
|
+
# group of any session still alive when the host process exits
|
|
19
|
+
# (follow-up review P1-8). This covers the case where the host
|
|
20
|
+
# dies before {Runner#cleanup} runs — SIGKILL to the host, an
|
|
21
|
+
# interpreter abort, or `exit!`.
|
|
22
|
+
class ChildSession
|
|
23
|
+
@live = {}.compare_by_identity
|
|
24
|
+
@live_mutex = Mutex.new
|
|
25
|
+
@at_exit_installed = false
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# @api private
|
|
29
|
+
attr_reader :live, :live_mutex
|
|
30
|
+
|
|
31
|
+
# Register the session as live and ensure the +at_exit+ orphan
|
|
32
|
+
# sweeper is installed.
|
|
33
|
+
def track(session)
|
|
34
|
+
install_at_exit_once
|
|
35
|
+
@live_mutex.synchronize { @live[session] = true }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Remove the session from the live-set (called on close).
|
|
39
|
+
def untrack(session)
|
|
40
|
+
@live_mutex.synchronize { @live.delete(session) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @api private for tests
|
|
44
|
+
def reap_orphans!
|
|
45
|
+
sessions = @live_mutex.synchronize { @live.keys.dup }
|
|
46
|
+
sessions.each(&:kill_group!)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def install_at_exit_once
|
|
52
|
+
return if @at_exit_installed
|
|
53
|
+
|
|
54
|
+
@at_exit_installed = true
|
|
55
|
+
at_exit { reap_orphans! }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param pid [Integer] child process id (session/pgroup leader)
|
|
60
|
+
# @param master [IO] PTY master fd
|
|
61
|
+
# @param rows [Integer] emulator/PTY row count (fixed for the lifetime of the session)
|
|
62
|
+
def initialize(pid:, master:, rows:)
|
|
63
|
+
@pid = pid
|
|
64
|
+
@master = master
|
|
65
|
+
@rows = rows
|
|
66
|
+
@closed = false
|
|
67
|
+
@reaped = false
|
|
68
|
+
self.class.track(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :rows
|
|
72
|
+
|
|
73
|
+
# Write user keystrokes / bytes to the child.
|
|
74
|
+
def write(data)
|
|
75
|
+
@master.write(data) if @master && !@master.closed?
|
|
76
|
+
rescue Errno::EIO, IOError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resize the PTY to the new column count. Rows are fixed.
|
|
81
|
+
def resize(cols)
|
|
82
|
+
@master&.winsize = [@rows, cols]
|
|
83
|
+
rescue Errno::EIO, Errno::EBADF, IOError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Forward a signal to the child's process group. No-op once the
|
|
88
|
+
# child has been reaped so a stale session cannot deliver to a
|
|
89
|
+
# recycled PID (belt-and-braces alongside the coordinator's
|
|
90
|
+
# unconditional @sessions.delete — see follow-up review P0-2).
|
|
91
|
+
def signal(name)
|
|
92
|
+
return if @reaped
|
|
93
|
+
return unless @pid
|
|
94
|
+
|
|
95
|
+
Process.kill(name, -@pid)
|
|
96
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Block until the child has output or +timeout+ seconds elapse.
|
|
101
|
+
#
|
|
102
|
+
# @return [IO, nil] truthy when readable, nil on timeout
|
|
103
|
+
def wait_readable(timeout)
|
|
104
|
+
@master.wait_readable(timeout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Read up to +length+ bytes of child output without blocking.
|
|
108
|
+
# Raises like IO#read_nonblock (IO::WaitReadable, EOFError,
|
|
109
|
+
# Errno::EIO on PTY hangup) — callers own the EOF handling.
|
|
110
|
+
def read_nonblock(length)
|
|
111
|
+
@master.read_nonblock(length)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Non-blocking waitpid poll. Marks the session as reaped on
|
|
115
|
+
# success so subsequent {#signal} calls are inert.
|
|
116
|
+
#
|
|
117
|
+
# @return [Process::Status, nil]
|
|
118
|
+
def try_wait
|
|
119
|
+
pid, status = Process.waitpid2(@pid, Process::WNOHANG)
|
|
120
|
+
return nil unless pid
|
|
121
|
+
|
|
122
|
+
@reaped = true
|
|
123
|
+
status
|
|
124
|
+
rescue Errno::ECHILD
|
|
125
|
+
@reaped = true
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Blocking waitpid — used when the caller has already decided
|
|
130
|
+
# to give up polling (e.g. after SIGKILL). Marks reaped.
|
|
131
|
+
def wait_blocking
|
|
132
|
+
_, status = Process.waitpid2(@pid)
|
|
133
|
+
@reaped = true
|
|
134
|
+
status
|
|
135
|
+
rescue Errno::ECHILD
|
|
136
|
+
@reaped = true
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Close the PTY master. Idempotent. Also removes the session
|
|
141
|
+
# from the orphan-tracking set.
|
|
142
|
+
def close
|
|
143
|
+
return if @closed
|
|
144
|
+
|
|
145
|
+
@closed = true
|
|
146
|
+
@master.close if @master && !@master.closed?
|
|
147
|
+
rescue IOError, Errno::EBADF
|
|
148
|
+
nil
|
|
149
|
+
ensure
|
|
150
|
+
self.class.untrack(self)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Send SIGKILL to the process group. Used by the +at_exit+
|
|
154
|
+
# orphan sweeper; safe to call on a reaped session (no-op).
|
|
155
|
+
def kill_group!
|
|
156
|
+
signal("KILL")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# Owns a region at the bottom of the terminal and paints the stack of
|
|
9
|
+
# active {Block}s into it from a dedicated render thread.
|
|
10
|
+
#
|
|
11
|
+
# All painted lines end with explicit "\r\n" so rendering stays correct
|
|
12
|
+
# even when the input router has put the terminal into raw mode (which
|
|
13
|
+
# disables output newline translation for the whole tty).
|
|
14
|
+
class Coordinator
|
|
15
|
+
FRAME_INTERVAL = 0.08
|
|
16
|
+
HIDE_CURSOR = "\e[?25l"
|
|
17
|
+
SHOW_CURSOR = "\e[?25h"
|
|
18
|
+
|
|
19
|
+
@registry = {}
|
|
20
|
+
@registry_mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Coordinator bound to the given output IO (one per IO).
|
|
24
|
+
#
|
|
25
|
+
# @param output [IO]
|
|
26
|
+
# @param width [Integer, nil] fixed width override
|
|
27
|
+
# @return [Coordinator]
|
|
28
|
+
def for(output, width: nil)
|
|
29
|
+
@registry_mutex.synchronize do
|
|
30
|
+
@registry[output] ||= new(output: output, width_override: width)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Emergency cleanup for at_exit: restore the cursor everywhere.
|
|
35
|
+
def restore_all
|
|
36
|
+
@registry_mutex.synchronize do
|
|
37
|
+
@registry.each_value(&:emergency_restore)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Trap-safe variant of {.restore_all}: iterates a snapshot of the
|
|
42
|
+
# registry without @registry_mutex, which the main thread may hold
|
|
43
|
+
# when the signal arrives (see review P0-5).
|
|
44
|
+
def emergency_restore_all
|
|
45
|
+
# .values (not each_value): iterating the live hash could raise
|
|
46
|
+
# if the main thread mutates the registry mid-trap.
|
|
47
|
+
@registry.values.each(&:emergency_restore) # rubocop:disable Style/HashEachMethods
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :output, :pastel
|
|
54
|
+
|
|
55
|
+
def initialize(output:, width_override: nil)
|
|
56
|
+
@output = output
|
|
57
|
+
@width_override = width_override
|
|
58
|
+
@blocks = []
|
|
59
|
+
@sessions = {} # block => ChildSession, for signal + PTY resize
|
|
60
|
+
@painted_height = 0
|
|
61
|
+
@active = false
|
|
62
|
+
@winch = false
|
|
63
|
+
@dirty = false
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
@spinner = Spinner.new
|
|
66
|
+
@pastel = Pastel.new(enabled: color?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Integer] current terminal width in columns
|
|
70
|
+
def width
|
|
71
|
+
return @width_override if @width_override
|
|
72
|
+
|
|
73
|
+
if @output.respond_to?(:winsize) && @output.respond_to?(:tty?) && @output.tty?
|
|
74
|
+
cols = @output.winsize[1]
|
|
75
|
+
return cols if cols.positive?
|
|
76
|
+
end
|
|
77
|
+
(ENV["COLUMNS"] || "80").to_i.clamp(20, 1000)
|
|
78
|
+
rescue Errno::ENOTTY, Errno::EBADF, IOError
|
|
79
|
+
80
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Add a block to the stack and start rendering if needed.
|
|
83
|
+
#
|
|
84
|
+
# @param block [Block]
|
|
85
|
+
# @param child_session [ChildSession, nil] used for signal forwarding
|
|
86
|
+
# and PTY winsize updates; may be nil for test doubles.
|
|
87
|
+
def register(block, child_session: nil)
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
activate unless @active
|
|
90
|
+
@blocks << block
|
|
91
|
+
@sessions[block] = child_session if child_session
|
|
92
|
+
paint
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Called by the runner when a block's command finished. Repaints and,
|
|
97
|
+
# for dump-on-failure blocks, replaces the block with its full
|
|
98
|
+
# history as permanent scrolled output. Drains the region when every
|
|
99
|
+
# block is done.
|
|
100
|
+
def finalize(block)
|
|
101
|
+
drained = false
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
next unless @active
|
|
104
|
+
|
|
105
|
+
if block.dump_on_finalize?
|
|
106
|
+
wipe_region
|
|
107
|
+
@blocks.delete(block)
|
|
108
|
+
write_permanent(block.dump_text(@pastel))
|
|
109
|
+
end
|
|
110
|
+
# Drop the session unconditionally: the child has been reaped by
|
|
111
|
+
# Runner and its PID is no longer safe to signal. Leaving stale
|
|
112
|
+
# entries in @sessions risks INT/TERM delivery to a recycled PID
|
|
113
|
+
# (see follow-up review P0-2).
|
|
114
|
+
@sessions.delete(block)
|
|
115
|
+
paint
|
|
116
|
+
if @blocks.all?(&:done?)
|
|
117
|
+
drain
|
|
118
|
+
drained = true
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
join_render_thread if drained
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Request a repaint on the next frame (cheap, lock-free).
|
|
125
|
+
def mark_dirty
|
|
126
|
+
@dirty = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Trap-context WINCH notification; actual work happens on the render
|
|
130
|
+
# thread.
|
|
131
|
+
def winch!
|
|
132
|
+
@winch = true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Boolean] whether colored output is enabled
|
|
136
|
+
def color?
|
|
137
|
+
return false if ENV.key?("NO_COLOR")
|
|
138
|
+
|
|
139
|
+
Window.assume_tty || (@output.respond_to?(:tty?) && @output.tty?)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# at_exit safety net: show the cursor if we died mid-paint.
|
|
143
|
+
def emergency_restore
|
|
144
|
+
@output.write(SHOW_CURSOR) if @active && @output.respond_to?(:write) && !@output.closed?
|
|
145
|
+
rescue IOError, Errno::EBADF, Errno::EPIPE
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def activate
|
|
152
|
+
@active = true
|
|
153
|
+
@painted_height = 0
|
|
154
|
+
@output.write("\r#{HIDE_CURSOR}")
|
|
155
|
+
install_traps
|
|
156
|
+
Window.register_at_exit
|
|
157
|
+
@render_thread = Thread.new { render_loop }
|
|
158
|
+
@render_thread.name = "tty-command-window-render"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Must be called while holding @mutex; the caller joins the render
|
|
162
|
+
# thread outside the lock afterwards.
|
|
163
|
+
def drain
|
|
164
|
+
@output.write("\r\n#{SHOW_CURSOR}")
|
|
165
|
+
@blocks.clear
|
|
166
|
+
@sessions.clear
|
|
167
|
+
@painted_height = 0
|
|
168
|
+
@active = false
|
|
169
|
+
restore_traps
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def join_render_thread
|
|
173
|
+
thread = @render_thread
|
|
174
|
+
@render_thread = nil
|
|
175
|
+
thread&.join(1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render_loop
|
|
179
|
+
while @active
|
|
180
|
+
sleep(FRAME_INTERVAL)
|
|
181
|
+
@mutex.synchronize do
|
|
182
|
+
next unless @active
|
|
183
|
+
|
|
184
|
+
handle_winch if @winch
|
|
185
|
+
next unless @dirty || @blocks.any?(&:running?)
|
|
186
|
+
|
|
187
|
+
@dirty = false
|
|
188
|
+
@spinner.tick
|
|
189
|
+
paint
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def handle_winch
|
|
195
|
+
@winch = false
|
|
196
|
+
wipe_region
|
|
197
|
+
new_width = width
|
|
198
|
+
@blocks.each do |block|
|
|
199
|
+
block.resize(new_width)
|
|
200
|
+
@sessions[block]&.resize(new_width)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Paint the whole stack. Must be called while holding @mutex.
|
|
205
|
+
def paint
|
|
206
|
+
lines = render_all_lines
|
|
207
|
+
height = lines.length
|
|
208
|
+
return if height.zero? && @painted_height.zero?
|
|
209
|
+
|
|
210
|
+
ensure_capacity(height)
|
|
211
|
+
buffer = +"\r"
|
|
212
|
+
up = @painted_height - 1
|
|
213
|
+
buffer << "\e[#{up}A" if up.positive?
|
|
214
|
+
lines.each_with_index do |line, index|
|
|
215
|
+
buffer << line << "\e[0m\e[K"
|
|
216
|
+
buffer << "\r\n" if index < height - 1
|
|
217
|
+
end
|
|
218
|
+
buffer << "\e[0J" if @painted_height > height
|
|
219
|
+
@painted_height = height
|
|
220
|
+
@output.write(buffer)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def render_all_lines
|
|
224
|
+
current_width = width
|
|
225
|
+
frame = @spinner.frame
|
|
226
|
+
@blocks.flat_map do |block|
|
|
227
|
+
block.render(width: current_width, pastel: @pastel, frame: frame)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Grow the painted region to +height+ lines by emitting newlines from
|
|
232
|
+
# the parked position (bottom line of the region, column 0).
|
|
233
|
+
def ensure_capacity(height)
|
|
234
|
+
return if height <= @painted_height
|
|
235
|
+
|
|
236
|
+
missing = height - [@painted_height, 1].max
|
|
237
|
+
@output.write("\r#{"\r\n" * missing}") if missing.positive?
|
|
238
|
+
@painted_height = height
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Erase the whole painted region, leaving the cursor at its former
|
|
242
|
+
# top line, column 0.
|
|
243
|
+
def wipe_region
|
|
244
|
+
return if @painted_height.zero?
|
|
245
|
+
|
|
246
|
+
up = @painted_height - 1
|
|
247
|
+
buffer = +"\r"
|
|
248
|
+
buffer << "\e[#{up}A" if up.positive?
|
|
249
|
+
buffer << "\e[0J"
|
|
250
|
+
@output.write(buffer)
|
|
251
|
+
@painted_height = 0
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Write permanent (scrolling) text at the current cursor position.
|
|
255
|
+
# Used for failure dumps. Cursor ends at column 0 of a fresh line.
|
|
256
|
+
def write_permanent(text)
|
|
257
|
+
body = text.split("\n", -1).join("\r\n")
|
|
258
|
+
body += "\r\n" unless body.end_with?("\r\n")
|
|
259
|
+
@output.write(body)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# --- signals -------------------------------------------------------
|
|
263
|
+
#
|
|
264
|
+
# Coordinators do not touch Signal.trap directly: the process-global
|
|
265
|
+
# TrapManager owns installation so concurrent coordinators (on
|
|
266
|
+
# distinct outputs) cannot clobber each other's handlers, and the
|
|
267
|
+
# host application's prior handler is captured exactly once and
|
|
268
|
+
# restored exactly once.
|
|
269
|
+
|
|
270
|
+
def install_traps
|
|
271
|
+
return unless @output.respond_to?(:tty?) && @output.tty?
|
|
272
|
+
|
|
273
|
+
@winch_sub = TrapManager.subscribe("WINCH", method(:on_winch_signal))
|
|
274
|
+
@int_sub = TrapManager.subscribe("INT", method(:on_process_signal))
|
|
275
|
+
@term_sub = TrapManager.subscribe("TERM", method(:on_process_signal))
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def restore_traps
|
|
279
|
+
TrapManager.unsubscribe("WINCH", @winch_sub) if @winch_sub
|
|
280
|
+
TrapManager.unsubscribe("INT", @int_sub) if @int_sub
|
|
281
|
+
TrapManager.unsubscribe("TERM", @term_sub) if @term_sub
|
|
282
|
+
@winch_sub = @int_sub = @term_sub = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Trap-context WINCH handler. Deferred to the render thread.
|
|
286
|
+
def on_winch_signal(_signal)
|
|
287
|
+
winch!
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Trap-context INT/TERM handler.
|
|
291
|
+
#
|
|
292
|
+
# Cannot acquire @mutex from trap context; instead we snapshot the
|
|
293
|
+
# sessions hash's values array. A concurrent register/finalize may
|
|
294
|
+
# miss the newly-added/removed session for this one signal — that
|
|
295
|
+
# is an accepted trade-off for INT/TERM forwarding.
|
|
296
|
+
def on_process_signal(signal)
|
|
297
|
+
sessions = begin
|
|
298
|
+
@sessions.values
|
|
299
|
+
rescue StandardError
|
|
300
|
+
[]
|
|
301
|
+
end
|
|
302
|
+
# ChildSession#signal already rescues Errno::ESRCH / EPERM;
|
|
303
|
+
# no outer rescue needed here.
|
|
304
|
+
sessions.each { |session| session.signal(signal) }
|
|
305
|
+
# Restore the terminal before TrapManager delegates to the prior
|
|
306
|
+
# handler, which may terminate the process (review P0-5): cursor
|
|
307
|
+
# visibility for every coordinator, termios for the input router
|
|
308
|
+
# even when this session is not interactive.
|
|
309
|
+
self.class.emergency_restore_all
|
|
310
|
+
InputRouter.emergency_restore
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|