workspaces 0.1.10 → 0.1.11
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/DESIGN.md +13 -8
- data/lib/workspaces/dashboard/app.js +60 -16
- data/lib/workspaces/dashboard/styles.css +58 -1
- data/lib/workspaces/dashboard/template.html.erb +55 -45
- data/lib/workspaces/dashboard_view.rb +9 -0
- data/lib/workspaces/presentation.rb +81 -15
- data/lib/workspaces/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc21704825da052eb9cde1ea64cf3ab4958242f08492cf2a5b538d6fd97ebca3
|
|
4
|
+
data.tar.gz: 46dc25654e97ef6fb461058470dcfeb56e5a82720fa6df875c5b3c34b5d273a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: daa43e3151dd5bb2e4239ae61ca9322101eb8793d437a91782dfa71ec078ce42721d384bea49564c0929a087b9476ab8c76d44860ad806ab3dab7e4145352193
|
|
7
|
+
data.tar.gz: 61d43cac06ed0948041320abf6d0a876697241a26063f5aa3f85537580cea660c8e0c7af2e8589fad80a32c6d3ba8432140e959a9fee70ba791ca92f3c81534a
|
data/DESIGN.md
CHANGED
|
@@ -48,19 +48,24 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
48
48
|
- **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
|
|
49
49
|
- **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
|
|
50
50
|
- **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
|
|
51
|
-
- **
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- `
|
|
51
|
+
- **Three layers**: the workspace page leads with the friendly, the deliberate, then the technical. Each layer is shown or collapsed by lifecycle state, driven by `Presentation::STATES`:
|
|
52
|
+
1. **Lead** (always visible): a plain-language headline, one sentence, and at most one primary action (a large button). The setup steps never appear here.
|
|
53
|
+
2. **Settings** (a `<details>` titled *Change settings*, or *Choose what to preview* on a brand new workspace): presets first, as buttons, then the environment variables. The one submit button names what it does (Start workspace, Save & restart, Save & start, Save & retry). It starts open only for a new workspace.
|
|
54
|
+
3. **Technical details** (one `<details>`): setup steps with timings, the live log, workspace controls (rebuild, restart, stop, update from PR), preview URL, branch and path, and the Danger Zone. It starts collapsed, and opens by itself while setup runs or after a failure so a cause is never hidden.
|
|
55
|
+
- **Lead by state**:
|
|
56
|
+
- `idle`: "Let's get your workspace running", with the settings open and *Start workspace* in the form.
|
|
57
|
+
- `busy`: "Setting things up…", with the current step as a quiet line and the elapsed time. Technical details open so progress and the log can be watched.
|
|
58
|
+
- `ready`: "Your workspace is ready" with a large **Open workspace** button. Everything else is collapsed.
|
|
59
|
+
- `stopped`: "Your workspace is paused" with a large **Start workspace** button.
|
|
60
|
+
- `error`/`interrupted`: a plain "Something didn't go to plan" with a large **Retry** button, the error shown, and technical details open.
|
|
61
|
+
- `unknown`: a minimal view with technical details open.
|
|
62
|
+
- **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
|
|
57
63
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
|
|
58
|
-
- **Actions & settings panel**: status-adjacent panel that combines routine lifecycle controls, Open workspace, Refresh status, PR update messaging, and editable environment settings so routine operations stay in one keyboard-reachable region.
|
|
59
64
|
- **Open workspace**: success-styled link available when the server is ready
|
|
60
65
|
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
|
61
66
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
62
67
|
- **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
|
|
63
|
-
- **Preview destination**: a small muted URL in
|
|
68
|
+
- **Preview destination**: a small muted URL in Technical details, visible before readiness; the large Open workspace button is reserved for ready servers.
|
|
64
69
|
- **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
|
|
65
70
|
- **Environment presets (JS-enhanced)**: optional preset selector is hidden by default and revealed only when JavaScript is active and presets exist. Selecting a preset replaces the entire draft editor content and announces unsaved state without submitting. Draft edits remain local until Save & restart.
|
|
66
71
|
- **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
let actionsSignature = null;
|
|
15
15
|
function toggleBusyState(nextBusy) {
|
|
16
16
|
busy = !!nextBusy;
|
|
17
|
-
root.querySelectorAll('.workspace-actions button').forEach((button) => {
|
|
17
|
+
root.querySelectorAll('.workspace-actions button, .lead__action button').forEach((button) => {
|
|
18
18
|
button.disabled = busy;
|
|
19
19
|
});
|
|
20
20
|
if (window.WorkspaceEnvironment)
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
completed = Date.parse(data.completed_at);
|
|
96
96
|
renderStatus(view);
|
|
97
97
|
renderSections(view);
|
|
98
|
-
node('message').textContent = data.message || '';
|
|
98
|
+
if (node('message')) node('message').textContent = data.message || '';
|
|
99
99
|
node('source-update-message').textContent = data.source_update_message || '';
|
|
100
100
|
node('progress').replaceChildren(...view.steps.map(renderStep));
|
|
101
101
|
renderActions(view.actions);
|
|
@@ -109,25 +109,69 @@
|
|
|
109
109
|
root.querySelectorAll('[data-section]').forEach((element) => {
|
|
110
110
|
element.hidden = !sections[element.dataset.section];
|
|
111
111
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
controls.hidden = !sections.actions && !sections.environment;
|
|
115
|
-
const title = node('actions-settings-title');
|
|
116
|
-
if (title && view.controls_title) title.textContent = view.controls_title;
|
|
117
|
-
}
|
|
118
|
-
const hint = node('hint');
|
|
119
|
-
if (hint) {
|
|
120
|
-
hint.textContent = view.hint || '';
|
|
121
|
-
hint.hidden = !view.hint;
|
|
122
|
-
}
|
|
123
|
-
node('message').hidden = view.state === 'idle';
|
|
112
|
+
renderLead(view);
|
|
113
|
+
applyDisclosureState(view);
|
|
124
114
|
if (window.WorkspaceEnvironment)
|
|
125
115
|
window.WorkspaceEnvironment.setSubmitLabel(view.environment_submit);
|
|
126
116
|
}
|
|
127
117
|
|
|
118
|
+
function renderLead(view) {
|
|
119
|
+
node('status').textContent = view.headline || view.title;
|
|
120
|
+
node('summary').textContent = view.summary || '';
|
|
121
|
+
const subtitle = node('subtitle');
|
|
122
|
+
const activity = node('activity');
|
|
123
|
+
if (subtitle) {
|
|
124
|
+
const text = view.subtitle || '';
|
|
125
|
+
subtitle.hidden = !text;
|
|
126
|
+
// The text has its own element, so it can be set whether or not the server rendered any.
|
|
127
|
+
const target = node('subtitle-text');
|
|
128
|
+
if (target) target.textContent = text;
|
|
129
|
+
}
|
|
130
|
+
if (activity) activity.hidden = !view.busy;
|
|
131
|
+
const title = node('environment-title');
|
|
132
|
+
if (title && view.environment_title) title.textContent = view.environment_title;
|
|
133
|
+
renderLeadAction(view.lead_action);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The lead action is a lifecycle post (start/retry) or, when ready, the preview link
|
|
137
|
+
// rendered separately as #preview. Rebuild only when it actually changes.
|
|
138
|
+
let leadSignature = null;
|
|
139
|
+
function renderLeadAction(action) {
|
|
140
|
+
const holder = node('lead-action');
|
|
141
|
+
if (!holder) return;
|
|
142
|
+
const signature = JSON.stringify(action || null);
|
|
143
|
+
if (signature === leadSignature) return;
|
|
144
|
+
leadSignature = signature;
|
|
145
|
+
holder.replaceChildren();
|
|
146
|
+
if (!action || action.kind !== 'post' || !allowedActions.includes(action.key)) return;
|
|
147
|
+
const form = document.createElement('form');
|
|
148
|
+
form.method = 'post';
|
|
149
|
+
form.action = base + '/' + action.key;
|
|
150
|
+
const button = document.createElement('button');
|
|
151
|
+
button.className = 'button button--primary button--large';
|
|
152
|
+
button.textContent = action.label;
|
|
153
|
+
form.append(button);
|
|
154
|
+
holder.append(form);
|
|
155
|
+
toggleBusyState(busy);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A disclosure opens by itself when its state says so (a failure must never be hidden,
|
|
159
|
+
// a fresh workspace needs its settings), but we never close one the user opened, and
|
|
160
|
+
// we only apply the state's preference when the state changes, not on every poll.
|
|
161
|
+
let lastDisclosureState = root.dataset.disclosureState || null;
|
|
162
|
+
function applyDisclosureState(view) {
|
|
163
|
+
if (view.state === lastDisclosureState) return;
|
|
164
|
+
lastDisclosureState = view.state;
|
|
165
|
+
const open = view.open || {};
|
|
166
|
+
[['environment-disclosure', open.environment], ['technical-disclosure', open.technical]].forEach(
|
|
167
|
+
([id, shouldOpen]) => {
|
|
168
|
+
const element = node(id);
|
|
169
|
+
if (element) element.open = !!shouldOpen;
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
function renderStatus(view) {
|
|
129
|
-
node('status').textContent = view.title;
|
|
130
|
-
node('activity').hidden = !view.busy;
|
|
131
175
|
node('preview').hidden = !view.ready;
|
|
132
176
|
node('error').textContent = view.error || '';
|
|
133
177
|
node('error-panel').hidden = !view.error;
|
|
@@ -148,7 +148,64 @@ h1 {
|
|
|
148
148
|
.log-panel,
|
|
149
149
|
.danger-zone { gap: var(--space-3); }
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
|
|
152
|
+
/* Lead: a friendly headline, one sentence, and at most one big button. */
|
|
153
|
+
.lead { gap: var(--space-4); padding-block: var(--space-8); }
|
|
154
|
+
.lead__text { display: grid; gap: var(--space-2); }
|
|
155
|
+
.lead h2 { font-size: clamp(1.4rem, 2vw + 1rem, 1.9rem); line-height: 1.2; }
|
|
156
|
+
.lead__summary { max-inline-size: 38rem; color: var(--muted); font-size: 1.05rem; line-height: 1.5; }
|
|
157
|
+
.lead__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
|
158
|
+
.lead__action:empty { display: none; }
|
|
159
|
+
.button--large { min-block-size: 52px; padding-inline: var(--space-8); font-size: 16px; border-radius: var(--radius); }
|
|
160
|
+
|
|
161
|
+
/* Disclosures: quiet by default, a clear label and a one-line hint of what is inside. */
|
|
162
|
+
/* A disclosure has to read as a control. The native marker is lost once a summary is a flex
|
|
163
|
+
container, so draw our own chevron; the whole header is the click target, with a hover
|
|
164
|
+
fill and a keyboard focus ring. */
|
|
165
|
+
/* Two classes so the narrow-screen panel padding below cannot re-add padding around the summary. */
|
|
166
|
+
.status-panel.disclosure { padding: 0; }
|
|
167
|
+
.disclosure > summary {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-wrap: wrap;
|
|
170
|
+
align-items: baseline;
|
|
171
|
+
gap: var(--space-1) var(--space-3);
|
|
172
|
+
padding: var(--space-4) calc(var(--space-6) + var(--space-8)) var(--space-4) var(--panel-padding);
|
|
173
|
+
position: relative;
|
|
174
|
+
list-style: none;
|
|
175
|
+
color: var(--text);
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
border-radius: var(--radius);
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
user-select: none;
|
|
180
|
+
transition: background-color 120ms ease;
|
|
181
|
+
}
|
|
182
|
+
.disclosure > summary::-webkit-details-marker { display: none; }
|
|
183
|
+
.disclosure > summary::after {
|
|
184
|
+
content: '';
|
|
185
|
+
position: absolute;
|
|
186
|
+
inset-inline-end: var(--panel-padding);
|
|
187
|
+
inset-block-start: 50%;
|
|
188
|
+
inline-size: 8px;
|
|
189
|
+
block-size: 8px;
|
|
190
|
+
margin-block-start: -6px;
|
|
191
|
+
border-inline-end: 2px solid var(--muted);
|
|
192
|
+
border-block-end: 2px solid var(--muted);
|
|
193
|
+
transform: rotate(45deg);
|
|
194
|
+
transition: transform 160ms ease, border-color 120ms ease;
|
|
195
|
+
}
|
|
196
|
+
.disclosure[open] > summary::after { margin-block-start: -2px; transform: rotate(225deg); }
|
|
197
|
+
.disclosure > summary:hover { background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
198
|
+
.disclosure > summary:hover::after { border-color: var(--text); }
|
|
199
|
+
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
200
|
+
.disclosure[open] > summary { border-end-start-radius: 0; border-end-end-radius: 0; border-block-end: 1px solid var(--border); margin-block-end: 0; }
|
|
201
|
+
.disclosure > :not(summary) { margin: var(--panel-padding); }
|
|
202
|
+
.disclosure__hint { color: var(--muted); font-size: 13px; font-weight: 400; }
|
|
203
|
+
@media (prefers-reduced-motion: reduce) { .disclosure > summary, .disclosure > summary::after { transition: none; } }
|
|
204
|
+
.disclosure .technical > section:not(.danger-zone) { padding: 0; border: 0; box-shadow: none; background: none; }
|
|
205
|
+
.disclosure .danger-zone { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--danger); border-radius: var(--radius); }
|
|
206
|
+
.disclosure .log-panel { padding: 0; }
|
|
207
|
+
.disclosure .danger-zone h3 { color: var(--danger); font-size: 1rem; }
|
|
208
|
+
.disclosure h3 { font-size: 1rem; }
|
|
152
209
|
|
|
153
210
|
.status-row {
|
|
154
211
|
display: grid;
|
|
@@ -1,37 +1,27 @@
|
|
|
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'] %>">
|
|
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'] %>" data-disclosure-state="<%= escape(@presentation['state']) %>">
|
|
2
2
|
<header class="dashboard__header">
|
|
3
|
-
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1
|
|
3
|
+
<div class="dashboard__title"><p class="eyebrow">Managed workspace</p><h1><%= @id %></h1></div>
|
|
4
4
|
<a class="button button--subtle" href="/workspaces">All workspaces</a>
|
|
5
5
|
</header>
|
|
6
|
-
<section class="status-panel" aria-labelledby="status"
|
|
7
|
-
<div class="
|
|
8
|
-
<h2 id="status" role="status"><%= escape(@presentation['
|
|
9
|
-
<
|
|
6
|
+
<section class="status-panel lead" id="lead" data-section="lead" aria-labelledby="status" <%= 'hidden' unless section?('lead') %>>
|
|
7
|
+
<div class="lead__text">
|
|
8
|
+
<h2 id="status" role="status"><%= escape(@presentation['headline']) %></h2>
|
|
9
|
+
<p id="summary" class="lead__summary"><%= escape(@presentation['summary']) %></p>
|
|
10
|
+
<p id="subtitle" class="muted" <%= 'hidden' unless @presentation['subtitle'] %>><span id="subtitle-text"><%= escape(@presentation['subtitle']) %></span><span id="activity" <%= 'hidden' unless @presentation['busy'] %>> · <span class="activity-dot" aria-hidden="true"></span> <span id="elapsed">—</span></span></p>
|
|
10
11
|
</div>
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<% end %>
|
|
17
|
-
</ol>
|
|
12
|
+
<div class="lead__actions">
|
|
13
|
+
<a id="preview" class="button button--primary button--large" href="<%= escape(Config.preview_url(@id)) %>" <%= 'hidden' unless @presentation['ready'] %> target="_blank" rel="noopener noreferrer">Open workspace</a>
|
|
14
|
+
<div id="lead-action" class="lead__action"><%= lead_action_form(@presentation['lead_action']) %></div>
|
|
15
|
+
</div>
|
|
16
|
+
<p id="message" class="muted" hidden><%= field('message') %></p>
|
|
18
17
|
<section class="error-panel setup-error" id="error-panel" role="alert" <%= 'hidden' unless @presentation['error'] %>>
|
|
19
18
|
<h3>What went wrong</h3><pre id="error"><%= escape(@presentation['error']) %></pre>
|
|
20
19
|
</section>
|
|
21
20
|
<p id="connection" role="status"></p>
|
|
22
21
|
</section>
|
|
23
|
-
<
|
|
24
|
-
<
|
|
25
|
-
<div class="workspace-
|
|
26
|
-
<h3 id="actions-heading">Workspace actions</h3>
|
|
27
|
-
<div class="workspace-actions">
|
|
28
|
-
<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>
|
|
29
|
-
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
30
|
-
</div>
|
|
31
|
-
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
32
|
-
</div>
|
|
33
|
-
<div class="workspace-settings" data-section="environment" aria-labelledby="environment-overrides" <%= 'hidden' unless section?('environment') %>>
|
|
34
|
-
<h3 id="environment-overrides">Environment</h3>
|
|
22
|
+
<details class="status-panel disclosure" id="environment-disclosure" data-section="environment" <%= 'open' if @presentation.dig('open', 'environment') %> <%= 'hidden' unless section?('environment') %>>
|
|
23
|
+
<summary><span id="environment-title"><%= escape(@presentation['environment_title']) %></span><span class="disclosure__hint">Presets and environment variables</span></summary>
|
|
24
|
+
<div class="workspace-settings" aria-labelledby="environment-title">
|
|
35
25
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" class="stack" data-environment-form data-environment-presets="<%= escape(environment_presets_json) %>">
|
|
36
26
|
<fieldset class="env-fieldset" data-environment-fieldset <%= 'disabled' if @presentation['busy'] %>>
|
|
37
27
|
<legend class="env-legend">Workspace settings</legend>
|
|
@@ -57,25 +47,45 @@
|
|
|
57
47
|
<p class="muted" id="environment-status" role="status" aria-live="polite"></p>
|
|
58
48
|
</form>
|
|
59
49
|
</div>
|
|
60
|
-
</
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
50
|
+
</details>
|
|
51
|
+
<details class="status-panel disclosure" id="technical-disclosure" data-section="technical" <%= 'open' if @presentation.dig('open', 'technical') %> <%= 'hidden' unless section?('technical') %>>
|
|
52
|
+
<summary><span>Technical details</span><span class="disclosure__hint">Setup steps, logs and workspace controls</span></summary>
|
|
53
|
+
<div class="technical stack stack--loose">
|
|
54
|
+
<ol class="stepper" id="progress" data-section="progress" aria-label="Setup progress" <%= 'hidden' unless section?('progress') %>>
|
|
55
|
+
<% @presentation['steps'].each do |step| %>
|
|
56
|
+
<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"><%= escape(step_outcome(step)) %></span></li>
|
|
57
|
+
<% end %>
|
|
58
|
+
</ol>
|
|
59
|
+
<div class="workspace-actions-group stack stack--snug" data-section="actions" aria-labelledby="actions-heading" <%= 'hidden' unless section?('actions') %>>
|
|
60
|
+
<h3 id="actions-heading">Workspace controls</h3>
|
|
61
|
+
<div class="workspace-actions">
|
|
62
|
+
<div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
|
|
63
|
+
</div>
|
|
64
|
+
<p id="source-update-message" role="status"><%= field('source_update_message') %></p>
|
|
65
|
+
</div>
|
|
66
|
+
<section class="log-panel" data-section="log" aria-labelledby="log-title" <%= 'hidden' unless section?('log') %>>
|
|
67
|
+
<div class="log-header"><h3 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h3><time id="updated"><%= field('updated_at') %></time></div>
|
|
68
|
+
<nav class="log-switch" aria-label="Log source">
|
|
69
|
+
<a href="?log=setup" data-log-source="setup" <%= 'aria-current="page"' if @snapshot['log_source'] != 'backend' %>>Setup log</a>
|
|
70
|
+
<a href="?log=backend" data-log-source="backend" <%= 'aria-current="page"' if @snapshot['log_source'] == 'backend' %>>Backend log</a>
|
|
71
|
+
</nav>
|
|
72
|
+
<pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
|
|
73
|
+
</section>
|
|
74
|
+
<section class="stack stack--snug" data-section="details" <%= 'hidden' unless section?('details') %>>
|
|
75
|
+
<h3>Workspace details</h3>
|
|
76
|
+
<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><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl>
|
|
77
|
+
</section>
|
|
78
|
+
<section class="danger-zone" data-section="danger" aria-labelledby="danger-heading" <%= 'hidden' unless section?('danger') %>>
|
|
79
|
+
<h3 id="danger-heading">Danger Zone</h3>
|
|
80
|
+
<p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
|
|
81
|
+
<form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
|
|
82
|
+
<label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
|
|
83
|
+
<input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
|
|
84
|
+
<p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
|
|
85
|
+
<button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
|
|
86
|
+
</form>
|
|
87
|
+
</section>
|
|
88
|
+
</div>
|
|
89
|
+
</details>
|
|
80
90
|
<noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
|
|
81
91
|
</main>
|
|
@@ -161,6 +161,15 @@ module Workspaces
|
|
|
161
161
|
JSON.generate(environment_presets)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
# The lead's primary action. `open` is rendered separately as a link (it needs the
|
|
165
|
+
# preview URL and must stay a real anchor), so only lifecycle posts produce a form.
|
|
166
|
+
def lead_action_form(action)
|
|
167
|
+
return '' unless action && action['kind'] == 'post'
|
|
168
|
+
|
|
169
|
+
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
170
|
+
"<button class=\"button button--primary button--large\">#{escape(action.fetch('label'))}</button></form>"
|
|
171
|
+
end
|
|
172
|
+
|
|
164
173
|
def action_form(action)
|
|
165
174
|
style = action['primary'] ? 'button--primary' : 'button--subtle'
|
|
166
175
|
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
@@ -6,8 +6,13 @@ module Workspaces
|
|
|
6
6
|
ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
|
|
7
7
|
'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# Three layers, from friendly to technical. `lead` is the headline, one sentence and at
|
|
10
|
+
# most one primary action. `environment` (presets and variables) is the area for
|
|
11
|
+
# deliberately changing something. `technical` is one disclosure holding setup steps,
|
|
12
|
+
# logs, workspace details, lifecycle controls and deletion; it is never the first thing
|
|
13
|
+
# a user reads, but it opens by itself when something needs attention.
|
|
14
|
+
SECTIONS = %w(lead progress actions environment technical log details danger).freeze
|
|
15
|
+
TECHNICAL = %w(technical progress actions log details danger).freeze
|
|
11
16
|
|
|
12
17
|
# Workspace lifecycle state machine. Each state declares only what is usable and
|
|
13
18
|
# meaningful there; everything else is hidden rather than shown disabled.
|
|
@@ -20,19 +25,60 @@ module Workspaces
|
|
|
20
25
|
#
|
|
21
26
|
# `environment_submit` labels the single environment-form button, which saves the
|
|
22
27
|
# settings and (re)starts the workspace, so a brand new workspace needs only that.
|
|
28
|
+
#
|
|
29
|
+
# Per state: `headline` and `summary` are plain language; `open_environment` and
|
|
30
|
+
# `open_technical` say which disclosures start expanded. A failure always opens the
|
|
31
|
+
# technical section so the cause is visible without hunting for it.
|
|
23
32
|
STATES = {
|
|
24
33
|
'idle' => {
|
|
25
|
-
sections: %w(environment),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
sections: %w(lead environment),
|
|
35
|
+
headline: "Let's get your workspace running",
|
|
36
|
+
summary: 'Pick the version of the site you want to preview, then start. Setup usually takes a few minutes.',
|
|
37
|
+
environment_submit: 'Start workspace', environment_title: 'Choose what to preview',
|
|
38
|
+
open_environment: true, open_technical: false
|
|
39
|
+
},
|
|
40
|
+
'busy' => {
|
|
41
|
+
sections: %w(lead technical progress log),
|
|
42
|
+
headline: 'Setting things up…',
|
|
43
|
+
summary: "This usually takes a few minutes. We'll take you to the preview when it's ready.",
|
|
44
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
45
|
+
open_environment: false, open_technical: true
|
|
46
|
+
},
|
|
47
|
+
'ready' => {
|
|
48
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
49
|
+
headline: 'Your workspace is ready',
|
|
50
|
+
summary: 'Everything is set up and running. Open it to see your changes.',
|
|
51
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
52
|
+
open_environment: false, open_technical: false
|
|
53
|
+
},
|
|
54
|
+
'stopped' => {
|
|
55
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
56
|
+
headline: 'Your workspace is paused',
|
|
57
|
+
summary: 'Start it again to pick up where you left off.',
|
|
58
|
+
environment_submit: 'Save & start', environment_title: 'Change settings',
|
|
59
|
+
open_environment: false, open_technical: false
|
|
60
|
+
},
|
|
61
|
+
'error' => {
|
|
62
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
63
|
+
headline: "Something didn't go to plan",
|
|
64
|
+
summary: 'Setup stopped before it finished. You can try again, or change settings first.',
|
|
65
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
66
|
+
open_environment: false, open_technical: true
|
|
67
|
+
},
|
|
68
|
+
'interrupted' => {
|
|
69
|
+
sections: %w(lead environment) + TECHNICAL,
|
|
70
|
+
headline: 'Setup was interrupted',
|
|
71
|
+
summary: 'It stopped before it finished. Try again to pick up from the start.',
|
|
72
|
+
environment_submit: 'Save & retry', environment_title: 'Change settings',
|
|
73
|
+
open_environment: false, open_technical: true
|
|
29
74
|
},
|
|
30
|
-
'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
75
|
+
'unknown' => {
|
|
76
|
+
sections: %w(lead technical log details),
|
|
77
|
+
headline: 'Workspace status unknown',
|
|
78
|
+
summary: 'Open the technical details to see what the workspace is doing.',
|
|
79
|
+
environment_submit: 'Save & restart', environment_title: 'Change settings',
|
|
80
|
+
open_environment: false, open_technical: true
|
|
81
|
+
}
|
|
36
82
|
}.freeze
|
|
37
83
|
|
|
38
84
|
def initialize(snapshot)
|
|
@@ -42,9 +88,12 @@ module Workspaces
|
|
|
42
88
|
def to_h
|
|
43
89
|
{ 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
|
|
44
90
|
'actions' => actions, 'ready' => status == 'ready' && !busy?,
|
|
45
|
-
'state' => state, 'sections' => sections,
|
|
46
|
-
'
|
|
47
|
-
'
|
|
91
|
+
'state' => state, 'sections' => sections,
|
|
92
|
+
'headline' => definition[:headline], 'summary' => definition[:summary],
|
|
93
|
+
'subtitle' => subtitle, 'lead_action' => lead_action,
|
|
94
|
+
'environment_title' => definition[:environment_title],
|
|
95
|
+
'environment_submit' => definition[:environment_submit],
|
|
96
|
+
'open' => { 'environment' => definition[:open_environment], 'technical' => definition[:open_technical] } }
|
|
48
97
|
end
|
|
49
98
|
|
|
50
99
|
private
|
|
@@ -73,6 +122,23 @@ module Workspaces
|
|
|
73
122
|
SECTIONS.to_h { |name| [name, visible.include?(name)] }
|
|
74
123
|
end
|
|
75
124
|
|
|
125
|
+
# The single most useful thing to do next, or nil when the user should just wait.
|
|
126
|
+
# `open` links straight to the preview; the others are lifecycle form posts.
|
|
127
|
+
def lead_action
|
|
128
|
+
case state
|
|
129
|
+
when 'ready' then { 'kind' => 'open', 'label' => 'Open workspace' }
|
|
130
|
+
when 'stopped' then { 'kind' => 'post', 'key' => 'start', 'label' => 'Start workspace' }
|
|
131
|
+
when 'error', 'interrupted' then retry_action.merge('kind' => 'post')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# What is happening right now, in the quiet line under the headline.
|
|
136
|
+
def subtitle
|
|
137
|
+
return nil unless state == 'busy'
|
|
138
|
+
|
|
139
|
+
@snapshot['message']
|
|
140
|
+
end
|
|
141
|
+
|
|
76
142
|
def title
|
|
77
143
|
return 'Workspace busy' if @snapshot['active'] && !busy_status?
|
|
78
144
|
return @snapshot['message'] || 'Running setup' if status == 'preparing'
|
data/lib/workspaces/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: workspaces
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Workspaces contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|