workspaces 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,461 @@
1
+ :root {
2
+ --bg: #0f1524;
3
+ --bg-elevated: #182035;
4
+ --border: #2f3a57;
5
+ --text: #e8edf9;
6
+ --muted: #9eaccd;
7
+ --accent: #4f8dff;
8
+ --success: #2fbf8f;
9
+ --danger: #ff6f74;
10
+ --warning: #f1bb53;
11
+ --radius: 12px;
12
+ --shadow: 0 10px 30px rgba(8, 12, 20, 0.35);
13
+ --space-1: 4px;
14
+ --space-2: 8px;
15
+ --space-3: 12px;
16
+ --space-4: 16px;
17
+ --space-5: 20px;
18
+ --space-6: 24px;
19
+ --space-8: 32px;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ [hidden] { display: none !important; }
27
+ input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
28
+ input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
29
+ summary { cursor: pointer; color: var(--muted); }
30
+ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
31
+ .workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block-start: var(--space-4); }
32
+ .setup-error { margin-block-start: var(--space-5); border-color: var(--danger); }
33
+ .setup-error h3 { color: var(--danger); margin-block-start: 0; }
34
+ .step-outcome { margin-inline-start: auto; font-size: 12px; }
35
+ .activity-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: workspace-pulse 1.5s infinite; }
36
+ @keyframes workspace-pulse { 50% { opacity: 0.3; } }
37
+ @media (prefers-reduced-motion: reduce) { .activity-dot { animation: none; } }
38
+ dd, h1, code { overflow-wrap: anywhere; }
39
+ dd { margin-inline-start: 0; margin-block-end: var(--space-3); }
40
+ #log { max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
41
+
42
+ body {
43
+ margin: 0;
44
+ min-block-size: 100dvb;
45
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
46
+ color: var(--text);
47
+ background: linear-gradient(180deg, #0c1220 0%, var(--bg) 60%, #0b1020 100%);
48
+ }
49
+
50
+ a {
51
+ color: inherit;
52
+ }
53
+
54
+ .dashboard {
55
+ inline-size: min(100% - var(--space-6), 960px);
56
+ margin-inline: auto;
57
+ padding-block: var(--space-8);
58
+ display: grid;
59
+ gap: var(--space-6);
60
+ }
61
+
62
+ .dashboard__header {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ justify-content: space-between;
66
+ align-items: end;
67
+ gap: var(--space-4);
68
+ }
69
+
70
+ .eyebrow {
71
+ margin: 0 0 var(--space-1);
72
+ color: var(--muted);
73
+ font-size: 12px;
74
+ letter-spacing: 0.08em;
75
+ text-transform: uppercase;
76
+ }
77
+
78
+ h1 {
79
+ margin: 0;
80
+ font-size: clamp(1.4rem, 2vw + 1rem, 2rem);
81
+ line-height: 1.2;
82
+ }
83
+
84
+ .header-message {
85
+ margin: var(--space-2) 0 0;
86
+ color: var(--muted);
87
+ }
88
+
89
+ .header-actions {
90
+ display: flex;
91
+ gap: var(--space-3);
92
+ }
93
+
94
+ .status-panel,
95
+ .controls-panel,
96
+ .error-panel,
97
+ .log-panel,
98
+ .noscript-note {
99
+ background: var(--bg-elevated);
100
+ border: 1px solid var(--border);
101
+ border-radius: var(--radius);
102
+ padding: var(--space-5);
103
+ box-shadow: var(--shadow);
104
+ }
105
+
106
+ .status-row {
107
+ display: grid;
108
+ gap: var(--space-4);
109
+ grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
110
+ }
111
+
112
+ .status-label {
113
+ margin: 0 0 var(--space-2);
114
+ color: var(--muted);
115
+ font-size: 13px;
116
+ }
117
+
118
+ .status-pill {
119
+ display: inline-flex;
120
+ align-items: center;
121
+ gap: var(--space-2);
122
+ margin: 0;
123
+ font-weight: 600;
124
+ }
125
+
126
+ .status-dot {
127
+ inline-size: 10px;
128
+ block-size: 10px;
129
+ border-radius: 999px;
130
+ background: var(--warning);
131
+ }
132
+
133
+ .status-pill[data-status='ready'] .status-dot {
134
+ background: var(--success);
135
+ }
136
+
137
+ .status-pill[data-status='error'] .status-dot,
138
+ .status-pill[data-status='interrupted'] .status-dot,
139
+ .status-pill[data-status='stopped'] .status-dot {
140
+ background: var(--danger);
141
+ }
142
+
143
+ .status-value {
144
+ margin: 0;
145
+ font-weight: 600;
146
+ }
147
+
148
+ .status-message {
149
+ margin: var(--space-4) 0 0;
150
+ color: var(--text);
151
+ }
152
+
153
+ .stepper {
154
+ margin: var(--space-5) 0 0;
155
+ padding: 0;
156
+ list-style: none;
157
+ display: grid;
158
+ gap: var(--space-2);
159
+ }
160
+
161
+ .step {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: var(--space-3);
165
+ color: var(--muted);
166
+ }
167
+
168
+ .step-bullet {
169
+ inline-size: 10px;
170
+ block-size: 10px;
171
+ border: 2px solid var(--muted);
172
+ border-radius: 999px;
173
+ }
174
+
175
+ .step[data-step-state='active'],
176
+ .step[data-step-state='complete'] {
177
+ color: var(--text);
178
+ }
179
+
180
+ .step[data-step-state='active'] .step-bullet {
181
+ border-color: var(--accent);
182
+ background: color-mix(in oklab, var(--accent) 25%, transparent);
183
+ }
184
+
185
+ .step[data-step-state='complete'] .step-bullet {
186
+ border-color: var(--success);
187
+ background: var(--success);
188
+ }
189
+
190
+ .step[data-step-state='failed'] {
191
+ color: color-mix(in oklab, var(--danger) 70%, var(--text));
192
+ }
193
+
194
+ .step[data-step-state='failed'] .step-bullet {
195
+ border-color: var(--danger);
196
+ background: color-mix(in oklab, var(--danger) 45%, transparent);
197
+ }
198
+
199
+ .step[data-step-state='unknown'] {
200
+ color: color-mix(in oklab, var(--muted) 80%, var(--text));
201
+ }
202
+
203
+ .step[data-step-state='unknown'] .step-bullet {
204
+ border-style: dashed;
205
+ background: transparent;
206
+ }
207
+
208
+ .env-panel {
209
+ display: grid;
210
+ gap: var(--space-4);
211
+ }
212
+
213
+ .env-fieldset {
214
+ border: 1px solid var(--border);
215
+ border-radius: 10px;
216
+ margin: 0;
217
+ padding: var(--space-4);
218
+ display: grid;
219
+ gap: var(--space-3);
220
+ }
221
+
222
+ .env-legend {
223
+ color: var(--muted);
224
+ font-size: 13px;
225
+ padding-inline: var(--space-2);
226
+ }
227
+
228
+ .env-existing-list,
229
+ .env-add-list {
230
+ list-style: none;
231
+ margin: 0;
232
+ padding: 0;
233
+ display: grid;
234
+ gap: var(--space-4);
235
+ }
236
+
237
+ .env-existing-item,
238
+ .env-add-item {
239
+ border: 1px solid var(--border);
240
+ border-radius: 10px;
241
+ padding: var(--space-3);
242
+ background: color-mix(in oklab, var(--bg) 65%, transparent);
243
+ display: grid;
244
+ gap: var(--space-3);
245
+ }
246
+
247
+ .env-existing-name,
248
+ .env-add-grid {
249
+ display: grid;
250
+ gap: var(--space-2);
251
+ }
252
+
253
+ .env-existing-actions {
254
+ display: flex;
255
+ flex-wrap: wrap;
256
+ gap: var(--space-4);
257
+ }
258
+
259
+ .env-inline-label,
260
+ .env-input-label {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: var(--space-2);
264
+ font-size: 14px;
265
+ }
266
+
267
+ .env-input-label {
268
+ display: block;
269
+ margin-block-end: var(--space-1);
270
+ }
271
+
272
+ .env-add-grid {
273
+ grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
274
+ }
275
+
276
+ .env-add-grid p {
277
+ margin: 0;
278
+ }
279
+
280
+ .env-row-actions {
281
+ display: flex;
282
+ justify-content: flex-end;
283
+ }
284
+
285
+ .button--icon {
286
+ min-inline-size: 2.25rem;
287
+ padding-inline: var(--space-2);
288
+ }
289
+
290
+ .env-actions {
291
+ display: flex;
292
+ flex-wrap: wrap;
293
+ gap: var(--space-3);
294
+ }
295
+
296
+ fieldset:disabled {
297
+ opacity: 0.7;
298
+ }
299
+
300
+ .controls-panel {
301
+ display: grid;
302
+ gap: var(--space-4);
303
+ align-items: start;
304
+ }
305
+
306
+ .button {
307
+ display: inline-flex;
308
+ justify-content: center;
309
+ align-items: center;
310
+ min-block-size: 40px;
311
+ padding-inline: var(--space-4);
312
+ border-radius: 10px;
313
+ border: 1px solid transparent;
314
+ text-decoration: none;
315
+ font-size: 14px;
316
+ font-weight: 600;
317
+ background: transparent;
318
+ color: var(--text);
319
+ cursor: pointer;
320
+ transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
321
+ }
322
+
323
+ .button:hover,
324
+ .button:focus-visible {
325
+ transform: translateY(-1px);
326
+ }
327
+
328
+ .button:focus-visible {
329
+ outline: 2px solid var(--accent);
330
+ outline-offset: 2px;
331
+ }
332
+
333
+ .button--primary {
334
+ background: var(--accent);
335
+ color: #ffffff;
336
+ }
337
+
338
+ .button--primary:disabled {
339
+ opacity: 0.55;
340
+ cursor: not-allowed;
341
+ transform: none;
342
+ }
343
+
344
+ .button--subtle {
345
+ border-color: var(--border);
346
+ }
347
+
348
+ .button--success {
349
+ background: color-mix(in oklab, var(--success) 22%, transparent);
350
+ border-color: color-mix(in oklab, var(--success) 55%, var(--border));
351
+ }
352
+
353
+ .muted {
354
+ margin: 0;
355
+ color: var(--muted);
356
+ font-size: 13px;
357
+ }
358
+
359
+ .error-panel h2,
360
+ .log-panel h2 {
361
+ margin: 0 0 var(--space-3);
362
+ font-size: 1rem;
363
+ }
364
+
365
+ .error-panel pre,
366
+ .log-panel pre {
367
+ margin: 0;
368
+ padding: var(--space-3);
369
+ border-radius: 10px;
370
+ border: 1px solid var(--border);
371
+ background: #0f172a;
372
+ color: #d8e1f7;
373
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
374
+ font-size: 12px;
375
+ line-height: 1.4;
376
+ white-space: pre-wrap;
377
+ overflow-wrap: anywhere;
378
+ }
379
+
380
+ .log-panel pre {
381
+ max-block-size: min(38dvh, 360px);
382
+ overflow: auto;
383
+ }
384
+
385
+ .log-header {
386
+ display: flex;
387
+ justify-content: space-between;
388
+ align-items: baseline;
389
+ gap: var(--space-3);
390
+ margin-block-end: var(--space-3);
391
+ }
392
+
393
+ .log-switch {
394
+ display: flex;
395
+ flex-wrap: wrap;
396
+ gap: var(--space-2);
397
+ margin-block-end: var(--space-3);
398
+ }
399
+
400
+ .log-switch a {
401
+ color: var(--muted);
402
+ padding: var(--space-2) var(--space-3);
403
+ border: 1px solid var(--border);
404
+ border-radius: var(--space-1);
405
+ text-decoration: none;
406
+ }
407
+
408
+ .log-switch a[aria-current='page'] {
409
+ color: var(--text);
410
+ border-color: var(--accent);
411
+ }
412
+
413
+ .log-switch a:focus-visible,
414
+ .detail-link:focus-visible {
415
+ outline: 2px solid var(--accent);
416
+ outline-offset: 2px;
417
+ }
418
+
419
+ .detail-link {
420
+ color: var(--muted);
421
+ }
422
+
423
+ .connection-warning {
424
+ margin: 0;
425
+ color: var(--warning);
426
+ }
427
+
428
+ .is-hidden {
429
+ display: none !important;
430
+ }
431
+
432
+ .visually-hidden {
433
+ position: absolute !important;
434
+ inline-size: 1px;
435
+ block-size: 1px;
436
+ margin: -1px;
437
+ padding: 0;
438
+ overflow: hidden;
439
+ clip: rect(0, 0, 0, 0);
440
+ border: 0;
441
+ }
442
+
443
+ @media (max-width: 640px) {
444
+ .dashboard {
445
+ inline-size: min(100% - var(--space-4), 960px);
446
+ padding-block: var(--space-6);
447
+ }
448
+
449
+ .status-panel,
450
+ .controls-panel,
451
+ .error-panel,
452
+ .log-panel,
453
+ .noscript-note {
454
+ padding: var(--space-4);
455
+ }
456
+
457
+ .log-header {
458
+ flex-direction: column;
459
+ align-items: flex-start;
460
+ }
461
+ }
@@ -0,0 +1,82 @@
1
+ <main class="dashboard" id="workspace" data-id="<%= @id %>" data-status="<%= field('status') %>" data-started="<%= field('prepare_started_at') %>" data-completed="<%= field('completed_at') %>" data-busy="<%= @presentation['busy'] %>">
2
+ <header class="dashboard__header">
3
+ <div><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1><p><%= field('branch') %></p></div>
4
+ <a class="button button--subtle" href="/workspaces">All workspaces</a>
5
+ </header>
6
+ <section class="status-panel" aria-labelledby="status">
7
+ <div class="status-row">
8
+ <h2 id="status" role="status"><%= escape(@presentation['title']) %></h2>
9
+ <span id="activity" <%= 'hidden' unless @presentation['busy'] %>><span class="activity-dot" aria-hidden="true"></span> In progress · <span id="elapsed">—</span></span>
10
+ </div>
11
+ <p id="message"><%= field('message') %></p>
12
+ <ol class="stepper" id="progress" aria-label="Setup progress">
13
+ <% @presentation['steps'].each do |step| %>
14
+ <li class="step" data-step-state="<%= step['state'] %>" <%= 'aria-current="step"' if step['state'] == 'active' %>><span class="step-bullet" aria-hidden="true"></span><span><%= escape(step['label']) %></span><span class="step-outcome"><%= step['state'] %></span></li>
15
+ <% end %>
16
+ </ol>
17
+ <section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
18
+ <h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
19
+ </section>
20
+ <div class="workspace-actions">
21
+ <a id="preview" class="button button--primary" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
22
+ <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
23
+ </div>
24
+ <p id="connection" role="status"></p>
25
+ </section>
26
+ <section class="log-panel" aria-labelledby="log-title">
27
+ <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
28
+ <nav class="log-switch" aria-label="Log source">
29
+ <a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
30
+ <a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
31
+ </nav>
32
+ <pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
33
+ </section>
34
+ <section class="status-panel env-panel" aria-labelledby="environment-overrides">
35
+ <h2 id="environment-overrides">Environment overrides</h2>
36
+ <p class="muted">Set workspace-only environment overrides for this managed workspace. Saved values are write-only and never shown here. Remove an override to restore recipe defaults.</p>
37
+ <p class="muted">Protected runner settings are rejected by the backend and cannot be overridden here.</p>
38
+ <form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form>
39
+ <fieldset class="env-fieldset" data-environment-fieldset>
40
+ <legend class="env-legend">Existing overrides</legend>
41
+ <% if environment_keys.empty? %>
42
+ <p class="muted">No workspace-specific overrides are currently saved.</p>
43
+ <% else %>
44
+ <ul class="env-existing-list">
45
+ <% environment_keys.each_with_index do |name, index| %>
46
+ <% key_id = "env-existing-#{index}" %>
47
+ <li class="env-existing-item" data-env-existing>
48
+ <div class="env-existing-name">
49
+ <label for="<%= key_id %>-replace-value"><code><%= escape(name) %></code></label>
50
+ <p class="muted">Saved value: <span aria-hidden="true">••••••••</span><span class="visually-hidden">Saved value masked</span></p>
51
+ </div>
52
+ <div class="env-existing-actions">
53
+ <label class="env-inline-label" for="<%= key_id %>-replace-toggle">
54
+ <input type="checkbox" id="<%= key_id %>-replace-toggle" data-replace-toggle aria-controls="<%= key_id %>-replace-value">
55
+ Replace
56
+ </label>
57
+ <label class="env-inline-label" for="<%= key_id %>-remove">
58
+ <input type="checkbox" id="<%= key_id %>-remove" name="remove[]" value="<%= escape(name) %>">
59
+ Remove
60
+ </label>
61
+ </div>
62
+ <label class="env-input-label" for="<%= key_id %>-replace-value">New value for <code><%= escape(name) %></code></label>
63
+ <input type="password" id="<%= key_id %>-replace-value" name="set[<%= escape(name) %>]" autocomplete="off" data-replace-value disabled>
64
+ </li>
65
+ <% end %>
66
+ </ul>
67
+ <% end %>
68
+ </fieldset>
69
+ <fieldset class="env-fieldset" data-environment-additions>
70
+ <legend class="env-legend">Add variables</legend>
71
+ <div class="env-add-list" data-env-add-list></div>
72
+ <button type="button" class="button button--subtle" data-env-add-row>Add variable</button>
73
+ </fieldset>
74
+ <div class="env-actions">
75
+ <button class="button button--primary" data-env-submit>Save &amp; restart</button>
76
+ </div>
77
+ <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
78
+ </form>
79
+ </section>
80
+ <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd></dl></details>
81
+ <noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
82
+ </main>
@@ -0,0 +1,96 @@
1
+ require 'erb'
2
+ require_relative 'config'
3
+ require_relative 'presentation'
4
+
5
+ module Workspaces
6
+ class DashboardView
7
+ def self.render(snapshot)
8
+ new(snapshot).render
9
+ end
10
+
11
+ def self.index(workspaces)
12
+ view = new({})
13
+ view.document('Workspaces', <<~HTML)
14
+ <main class="dashboard"><header class="dashboard__header"><h1>Workspaces</h1></header>
15
+ <section class="status-panel"><h2>Create workspace</h2>
16
+ <p>Choose a branch, PR number, or new branch. Every submission creates a separate managed worktree.</p>
17
+ <form method="post" action="/workspaces/create">
18
+ <p><label>Existing branch <input name="branch" placeholder="main"></label></p>
19
+ <p><label>PR number <input name="pr" inputmode="numeric" placeholder="43828"></label></p>
20
+ <p><label>New branch <input name="new_branch" placeholder="agent/fix-checkout"></label></p>
21
+ <p><label>Start new branch from <input name="from" placeholder="HEAD"></label></p>
22
+ <button class="button button--primary">Create workspace</button>
23
+ </form></section>
24
+ <section class="status-panel"><h2>Managed workspaces</h2><ul>
25
+ #{workspaces.map { |workspace| "<li><a href=\"/workspaces/#{view.escape(workspace.fetch('id'))}\">#{view.escape(workspace.fetch('id'))}</a> — #{view.escape(workspace['branch'])}</li>" }.join}
26
+ </ul></section></main>
27
+ HTML
28
+ end
29
+
30
+ def self.pull_request(number, workspaces)
31
+ new({}).render_pull_request(number, workspaces)
32
+ end
33
+
34
+ def self.checkout_authentication_error(path)
35
+ new({}).render_checkout_authentication_error(path)
36
+ end
37
+
38
+ def initialize(snapshot)
39
+ @snapshot = snapshot
40
+ @presentation = Presentation.new(snapshot).to_h
41
+ end
42
+
43
+ def render
44
+ @id = Config.validate_id!(@snapshot.fetch('workspace_id'))
45
+ document("Workspace #{@id}",
46
+ ERB.new(File.read(File.join(__dir__, 'dashboard/template.html.erb'),
47
+ encoding: 'UTF-8')).result(binding))
48
+ end
49
+
50
+ def render_pull_request(number, workspaces)
51
+ @number = number
52
+ @workspaces = workspaces
53
+ template = File.read(File.join(__dir__, 'dashboard/pull_request.html.erb'))
54
+ document("PR ##{number} workspaces", ERB.new(template).result(binding))
55
+ end
56
+
57
+ def render_checkout_authentication_error(path)
58
+ @retry_path = path if path.match?(%r{\A/pr/[1-9][0-9]*\z})
59
+ template = File.read(File.join(__dir__, 'dashboard/checkout_authentication_error.html.erb'))
60
+ document('GitHub access needs setup', ERB.new(template).result(binding))
61
+ end
62
+
63
+ def document(title, body)
64
+ css = File.read(File.join(__dir__, 'dashboard/styles.css'), encoding: 'UTF-8')
65
+ script = File.read(File.join(__dir__, 'dashboard/app.js'), encoding: 'UTF-8') +
66
+ File.read(File.join(__dir__, 'dashboard/environment.js'), encoding: 'UTF-8')
67
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">' \
68
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' \
69
+ "<title>#{escape(title)}</title><style>#{css}</style></head>" \
70
+ "<body>#{body}<script>#{script}</script></body></html>"
71
+ end
72
+
73
+ def escape(value)
74
+ ERB::Util.html_escape(value.to_s)
75
+ end
76
+
77
+ private
78
+
79
+ def field(name)
80
+ escape(@snapshot[name])
81
+ end
82
+
83
+ def environment_keys
84
+ keys = @snapshot['environment_keys']
85
+ return [] unless keys.is_a?(Array)
86
+
87
+ keys.map(&:to_s).sort
88
+ end
89
+
90
+ def action_form(action)
91
+ style = action['primary'] ? 'button--primary' : 'button--subtle'
92
+ "<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
93
+ "<button class=\"button #{style}\">#{escape(action.fetch('label'))}</button></form>"
94
+ end
95
+ end
96
+ end