ruby_everywhere 0.4.0 → 0.5.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/README.md +86 -2
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/auth_handoff.rb +8 -2
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +44 -5
- data/lib/everywhere/commands/dev.rb +426 -59
- data/lib/everywhere/commands/install.rb +20 -0
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +298 -4
- data/lib/everywhere/console.rb +117 -0
- data/lib/everywhere/desktop_assets.rb +150 -0
- data/lib/everywhere/dock/footer.rb +150 -0
- data/lib/everywhere/dock/screen.rb +114 -0
- data/lib/everywhere/dock/state.rb +59 -0
- data/lib/everywhere/dock.rb +238 -0
- data/lib/everywhere/emulator.rb +2 -2
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +37 -0
- data/lib/everywhere/native_helper.rb +38 -5
- data/lib/everywhere/paths.rb +62 -11
- data/lib/everywhere/relay.rb +77 -0
- data/lib/everywhere/shellout.rb +90 -15
- data/lib/everywhere/task_pool.rb +123 -0
- data/lib/everywhere/ui.rb +54 -11
- data/lib/everywhere/version.rb +1 -1
- data/support/desktop/README.md +121 -0
- data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
- data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
- data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
- data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/desktop/src-tauri/src/extension_host.rs +53 -0
- data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
- data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
- data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +31 -17
- data/support/github/build.yml +0 -85
- data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
- /data/support/{shell → desktop}/splash/index.html +0 -0
- /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
- /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
- /data/support/{macos → release/macos}/entitlements.plist +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ui"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
class Dock
|
|
7
|
+
# Renders the pinned footer. PURE: state in, array of strings out. No IO, no
|
|
8
|
+
# clock, no terminal — every layout decision here is testable with plain
|
|
9
|
+
# string assertions.
|
|
10
|
+
#
|
|
11
|
+
# Composition rule: build every segment as PLAIN text, measure it, truncate,
|
|
12
|
+
# and only then paint. Measuring or cutting a string that already contains
|
|
13
|
+
# SGR codes is how you end up slicing through "\e[32" and corrupting the
|
|
14
|
+
# terminal, and it makes the width math quietly wrong under NO_COLOR.
|
|
15
|
+
module Footer
|
|
16
|
+
# Every state gets its own glyph, so the footer stays fully legible with
|
|
17
|
+
# NO_COLOR — color is redundant reinforcement, never the only signal.
|
|
18
|
+
# A nil glyph means "use the spinner frame".
|
|
19
|
+
GLYPHS = {
|
|
20
|
+
idle: ["○", :gray],
|
|
21
|
+
booting: [nil, :cyan],
|
|
22
|
+
building: [nil, :cyan],
|
|
23
|
+
installing: [nil, :cyan],
|
|
24
|
+
running: ["●", :green],
|
|
25
|
+
failed: ["✗", :red],
|
|
26
|
+
stopped: ["!", :yellow]
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
GAP = " "
|
|
30
|
+
KEY_GAP = " · "
|
|
31
|
+
ELLIPSIS = "…"
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# targets: Array<State::Target>. elapsed: ->(target) { seconds }.
|
|
36
|
+
# keys: Array<[key, description]>, or nil to omit the keymap row.
|
|
37
|
+
def render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true)
|
|
38
|
+
cols = [cols.to_i, 1].max
|
|
39
|
+
lines = []
|
|
40
|
+
lines << UI.gray("─" * cols) if rule
|
|
41
|
+
lines << status_row(targets, cols, frame, elapsed)
|
|
42
|
+
lines << keys_row(keys, cols) if keys
|
|
43
|
+
lines
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- status row ----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Three levels of degradation, tried in order until one fits: everything;
|
|
49
|
+
# details only on what's actually working; then drop idle targets. A hard
|
|
50
|
+
# truncation is the last resort.
|
|
51
|
+
def status_row(targets, cols, frame, elapsed)
|
|
52
|
+
segments = targets.map { |t| segment(t, frame, elapsed) }
|
|
53
|
+
|
|
54
|
+
candidates = [
|
|
55
|
+
segments,
|
|
56
|
+
segments.map { |s| s[:busy] ? s : s.merge(detail: nil) },
|
|
57
|
+
segments.select { |s| s[:state] != :idle }
|
|
58
|
+
]
|
|
59
|
+
chosen = candidates.find { |set| plain_width(set) <= cols } || candidates.last
|
|
60
|
+
fit(chosen.map { |s| [plain(s), painted(s)] }, cols)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def segment(target, frame, elapsed)
|
|
64
|
+
glyph, color = GLYPHS.fetch(target.state, GLYPHS[:idle])
|
|
65
|
+
{ glyph: glyph || UI::FRAMES[frame % UI::FRAMES.size],
|
|
66
|
+
color: color,
|
|
67
|
+
label: target.label,
|
|
68
|
+
detail: detail_for(target, elapsed),
|
|
69
|
+
state: target.state,
|
|
70
|
+
busy: target.busy? }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Busy targets carry a running clock — the whole reason the dock exists is
|
|
74
|
+
# that a cold Gradle build looks identical to a hung one without it.
|
|
75
|
+
def detail_for(target, elapsed)
|
|
76
|
+
if target.busy?
|
|
77
|
+
seconds = elapsed&.call(target)
|
|
78
|
+
[target.state == :building ? nil : target.state.to_s,
|
|
79
|
+
(UI.elapsed(seconds) if seconds)].compact.join(" ")
|
|
80
|
+
else
|
|
81
|
+
target.detail
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def plain(seg)
|
|
86
|
+
[seg[:glyph], seg[:label], presence(seg[:detail])].compact.join(" ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def painted(seg)
|
|
90
|
+
tint = UI.method(seg[:color])
|
|
91
|
+
parts = [tint.call(seg[:glyph]), seg[:state] == :idle ? UI.gray(seg[:label]) : seg[:label]]
|
|
92
|
+
parts << UI.dim(seg[:detail]) if presence(seg[:detail])
|
|
93
|
+
parts.join(" ")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)
|
|
97
|
+
|
|
98
|
+
# --- keys row ------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# Named keys with dot separators, then named keys packed tighter, then
|
|
101
|
+
# bare letters. Measured on the plain text and painted only once a tier
|
|
102
|
+
# fits, so the width math can't be thrown off by SGR codes.
|
|
103
|
+
def keys_row(keys, cols)
|
|
104
|
+
tiers = [
|
|
105
|
+
[:named, KEY_GAP],
|
|
106
|
+
[:named, " "],
|
|
107
|
+
[:bare, " "]
|
|
108
|
+
]
|
|
109
|
+
tiers.each do |style, gap|
|
|
110
|
+
plain = "⌨ #{keys.map { |key, desc| plain_key(key, desc, style) }.join(gap)}"
|
|
111
|
+
next unless plain.length <= cols
|
|
112
|
+
|
|
113
|
+
painted = keys.map { |key, desc| paint_key(key, desc, style) }.join(gap == KEY_GAP ? UI.gray(gap) : gap)
|
|
114
|
+
return "#{UI.cyan("⌨")} #{painted}"
|
|
115
|
+
end
|
|
116
|
+
""
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plain_key(key, desc, style) = style == :bare ? key : "#{key} #{desc}"
|
|
120
|
+
|
|
121
|
+
def paint_key(key, desc, style)
|
|
122
|
+
style == :bare ? UI.bold(key) : "#{UI.bold(key)} #{UI.dim(desc)}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# --- fitting -------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
# pairs: [[plain, painted], …]. Emit painted segments while they fit,
|
|
128
|
+
# measuring only the plain text; cut the first one that doesn't.
|
|
129
|
+
def fit(pairs, cols)
|
|
130
|
+
out = +""
|
|
131
|
+
width = 0
|
|
132
|
+
pairs.each_with_index do |(text, colored), index|
|
|
133
|
+
gap = index.zero? ? "" : GAP
|
|
134
|
+
if width + gap.length + text.length <= cols
|
|
135
|
+
out << gap << colored
|
|
136
|
+
width += gap.length + text.length
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
room = cols - width - gap.length - ELLIPSIS.length
|
|
141
|
+
out << gap << text[0, room] << ELLIPSIS if room.positive?
|
|
142
|
+
break
|
|
143
|
+
end
|
|
144
|
+
out
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def presence(str) = str.nil? || str.empty? ? nil : str
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Dock
|
|
5
|
+
# Emits the escape sequences that keep the footer pinned to the bottom.
|
|
6
|
+
#
|
|
7
|
+
# THE MECHANIC, and why it isn't the obvious one:
|
|
8
|
+
#
|
|
9
|
+
# The tempting approach is DECSTBM — set a scroll region (ESC[1;Nr) that
|
|
10
|
+
# excludes the last few rows and let the terminal itself keep output off
|
|
11
|
+
# them. It works, and it even confines child processes writing straight to
|
|
12
|
+
# the fd. It was rejected for two reasons:
|
|
13
|
+
#
|
|
14
|
+
# 1. It destroys scrollback. tmux only copies scrolled-out rows into its
|
|
15
|
+
# history when the scroll region is the full screen
|
|
16
|
+
# (grid_view_scroll_region_up checks rupper == 0 && rlower == sy - 1);
|
|
17
|
+
# screen and, as far as we can tell, iTerm2 behave the same way. Losing
|
|
18
|
+
# the ability to scroll up and read a Rails backtrace is a far worse
|
|
19
|
+
# regression than the one the dock is fixing.
|
|
20
|
+
# 2. Its failure mode is terrible. If the CLI is SIGKILLed before it can
|
|
21
|
+
# emit ESC[r, the user's terminal keeps a dead band at the bottom until
|
|
22
|
+
# they run `reset`.
|
|
23
|
+
#
|
|
24
|
+
# So instead we own every byte (the dev server and shells are relayed
|
|
25
|
+
# through us, not inherited) and repaint: erase the footer rows, write the
|
|
26
|
+
# new content — which scrolls normally, preserving scrollback everywhere —
|
|
27
|
+
# then draw the footer again. This is what indicatif, Ink, docker compose
|
|
28
|
+
# and cargo all do. Worst-case failure is one stale line on screen.
|
|
29
|
+
#
|
|
30
|
+
# Everything is emitted as a SINGLE write per frame. Byte interleaving
|
|
31
|
+
# between processes happens between write(2) calls, not inside one.
|
|
32
|
+
class Screen
|
|
33
|
+
ERASE_ROW = "\r\e[2K" # clear this row, cursor back to column 1
|
|
34
|
+
UP_ROW = "\e[1A\e[2K" # up one row and clear it
|
|
35
|
+
HIDE = "\e[?25l"
|
|
36
|
+
SHOW = "\e[?25h"
|
|
37
|
+
|
|
38
|
+
DEFAULT_SIZE = [24, 80].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(io:, size: nil)
|
|
41
|
+
@io = io
|
|
42
|
+
@size = size
|
|
43
|
+
@drawn = 0
|
|
44
|
+
@open = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def open
|
|
48
|
+
return if @open
|
|
49
|
+
|
|
50
|
+
@open = true
|
|
51
|
+
@sync = @io.sync
|
|
52
|
+
@io.sync = true
|
|
53
|
+
@io.write(HIDE)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# content: text to land above the footer (must end in a newline, or be
|
|
57
|
+
# empty). footer: the rows to pin.
|
|
58
|
+
def frame(content, footer)
|
|
59
|
+
return unless @open
|
|
60
|
+
|
|
61
|
+
out = +""
|
|
62
|
+
out << erase
|
|
63
|
+
out << content
|
|
64
|
+
out << footer.join("\n")
|
|
65
|
+
@drawn = footer.length
|
|
66
|
+
@io.write(out)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wipe the footer without redrawing it — for anything that has to write
|
|
70
|
+
# outside our frame discipline (stderr, an interactive child).
|
|
71
|
+
def clear
|
|
72
|
+
return unless @open && @drawn.positive?
|
|
73
|
+
|
|
74
|
+
@io.write(erase)
|
|
75
|
+
@drawn = 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close
|
|
79
|
+
return unless @open
|
|
80
|
+
|
|
81
|
+
@io.write("#{erase}#{SHOW}")
|
|
82
|
+
@drawn = 0
|
|
83
|
+
@open = false
|
|
84
|
+
@io.sync = @sync
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rows and columns, re-read on every resize. IO#winsize is a plain
|
|
88
|
+
# TIOCGWINSZ ioctl — it does not touch termios, so it is safe alongside
|
|
89
|
+
# the `stty -icanon -echo` the key loop sets.
|
|
90
|
+
def size
|
|
91
|
+
return @size if @size
|
|
92
|
+
|
|
93
|
+
require "io/console"
|
|
94
|
+
@io.winsize
|
|
95
|
+
rescue StandardError
|
|
96
|
+
env = [ENV["LINES"].to_i, ENV["COLUMNS"].to_i]
|
|
97
|
+
env.all?(&:positive?) ? env : DEFAULT_SIZE
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rows = size[0]
|
|
101
|
+
def cols = size[1]
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Cursor sits at the end of the last footer row; walk back up to column 1
|
|
106
|
+
# of the first, clearing as we go.
|
|
107
|
+
def erase
|
|
108
|
+
return "" if @drawn.zero?
|
|
109
|
+
|
|
110
|
+
ERASE_ROW + (UP_ROW * (@drawn - 1))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Dock
|
|
5
|
+
# What the dock knows about each thing `every dev` is running. Pure data
|
|
6
|
+
# with an injected clock, so elapsed times are deterministic under test.
|
|
7
|
+
class State
|
|
8
|
+
# Ordered so the dock reads left-to-right the way the session starts up.
|
|
9
|
+
ORDER = %i[web desktop ios android].freeze
|
|
10
|
+
|
|
11
|
+
LABELS = {
|
|
12
|
+
web: "web",
|
|
13
|
+
desktop: "desktop",
|
|
14
|
+
ios: "iOS",
|
|
15
|
+
android: "Android"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# States that are "in flight" — they get a spinner and a running clock.
|
|
19
|
+
BUSY = %i[booting building installing].freeze
|
|
20
|
+
|
|
21
|
+
Target = Struct.new(:key, :label, :state, :detail, :since, keyword_init: true) do
|
|
22
|
+
def busy? = BUSY.include?(state)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(keys, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
26
|
+
@clock = clock
|
|
27
|
+
@lock = Mutex.new
|
|
28
|
+
ordered = ORDER & keys
|
|
29
|
+
@targets = ordered.to_h do |key|
|
|
30
|
+
[key, Target.new(key: key, label: LABELS.fetch(key, key.to_s), state: :idle, since: clock.call)]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set(key, state, detail: nil)
|
|
35
|
+
@lock.synchronize do
|
|
36
|
+
target = @targets[key] or return nil
|
|
37
|
+
# Keep `since` when nothing actually changed, so a repeated
|
|
38
|
+
# set(:ios, :building) from a retry doesn't reset the elapsed clock.
|
|
39
|
+
target.since = @clock.call unless target.state == state
|
|
40
|
+
target.state = state
|
|
41
|
+
target.detail = detail
|
|
42
|
+
target
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detail(key, text)
|
|
47
|
+
@lock.synchronize { @targets[key]&.detail = text }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def [](key) = @lock.synchronize { @targets[key] }
|
|
51
|
+
|
|
52
|
+
def targets = @lock.synchronize { @targets.values.map(&:dup) }
|
|
53
|
+
|
|
54
|
+
def busy? = @lock.synchronize { @targets.each_value.any?(&:busy?) }
|
|
55
|
+
|
|
56
|
+
def elapsed(target) = @clock.call - target.since
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
require_relative "dock/state"
|
|
6
|
+
require_relative "dock/footer"
|
|
7
|
+
require_relative "dock/screen"
|
|
8
|
+
|
|
9
|
+
module Everywhere
|
|
10
|
+
# The pinned status bar at the bottom of `every dev`.
|
|
11
|
+
#
|
|
12
|
+
# The old key menu was printed once, before the loop, and was gone from the
|
|
13
|
+
# screen within seconds of the Rails server starting to log. The dock keeps
|
|
14
|
+
# the keymap — and what each target is actually doing — permanently visible,
|
|
15
|
+
# while the logs scroll normally above it.
|
|
16
|
+
#
|
|
17
|
+
# It installs itself as Console's sink, so every write in the process (CLI
|
|
18
|
+
# chrome, relayed server output, filtered build output) is framed by an erase
|
|
19
|
+
# and a repaint. See Dock::Screen for why it repaints rather than reserving
|
|
20
|
+
# rows with a scroll region.
|
|
21
|
+
class Dock
|
|
22
|
+
TICK = 0.2
|
|
23
|
+
|
|
24
|
+
KEYS = [
|
|
25
|
+
["d", "desktop"],
|
|
26
|
+
["i", "iOS"],
|
|
27
|
+
["a", "Android"],
|
|
28
|
+
["b", "browser"],
|
|
29
|
+
["l", "logs"],
|
|
30
|
+
["r", "restart"],
|
|
31
|
+
["?", "help"],
|
|
32
|
+
["q", "quit"]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
HELP = [
|
|
36
|
+
["d", "relaunch the desktop shell (opens it if it isn't running)"],
|
|
37
|
+
["i", "rebuild, reinstall and relaunch on the iOS Simulator"],
|
|
38
|
+
["a", "rebuild, reinstall and relaunch on the Android emulator"],
|
|
39
|
+
["b", "open the app in your default browser"],
|
|
40
|
+
["l", "start/stop streaming iOS Simulator logs"],
|
|
41
|
+
["r", "restart the dev server only"],
|
|
42
|
+
["?", "hide this help"],
|
|
43
|
+
["q", "quit (Ctrl-C works too)"]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# A dock needs room to be worth having, and a terminal that understands
|
|
47
|
+
# cursor movement. NO_COLOR is deliberately NOT a disqualifier: it governs
|
|
48
|
+
# SGR, not cursor control, and the footer's glyphs carry the meaning on
|
|
49
|
+
# their own.
|
|
50
|
+
MIN_ROWS = 12
|
|
51
|
+
MIN_COLS = 40
|
|
52
|
+
|
|
53
|
+
def self.open(targets, io: $stdout, keys: true, size: nil)
|
|
54
|
+
return Null.new(targets, keys: keys) unless drawable?(io, size)
|
|
55
|
+
|
|
56
|
+
new(targets, io: io, keys: keys, size: size).tap(&:install)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.drawable?(io, size)
|
|
60
|
+
return false if ENV["EVERY_NO_DOCK"]
|
|
61
|
+
return false unless io.respond_to?(:tty?) && io.tty?
|
|
62
|
+
return false if ENV["TERM"].to_s.empty? || ENV["TERM"] == "dumb"
|
|
63
|
+
|
|
64
|
+
rows, cols = size || Screen.new(io: io).size
|
|
65
|
+
rows >= MIN_ROWS && cols >= MIN_COLS
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(targets, io: $stdout, keys: true, size: nil)
|
|
69
|
+
@state = State.new(targets)
|
|
70
|
+
@screen = Screen.new(io: io, size: size)
|
|
71
|
+
@keys = keys
|
|
72
|
+
@help = false
|
|
73
|
+
@frame = 0
|
|
74
|
+
@dirty = true
|
|
75
|
+
@open = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :state
|
|
79
|
+
|
|
80
|
+
def install
|
|
81
|
+
@open = true
|
|
82
|
+
@screen.open
|
|
83
|
+
Console.dock = self
|
|
84
|
+
trap_resize
|
|
85
|
+
start_ticker
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- state --------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def set(key, state, detail: nil)
|
|
92
|
+
@state.set(key, state, detail: detail)
|
|
93
|
+
@dirty = true
|
|
94
|
+
refresh
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def detail(key, text)
|
|
98
|
+
@state.detail(key, text)
|
|
99
|
+
@dirty = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def toggle_help
|
|
103
|
+
@help = !@help
|
|
104
|
+
@dirty = true
|
|
105
|
+
refresh
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The dock draws the keymap permanently, so there is nothing to announce.
|
|
109
|
+
# Null overrides this to print the one-shot hint the old command used.
|
|
110
|
+
def announce_keys = nil
|
|
111
|
+
|
|
112
|
+
# --- drawing ------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# Called by Console, which already holds the lock. Content must end at
|
|
115
|
+
# column 1, or the erase math for the next frame is off by a row.
|
|
116
|
+
def emit(text)
|
|
117
|
+
body = text.end_with?("\n") ? text : "#{text}\n"
|
|
118
|
+
@screen.frame(body, footer_rows)
|
|
119
|
+
@dirty = false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def refresh
|
|
123
|
+
Console.synchronize do
|
|
124
|
+
next unless @open
|
|
125
|
+
|
|
126
|
+
@screen.frame("", footer_rows)
|
|
127
|
+
@dirty = false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Wipe the footer and leave it wiped — for stderr, or an interactive child
|
|
132
|
+
# that needs the screen to itself.
|
|
133
|
+
def clear = @screen.clear
|
|
134
|
+
|
|
135
|
+
def close
|
|
136
|
+
return unless @open
|
|
137
|
+
|
|
138
|
+
# Order matters: drop the flag so the ticker stops drawing, let it finish
|
|
139
|
+
# the frame it may be mid-way through, and only then unhook and restore.
|
|
140
|
+
@open = false
|
|
141
|
+
@ticker&.join(TICK * 2)
|
|
142
|
+
@ticker&.kill
|
|
143
|
+
Console.dock = nil
|
|
144
|
+
Console.synchronize { @screen.close }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One column short of the terminal on purpose. A row filled to the last
|
|
150
|
+
# column leaves the cursor in a pending-wrap state whose handling varies
|
|
151
|
+
# between terminals, and a footer row that wraps costs the erase math a row.
|
|
152
|
+
def footer_rows
|
|
153
|
+
lines = Footer.render(@state.targets,
|
|
154
|
+
cols: [@screen.cols - 1, 1].max,
|
|
155
|
+
keys: (@keys ? KEYS : nil),
|
|
156
|
+
frame: @frame,
|
|
157
|
+
elapsed: ->(target) { @state.elapsed(target) },
|
|
158
|
+
rule: @screen.rows >= 24)
|
|
159
|
+
@help ? lines + help_rows : lines
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def help_rows
|
|
163
|
+
width = HELP.map { |key, _| key.length }.max
|
|
164
|
+
HELP.map { |key, desc| " #{UI.bold(key.ljust(width))} #{UI.dim(desc)}" }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# An idle dev session should emit zero bytes per second, so only redraw when
|
|
168
|
+
# something changed or a spinner is actually turning.
|
|
169
|
+
def start_ticker
|
|
170
|
+
@ticker = Thread.new do
|
|
171
|
+
Thread.current.name = "dock"
|
|
172
|
+
Thread.current.report_on_exception = false
|
|
173
|
+
while @open
|
|
174
|
+
sleep TICK
|
|
175
|
+
next unless @open
|
|
176
|
+
|
|
177
|
+
# Screen#size re-reads the ioctl every frame, so a resize needs
|
|
178
|
+
# nothing more than a redraw.
|
|
179
|
+
if @resized
|
|
180
|
+
@resized = false
|
|
181
|
+
@dirty = true
|
|
182
|
+
end
|
|
183
|
+
busy = @state.busy?
|
|
184
|
+
@frame += 1 if busy
|
|
185
|
+
refresh if busy || @dirty
|
|
186
|
+
end
|
|
187
|
+
rescue StandardError
|
|
188
|
+
# A dock that can't draw must never be the reason a dev session dies.
|
|
189
|
+
@open = false
|
|
190
|
+
Console.dock = nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Trap handlers must not take a lock (Ruby raises ThreadError when the lock
|
|
195
|
+
# is held in trap context), so this only sets a flag; the ticker draws.
|
|
196
|
+
def trap_resize
|
|
197
|
+
Signal.trap("WINCH") { @resized = true }
|
|
198
|
+
rescue ArgumentError, StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The headless stand-in: same API, no escape sequences ever. Used for CI,
|
|
203
|
+
# pipes, `| tee`, dumb terminals and windows too small to spare the rows.
|
|
204
|
+
# It narrates transitions as ordinary lines so a piped log still reads.
|
|
205
|
+
#
|
|
206
|
+
# Note it never installs itself as Console's sink — output keeps going
|
|
207
|
+
# straight to $stdout, exactly as it did before the dock existed.
|
|
208
|
+
class Null
|
|
209
|
+
def initialize(targets, keys: true)
|
|
210
|
+
@state = State.new(targets)
|
|
211
|
+
@keys = keys
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
attr_reader :state
|
|
215
|
+
|
|
216
|
+
def install = self
|
|
217
|
+
|
|
218
|
+
def set(key, state, detail: nil)
|
|
219
|
+
target = @state.set(key, state, detail: detail) or return
|
|
220
|
+
UI.step("#{target.label} → #{[state, detail].compact.join(" ")}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def detail(_key, _text) = nil
|
|
224
|
+
def toggle_help = nil
|
|
225
|
+
def emit(text) = nil
|
|
226
|
+
def refresh = nil
|
|
227
|
+
def clear = nil
|
|
228
|
+
def close = nil
|
|
229
|
+
|
|
230
|
+
# The one-shot keymap the old command printed, kept for headless runs.
|
|
231
|
+
def announce_keys
|
|
232
|
+
return unless @keys
|
|
233
|
+
|
|
234
|
+
UI.note(KEYS.map { |key, desc| "#{UI.bold(key)} #{desc}" }.join(" · "), marker: "⌨", color: :cyan)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/everywhere/emulator.rb
CHANGED
|
@@ -372,7 +372,7 @@ module Everywhere
|
|
|
372
372
|
# go to /dev/null: they would otherwise land in the middle of the dev
|
|
373
373
|
# server's log, and nothing here reads them.
|
|
374
374
|
def spawn_emulator(argv)
|
|
375
|
-
pid =
|
|
375
|
+
pid = Process.spawn(Shellout.child_env, *argv, pgroup: true, out: File::NULL, err: File::NULL)
|
|
376
376
|
Process.detach(pid)
|
|
377
377
|
pid
|
|
378
378
|
end
|
|
@@ -384,7 +384,7 @@ module Everywhere
|
|
|
384
384
|
# Waiting on the child ourselves is the only way to put a ceiling on it, so
|
|
385
385
|
# the "never came up" path reaches a message a human can act on.
|
|
386
386
|
def bounded_wait(argv, timeout: BOOT_TIMEOUT)
|
|
387
|
-
pid =
|
|
387
|
+
pid = Process.spawn(Shellout.child_env, *argv, out: File::NULL, err: File::NULL)
|
|
388
388
|
deadline = Time.now + timeout
|
|
389
389
|
loop do
|
|
390
390
|
return true if Process.waitpid(pid, Process::WNOHANG)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# A failure that ends the current unit of work — not necessarily the process.
|
|
5
|
+
#
|
|
6
|
+
# It subclasses SystemExit deliberately. At the top level Ruby exits with
|
|
7
|
+
# #status and prints nothing (UI.die! has already written the ✗ line), so an
|
|
8
|
+
# uncaught Fatal behaves exactly like the Kernel#abort it replaces. Inside
|
|
9
|
+
# `every dev` a worker thread catches it instead and returns to the key menu,
|
|
10
|
+
# which is the whole point: a Swift typo must not take the Rails server down
|
|
11
|
+
# with it.
|
|
12
|
+
#
|
|
13
|
+
# CAUTION for anyone catching this in a thread: MRI enqueues a SystemExit that
|
|
14
|
+
# escapes a non-main thread onto the MAIN thread, which re-raises it wherever
|
|
15
|
+
# that thread happens to be. A worker must therefore `rescue Exception`, not
|
|
16
|
+
# `rescue Everywhere::Fatal` — see TaskPool.
|
|
17
|
+
class Fatal < SystemExit
|
|
18
|
+
def initialize(status = 1, message = nil) = super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Turns the raw byte stream off a pty master into whole lines.
|
|
5
|
+
#
|
|
6
|
+
# `every dev` relays the dev server and the desktop shell itself so it can tag
|
|
7
|
+
# each line with its source. That relay has to be line-oriented — a half-line
|
|
8
|
+
# written to the terminal would leave the cursor mid-row and the dock repaints
|
|
9
|
+
# from column 1 — but a pty hands over arbitrary chunks, so this buffers.
|
|
10
|
+
#
|
|
11
|
+
# Two pty-specific quirks it exists to absorb:
|
|
12
|
+
#
|
|
13
|
+
# * ONLCR. The slave's line discipline turns every LF into CRLF, so lines
|
|
14
|
+
# arrive as "text\r\n". The trailing CR is an artifact, not content.
|
|
15
|
+
# * In-place rewrites. cargo (and anything with a progress bar) redraws a
|
|
16
|
+
# row by writing CR and the new text without a newline. What the terminal
|
|
17
|
+
# would have shown is whatever followed the LAST CR, so that is what gets
|
|
18
|
+
# emitted — one settled line instead of thirty flickering ones.
|
|
19
|
+
#
|
|
20
|
+
# Pure: feed it strings, get lines back. No IO, no threads, no clock beyond
|
|
21
|
+
# the idle deadline the caller passes in.
|
|
22
|
+
class LinePump
|
|
23
|
+
# How long a partial line may sit unterminated before it is emitted anyway.
|
|
24
|
+
# Without this a progress bar that never writes a newline — or a prompt like
|
|
25
|
+
# "Overwrite? [Yn]" — would be invisible for as long as it mattered.
|
|
26
|
+
IDLE_FLUSH = 0.2
|
|
27
|
+
|
|
28
|
+
def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
29
|
+
# Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
|
|
30
|
+
# land in the middle of a multi-byte character, so buffering and splitting
|
|
31
|
+
# happen on bytes — UTF-8 is self-synchronizing, so a newline is never
|
|
32
|
+
# inside a character — and each settled line is transcoded on the way out.
|
|
33
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
34
|
+
@idle_flush = idle_flush
|
|
35
|
+
@clock = clock
|
|
36
|
+
@touched = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Feed a chunk; returns the complete lines it produced (without newlines).
|
|
40
|
+
def <<(chunk)
|
|
41
|
+
@buffer << chunk.b
|
|
42
|
+
@touched = @clock.call
|
|
43
|
+
lines = []
|
|
44
|
+
while (index = @buffer.index("\n"))
|
|
45
|
+
raw = @buffer.slice!(0, index + 1)
|
|
46
|
+
line = settle(raw.chomp("\n"))
|
|
47
|
+
lines << line if line
|
|
48
|
+
end
|
|
49
|
+
lines
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Emit a still-unterminated partial line once it has gone quiet. Call this
|
|
53
|
+
# from the relay loop whenever a read times out.
|
|
54
|
+
def flush_idle
|
|
55
|
+
return [] if @buffer.empty?
|
|
56
|
+
return [] if @touched && (@clock.call - @touched) < @idle_flush
|
|
57
|
+
|
|
58
|
+
lines = drain
|
|
59
|
+
@touched = @clock.call
|
|
60
|
+
lines
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Emit whatever is left, terminated or not. For end-of-stream.
|
|
64
|
+
def drain
|
|
65
|
+
return [] if @buffer.empty?
|
|
66
|
+
|
|
67
|
+
line = settle(@buffer)
|
|
68
|
+
@buffer = +""
|
|
69
|
+
line ? [line] : []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pending? = !@buffer.empty?
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# chomp the ONLCR artifact first, THEN collapse an in-place rewrite —
|
|
77
|
+
# order matters. Doing it the other way round reads "bar\r" as a rewrite to
|
|
78
|
+
# the empty string and swallows the line.
|
|
79
|
+
#
|
|
80
|
+
# scrub, not just force_encoding: a build tool can emit a stray byte that is
|
|
81
|
+
# not valid UTF-8, and a String that claims an encoding it doesn't satisfy
|
|
82
|
+
# raises the moment anything tries to concatenate a tag onto it.
|
|
83
|
+
def settle(raw)
|
|
84
|
+
line = raw.chomp("\r")
|
|
85
|
+
line = line[(line.rindex("\r") + 1)..] if line.include?("\r")
|
|
86
|
+
line.dup.force_encoding(Encoding::UTF_8).scrub
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|