ruby_everywhere 0.4.0 → 0.6.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 +99 -3
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/agents_guide.rb +57 -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 +93 -1
- 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/agents/AGENTS.md +90 -0
- data/support/agents/frameworks/hanami-views.md +26 -0
- data/support/agents/frameworks/hanami.md +10 -0
- data/support/agents/frameworks/rails-views.md +98 -0
- data/support/agents/frameworks/rails.md +16 -0
- data/support/agents/frameworks/sinatra-views.md +24 -0
- data/support/agents/frameworks/sinatra.md +9 -0
- data/support/agents/modes/local.md +12 -0
- data/support/agents/modes/remote.md +14 -0
- 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 +55 -22
- 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 "fileutils"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Compiles an app's native/desktop/assets/ tree into the flat assets/ folder
|
|
7
|
+
# the desktop shell reads at runtime — Resources/assets/ inside a packaged
|
|
8
|
+
# .app, or the source tree itself under `every dev` (NATIVE_ASSETS_DIR).
|
|
9
|
+
#
|
|
10
|
+
# Unlike iOS and Android there is no platform resource format to generate:
|
|
11
|
+
# macOS has no catalog for a Tauri bundle's loose resources, so "compiling"
|
|
12
|
+
# here means validating the names and copying. The filenames stay exactly as
|
|
13
|
+
# written, because the shell resolves variants itself (see assets_dir /
|
|
14
|
+
# asset_path in the shell's main.rs) — it is the one consumer, and it can read
|
|
15
|
+
# a suffix as cheaply as we can rewrite one.
|
|
16
|
+
#
|
|
17
|
+
# Filenames are the whole API, matching AssetCatalog: <name>[@2x|@3x][~dark].<ext>.
|
|
18
|
+
# Sub-directories organise the tree but do NOT namespace it — names are global,
|
|
19
|
+
# so assets/branding/logo.png is still "logo". That also means the destination
|
|
20
|
+
# can be flat, which is what keeps the Rust side a plain directory read.
|
|
21
|
+
#
|
|
22
|
+
# These assets are for the SHELL, not for your pages: window and tray icons,
|
|
23
|
+
# splash artwork, and anything extension Rust loads. Images your HTML
|
|
24
|
+
# references should keep coming from the asset pipeline, which already
|
|
25
|
+
# fingerprints and serves them.
|
|
26
|
+
module DesktopAssets
|
|
27
|
+
# What the shell can actually decode. Deliberately narrower than
|
|
28
|
+
# AssetCatalog's: these are handed to tauri::image::Image or read by
|
|
29
|
+
# extension Rust, and neither speaks PDF or SVG.
|
|
30
|
+
RASTER = %w[.png .jpg .jpeg].freeze
|
|
31
|
+
# Windows tray icons are conventionally .ico; it's passed through untouched
|
|
32
|
+
# for an extension that wants to hand one to the platform directly.
|
|
33
|
+
PASSTHROUGH = %w[.ico].freeze
|
|
34
|
+
EXTENSIONS = (RASTER + PASSTHROUGH).freeze
|
|
35
|
+
VECTOR = %w[.pdf .svg].freeze
|
|
36
|
+
|
|
37
|
+
# Names the shell gives meaning to on its own. Not reserved — providing one
|
|
38
|
+
# is exactly how you override a default — but worth naming here so the list
|
|
39
|
+
# has one home.
|
|
40
|
+
#
|
|
41
|
+
# tray the menu-bar / system-tray icon, drawn as-is
|
|
42
|
+
# tray-template same, but declared a macOS template image: black + alpha,
|
|
43
|
+
# recoloured by the system so it stays legible in a light
|
|
44
|
+
# menu bar, a dark one, and under a highlight. Wins over
|
|
45
|
+
# `tray` when both are present.
|
|
46
|
+
SHELL_NAMES = %w[tray tray-template].freeze
|
|
47
|
+
|
|
48
|
+
VALID_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Copy every image under `source` into `dest`. Returns [names, errors];
|
|
52
|
+
# nothing is written when errors is non-empty, so a broken assets/ tree
|
|
53
|
+
# can't half-stamp a bundle.
|
|
54
|
+
#
|
|
55
|
+
# `dest` is regenerated wholesale — this module owns that directory, and
|
|
56
|
+
# an asset deleted from the app repo has to stop shipping.
|
|
57
|
+
def write!(source, dest)
|
|
58
|
+
files, errors = plan(source)
|
|
59
|
+
return [[], errors] unless errors.empty?
|
|
60
|
+
|
|
61
|
+
FileUtils.rm_rf(dest)
|
|
62
|
+
return [[], []] if files.empty?
|
|
63
|
+
|
|
64
|
+
FileUtils.mkdir_p(dest)
|
|
65
|
+
files.each { |file| FileUtils.cp(file[:path], File.join(dest, File.basename(file[:path]))) }
|
|
66
|
+
|
|
67
|
+
[files.map { |file| file[:name] }.uniq.sort, []]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Errors are collected rather than raised so one `every build` reports
|
|
73
|
+
# every problem in the tree, not just the first.
|
|
74
|
+
def plan(source)
|
|
75
|
+
return [[], []] unless File.directory?(source)
|
|
76
|
+
|
|
77
|
+
errors = []
|
|
78
|
+
found = Dir.glob(File.join(source, "**", "*"))
|
|
79
|
+
.select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
|
|
80
|
+
.sort
|
|
81
|
+
images, other = found.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
|
|
82
|
+
|
|
83
|
+
other.each { |path| errors << unsupported_error(path, source) }
|
|
84
|
+
files = images.filter_map { |path| variant(path, source, errors) }
|
|
85
|
+
|
|
86
|
+
files.group_by { |file| file[:name] }.each do |name, group|
|
|
87
|
+
dirs = group.map { |file| file[:dir] }.uniq
|
|
88
|
+
if dirs.length > 1
|
|
89
|
+
errors << "#{name}: same asset name in #{dirs.map { |dir| label(dir) }.join(" and ")} — " \
|
|
90
|
+
"names are global (the shell reads one flat folder), so rename one"
|
|
91
|
+
end
|
|
92
|
+
group.group_by { |file| [file[:scale], file[:dark]] }.each_value do |clash|
|
|
93
|
+
next if clash.length < 2
|
|
94
|
+
|
|
95
|
+
errors << "#{name}: #{clash.map { |file| file[:relative] }.join(" and ")} both claim the same slot"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
[files, errors]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A vector gets its own message: "unsupported image type" reads like the
|
|
103
|
+
# file is broken, when the real answer is a one-line export.
|
|
104
|
+
def unsupported_error(path, source)
|
|
105
|
+
relative = relative(path, source)
|
|
106
|
+
if VECTOR.include?(File.extname(path).downcase)
|
|
107
|
+
"#{relative}: the desktop shell can't rasterize vectors — export a PNG " \
|
|
108
|
+
"(at @2x, so it stays sharp on a Retina display)"
|
|
109
|
+
else
|
|
110
|
+
"#{relative}: unsupported image type — use #{EXTENSIONS.join(", ")}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def variant(path, source, errors)
|
|
115
|
+
relative = relative(path, source)
|
|
116
|
+
name, scale, dark = parse(File.basename(path, File.extname(path)))
|
|
117
|
+
|
|
118
|
+
unless name.match?(VALID_NAME)
|
|
119
|
+
errors << "#{relative}: #{name.inspect} isn't a usable asset name — use letters, digits, _, - and ."
|
|
120
|
+
return nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{ name: name, dir: File.dirname(relative), scale: scale, dark: dark, path: path, relative: relative }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Trailing @2x/@3x and ~dark markers, in either order, off the stem.
|
|
127
|
+
# Identical to AssetCatalog#parse on purpose: one filename convention
|
|
128
|
+
# across all three platforms is the whole point of the suffix API.
|
|
129
|
+
def parse(base)
|
|
130
|
+
name = base.dup
|
|
131
|
+
scale = nil
|
|
132
|
+
dark = false
|
|
133
|
+
loop do
|
|
134
|
+
if name.sub!(/~dark\z/, "")
|
|
135
|
+
dark = true
|
|
136
|
+
elsif name.sub!(/@([23])x\z/, "")
|
|
137
|
+
scale = "#{Regexp.last_match(1)}x"
|
|
138
|
+
else
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
[name, scale, dark]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def label(dir) = dir == "." ? "assets/" : "assets/#{dir}/"
|
|
146
|
+
|
|
147
|
+
def relative(path, source) = path.delete_prefix("#{source}/")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -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
|