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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'uri'
5
+ require_relative 'paths'
6
+
7
+ module Workspaces
8
+ module Config
9
+ extend Paths
10
+
11
+ SAFE_LOG_BYTES = 8 * 1024
12
+ SAFE_LOG_LINES = 100
13
+ DEFAULT_HOME = File.expand_path('~/.workspaces')
14
+ DEFAULT_SUBDOMAIN_PREFIX = 'ws-'
15
+ DEFAULT_FRONT_DOOR_PORT = 4747
16
+ BACKEND_PORT_RANGE = (30_000..30_999)
17
+
18
+ def self.front_door_port
19
+ Integer(ENV.fetch('WORKSPACES_PORT', DEFAULT_FRONT_DOOR_PORT))
20
+ end
21
+
22
+ def self.front_door_host
23
+ public_uri.authority
24
+ end
25
+
26
+ def self.bind_address
27
+ ENV.fetch('WORKSPACES_BIND', '127.0.0.1')
28
+ end
29
+
30
+ def self.public_origin
31
+ uri = public_uri
32
+ "#{uri.scheme}://#{uri.authority}"
33
+ end
34
+
35
+ def self.public_uri
36
+ value = ENV.fetch('WORKSPACES_PUBLIC_ORIGIN', "http://localhost:#{front_door_port}")
37
+ uri = URI.parse(value)
38
+ unless valid_origin?(uri)
39
+ raise ArgumentError,
40
+ 'WORKSPACES_PUBLIC_ORIGIN must be an HTTP(S) origin without credentials or a path'
41
+ end
42
+
43
+ uri
44
+ rescue URI::InvalidURIError
45
+ raise ArgumentError, 'Invalid WORKSPACES_PUBLIC_ORIGIN'
46
+ end
47
+
48
+ def self.valid_origin?(uri)
49
+ uri.is_a?(URI::HTTP) && uri.host && ['', '/'].include?(uri.path) &&
50
+ [uri.userinfo, uri.query, uri.fragment].all?(&:nil?)
51
+ end
52
+
53
+ def self.preview_base_domain
54
+ domain = ENV.fetch('WORKSPACES_BASE_DOMAIN', 'localhost').downcase
55
+ labels = domain.split('.', -1)
56
+ valid = domain.length <= 253 && labels.all? do |label|
57
+ label.length <= 63 && label.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/)
58
+ end
59
+ raise ArgumentError, 'Invalid WORKSPACES_BASE_DOMAIN' unless valid
60
+
61
+ domain
62
+ end
63
+
64
+ def self.preview_subdomain_prefix
65
+ prefix = ENV.fetch('WORKSPACES_SUBDOMAIN_PREFIX', DEFAULT_SUBDOMAIN_PREFIX)
66
+ validate_preview_subdomain_prefix!(prefix)
67
+ prefix
68
+ end
69
+
70
+ def self.preview_host(workspace_id)
71
+ validate_id!(workspace_id)
72
+ hostname = "#{preview_subdomain_prefix}#{workspace_id}.#{preview_base_domain}"
73
+ raise ArgumentError, 'Workspace hostname exceeds DNS label limits' if hostname.split('.').any? do |label|
74
+ label.length > 63
75
+ end
76
+ uri = public_uri
77
+ uri.port == uri.default_port ? hostname : "#{hostname}:#{uri.port}"
78
+ end
79
+
80
+ def self.preview_url(workspace_id)
81
+ "#{public_uri.scheme}://#{preview_host(workspace_id)}/"
82
+ end
83
+
84
+ def self.preview_workspace_id(host)
85
+ pattern = "#{Regexp.escape(preview_subdomain_prefix)}(?<workspace_id>[a-z0-9]+(?:-[a-z0-9]+)*)\\." \
86
+ "#{Regexp.escape(preview_base_domain)}"
87
+ match = /\A#{pattern}\z/i.match(host.to_s)
88
+ match && match[:workspace_id].downcase
89
+ end
90
+
91
+ def self.validate_id!(id)
92
+ return id if id.is_a?(String) && id.length <= 48 && id.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
93
+
94
+ raise ArgumentError, 'Invalid workspace ID'
95
+ end
96
+
97
+ def self.dashboard_url(id)
98
+ "#{public_origin}/workspaces/#{validate_id!(id)}"
99
+ end
100
+
101
+ def self.validate_preview_subdomain_prefix!(prefix)
102
+ parts = prefix.to_s.split('.', -1)
103
+ raise ArgumentError, 'WORKSPACES_SUBDOMAIN_PREFIX must not be empty' if parts.empty? || parts.any?(&:empty?)
104
+
105
+ parts[0...-1].each do |part|
106
+ next if part.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/)
107
+
108
+ raise ArgumentError, "Unsafe WORKSPACES_SUBDOMAIN_PREFIX: #{prefix.inspect}"
109
+ end
110
+
111
+ return prefix if parts.last.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9-])?\z/)
112
+
113
+ raise ArgumentError, "Unsafe WORKSPACES_SUBDOMAIN_PREFIX: #{prefix.inspect}"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,203 @@
1
+ require 'time'
2
+ require_relative 'registry'
3
+ require_relative 'backend'
4
+ require_relative 'workflow'
5
+ require_relative 'tee_log'
6
+ require_relative 'presentation'
7
+ require_relative 'environment_overrides'
8
+
9
+ module Workspaces
10
+ class Coordinator
11
+ ACTIVE = %w(preparing).freeze
12
+ attr_reader :workers, :workers_mutex
13
+
14
+ def initialize(registry: Registry.new, state: StateStore.new(Config.state_file))
15
+ @registry = registry
16
+ @state = state
17
+ @workers = {}
18
+ @workers_mutex = Mutex.new
19
+ end
20
+
21
+ def start(id, force: false)
22
+ launch(id, restart: false, force: force)
23
+ end
24
+
25
+ def restart(id)
26
+ launch(id, restart: true, force: true)
27
+ end
28
+
29
+ def update_environment(id, set:, remove:)
30
+ restart = Backend.new(id).running?
31
+ launch(id, restart: restart, force: true,
32
+ prelaunch: lambda { |_workspace|
33
+ Recipe.new
34
+ EnvironmentOverrides.new(id).apply_patch(set: set, remove: remove)
35
+ })
36
+ end
37
+
38
+ def wait(id)
39
+ workers_mutex.synchronize { workers[id] }&.value
40
+ snapshot(id)
41
+ end
42
+
43
+ def snapshot(id, log: 'setup', include_log: true)
44
+ raise ArgumentError, 'Unknown workspace log' unless %w(setup backend).include?(log)
45
+
46
+ workspace = @registry.find(id)
47
+ entry = @state.get(id) || {}
48
+ busy = locked?(id)
49
+ status = resolve_status(id, entry.fetch('status', 'idle'), busy)
50
+ data = workspace.describe.merge(snapshot_fields(id, entry, busy, status, log, include_log))
51
+ data.merge('presentation' => Presentation.new(data).to_h)
52
+ end
53
+
54
+
55
+ private
56
+
57
+ def resolve_status(id, status, busy)
58
+ return busy ? status : 'interrupted' if ACTIVE.include?(status)
59
+ return status if status == 'error'
60
+ return 'ready' if Backend.new(id).running?
61
+
62
+ status == 'ready' ? 'stopped' : status
63
+ end
64
+
65
+ def snapshot_fields(id, entry, busy, status, log, include_log)
66
+ {
67
+ 'workspace_id' => id, 'status' => status, 'active' => busy,
68
+ 'message' => status == entry['status'] ? entry['message'] : status.capitalize,
69
+ 'prepare_started_at' => entry['prepare_started_at'], 'updated_at' => entry['updated_at'],
70
+ 'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
71
+ 'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
72
+ 'steps' => entry.fetch('steps', []),
73
+ 'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
74
+ 'environment_keys' => environment_keys(id)
75
+ }
76
+ end
77
+
78
+ def failure_phase(entry, status)
79
+ status == 'interrupted' ? entry['status'] : entry['failed_phase']
80
+ end
81
+
82
+ def launch(id, restart:, force:, prelaunch: nil)
83
+ workspace = @registry.find(id)
84
+ lock = acquire_lock(id)
85
+ return false unless lock
86
+
87
+ return false if backend_already_running?(id, force, lock)
88
+ prelaunch&.call(workspace)
89
+ publish_starting(id, restart)
90
+ spawn_worker(workspace, restart, lock)
91
+ true
92
+ rescue StandardError
93
+ lock&.close unless lock&.closed?
94
+ raise
95
+ end
96
+
97
+ def backend_already_running?(id, force, lock)
98
+ return false if force || !Backend.new(id).running?
99
+
100
+ lock.close
101
+ true
102
+ end
103
+
104
+ def acquire_lock(id)
105
+ Config.locks_dir.mkpath
106
+ # The worker owns this duplicate; closing the request's descriptor must not release its lock.
107
+ File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |file|
108
+ file.dup if file.flock(File::LOCK_EX | File::LOCK_NB)
109
+ end
110
+ end
111
+
112
+ def publish_starting(id, restart)
113
+ recipe = Recipe.new
114
+ steps = recipe.for_operation(restart: restart).map do |step|
115
+ { 'key' => step['id'], 'label' => step['name'], 'state' => 'pending' }
116
+ end
117
+ update(id, 'status' => 'preparing', 'message' => restart ? 'Restarting application' : 'Preparing workspace',
118
+ 'steps' => steps,
119
+ 'operation' => restart ? 'restart' : 'prepare', 'failed_phase' => nil,
120
+ 'prepare_started_at' => now, 'completed_at' => nil, 'last_error' => nil)
121
+ end
122
+
123
+ def spawn_worker(workspace, restart, lock)
124
+ id = workspace.id
125
+ workers_mutex.synchronize do
126
+ workers[id] = Thread.new do
127
+ run(workspace, restart: restart)
128
+ ensure
129
+ lock.close
130
+ workers_mutex.synchronize { workers.delete(id) }
131
+ end
132
+ end
133
+ end
134
+
135
+ def run(workspace, restart:)
136
+ id = workspace.id
137
+ File.open(Config.setup_log_path(id), 'a') do |file|
138
+ file.sync = true
139
+ log = TeeLog.new(id, file)
140
+ log.puts "==> #{restart ? 'restarting' : 'preparing'} workspace #{id}; branch and working files unchanged"
141
+ prepare_and_boot(workspace, log, restart)
142
+ end
143
+ rescue StandardError => e
144
+ publish_error(id, e)
145
+ end
146
+
147
+ def prepare_and_boot(workspace, log, restart)
148
+ id = workspace.id
149
+ port = Workflow.new(workspace, log, state: @state).run!(restart: restart)
150
+ update(id, 'status' => 'ready', 'message' => 'Workspace ready', 'completed_at' => now, 'port' => port)
151
+ end
152
+
153
+ def publish_error(id, exception)
154
+ detail = scrub("#{exception.class}: #{exception.message}", id: id)
155
+ $stdout.puts "[Workspace #{id}] ERROR: #{detail}"
156
+ File.open(Config.setup_log_path(id), 'a') { |file| file.puts "ERROR: #{detail}" }
157
+ update(id, 'status' => 'error', 'message' => 'Workspace preparation failed', 'last_error' => detail,
158
+ 'failed_phase' => @state.get(id)&.fetch('status', nil), 'completed_at' => now)
159
+ end
160
+
161
+ def locked?(id)
162
+ path = Config.prepare_lock_path(id)
163
+ return false unless path.file?
164
+
165
+ File.open(path, File::RDWR) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
166
+ end
167
+
168
+ def log_tail(id, log)
169
+ path = log == 'backend' ? Config.backend_log_path(id, create: false) : Config.setup_log_path(id, create: false)
170
+ return '' unless path.file?
171
+
172
+ raw = File.open(path, 'rb') do |file|
173
+ file.seek(-Config::SAFE_LOG_BYTES, IO::SEEK_END) if file.size > Config::SAFE_LOG_BYTES
174
+ file.read
175
+ end
176
+ scrub(raw, id: id).lines.last(Config::SAFE_LOG_LINES).join
177
+ end
178
+
179
+ def scrub(value, id:)
180
+ scrubbed = value.to_s
181
+ scrubbed = EnvironmentOverrides.new(id).scrub(scrubbed)
182
+ scrubbed.encode('UTF-8', invalid: :replace, undef: :replace)
183
+ .gsub(%r{\e\[[0-9;?]*[ -/]*[@-~]}, '')
184
+ .gsub(/((?:api[_-]?key|token|secret|password|authorization)[^\n:=]{0,40}[:=]\s*)[^\s"',;]+/i, '\1[FILTERED]')
185
+ .gsub(/(Bearer\s+)[^\s]+/i, '\1[FILTERED]')
186
+ .gsub(%r{(https?://[^:\s/]+:)[^@\s]+(@)}i, '\1[FILTERED]\2')
187
+ end
188
+
189
+ def environment_keys(id)
190
+ EnvironmentOverrides.new(id).keys
191
+ rescue EnvironmentOverrides::Invalid
192
+ []
193
+ end
194
+
195
+ def update(id, attributes)
196
+ @state.set(id, attributes.merge('updated_at' => now))
197
+ end
198
+
199
+ def now
200
+ Time.now.utc.iso8601
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,132 @@
1
+ (function () {
2
+ 'use strict';
3
+ const root = document.getElementById('workspace');
4
+ if (!root) return;
5
+ const node = (id) => document.getElementById(id);
6
+ const base = '/workspaces/' + root.dataset.id;
7
+ const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
8
+ ? 'backend'
9
+ : 'setup';
10
+ const allowedActions = ['start', 'prepare', 'restart', 'stop'];
11
+ let busy = root.dataset.busy === 'true';
12
+ let started = Date.parse(root.dataset.started);
13
+ let completed = Date.parse(root.dataset.completed);
14
+ let actionsSignature = null;
15
+ function toggleBusyState(nextBusy) {
16
+ root.querySelectorAll('.workspace-actions button').forEach((button) => {
17
+ button.disabled = nextBusy;
18
+ });
19
+ if (window.WorkspaceEnvironment)
20
+ window.WorkspaceEnvironment.toggleBusy(nextBusy);
21
+ }
22
+
23
+ function renderActions(actions) {
24
+ const signature = JSON.stringify(actions);
25
+ if (signature === actionsSignature) return;
26
+ actionsSignature = signature;
27
+ node('actions').replaceChildren();
28
+ actions.forEach((action) => {
29
+ if (!allowedActions.includes(action.key)) return;
30
+ const form = document.createElement('form');
31
+ form.method = 'post';
32
+ form.action = base + '/' + action.key;
33
+ const button = document.createElement('button');
34
+ button.className =
35
+ 'button ' + (action.primary ? 'button--primary' : 'button--subtle');
36
+ button.textContent = action.label;
37
+ form.append(button);
38
+ node('actions').append(form);
39
+ });
40
+ }
41
+
42
+ function renderStep(step) {
43
+ const item = document.createElement('li');
44
+ item.className = 'step';
45
+ item.dataset.stepState = step.state;
46
+ if (step.state === 'active') item.setAttribute('aria-current', 'step');
47
+ const bullet = document.createElement('span');
48
+ bullet.className = 'step-bullet';
49
+ bullet.setAttribute('aria-hidden', 'true');
50
+ const label = document.createElement('span');
51
+ label.textContent = step.label;
52
+ const outcome = document.createElement('span');
53
+ outcome.className = 'step-outcome';
54
+ outcome.textContent = step.state;
55
+ item.append(bullet, label, outcome);
56
+ return item;
57
+ }
58
+
59
+ function updateLog(data) {
60
+ if (data.log_source !== selectedLog) return;
61
+ const log = node('log');
62
+ const bottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 18;
63
+ log.textContent = data.log_tail || '';
64
+ if (bottom) log.scrollTop = log.scrollHeight;
65
+ node('updated').textContent = data.updated_at || '';
66
+ }
67
+
68
+ function update(data) {
69
+ const view = data.presentation;
70
+ if (!view)
71
+ throw new Error('Restart the front door to load the updated status API.');
72
+ busy = view.busy;
73
+ toggleBusyState(busy);
74
+ started = Date.parse(data.prepare_started_at);
75
+ completed = Date.parse(data.completed_at);
76
+ renderStatus(view);
77
+ node('message').textContent = data.message || '';
78
+ node('progress').replaceChildren(...view.steps.map(renderStep));
79
+ renderActions(view.actions);
80
+ updateLog(data);
81
+ }
82
+
83
+ function renderStatus(view) {
84
+ node('status').textContent = view.title;
85
+ node('activity').hidden = !view.busy;
86
+ node('preview').hidden = !view.ready;
87
+ node('error').textContent = view.error || '';
88
+ node('error-panel').hidden = !view.error;
89
+ if (window.WorkspaceEnvironment)
90
+ window.WorkspaceEnvironment.updateStatus(view.busy);
91
+ }
92
+
93
+ async function poll() {
94
+ const controller = new AbortController();
95
+ const timeout = setTimeout(() => controller.abort(), 10000);
96
+ try {
97
+ const response = await fetch(base + '/status?log=' + selectedLog, {
98
+ credentials: 'same-origin',
99
+ signal: controller.signal
100
+ });
101
+ if (!response.ok)
102
+ throw new Error('Status unavailable. Retrying automatically.');
103
+ update(await response.json());
104
+ node('connection').textContent = '';
105
+ } catch (error) {
106
+ node('connection').textContent = error.message;
107
+ } finally {
108
+ clearTimeout(timeout);
109
+ setTimeout(poll, busy || selectedLog === 'backend' ? 2000 : 10000);
110
+ }
111
+ }
112
+
113
+ function tick() {
114
+ if (Number.isFinite(started)) {
115
+ const end = busy ? Date.now() : completed;
116
+ const seconds = Math.max(0, Math.floor((end - started) / 1000));
117
+ if (Number.isFinite(seconds))
118
+ node('elapsed').textContent =
119
+ Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
120
+ }
121
+ setTimeout(tick, 1000);
122
+ }
123
+
124
+ root.addEventListener('submit', (event) => {
125
+ if (event.target.id !== 'environment-form') toggleBusyState(true);
126
+ node('connection').textContent = 'Submitting…';
127
+ });
128
+
129
+ toggleBusyState(busy);
130
+ tick();
131
+ setTimeout(poll, 1000);
132
+ })();
@@ -0,0 +1,22 @@
1
+ <main class="dashboard">
2
+ <header class="dashboard__header"><h1>GitHub access needs setup</h1><a href="/workspaces">All workspaces</a></header>
3
+ <section class="error-panel" role="alert">
4
+ <h2>Checkout couldn't authenticate</h2>
5
+ <p>The server needs Git credentials to fetch this repository. GitHub CLI access alone may not configure Git.</p>
6
+ </section>
7
+ <section class="status-panel" aria-labelledby="fix-heading">
8
+ <h2 id="fix-heading">Fix on the server</h2>
9
+ <ol>
10
+ <li>Open a terminal in the app container, as the user running Workspaces.</li>
11
+ <li>Check GitHub CLI authentication: <code>gh auth status</code>. If needed, authenticate with <code>gh auth login</code>.</li>
12
+ <li>Connect Git to those credentials:</li>
13
+ </ol>
14
+ <pre><code>gh auth setup-git --hostname github.com</code></pre>
15
+ <p>For SSH remotes, configure a key authorized for the repository instead. Persist the credential setup in the container configuration.</p>
16
+ <% if @retry_path %>
17
+ <form method="post" action="<%= escape(@retry_path) %>"><button class="button button--primary">Retry checkout</button></form>
18
+ <% else %>
19
+ <a class="button button--primary" href="/workspaces">Back to workspace creation</a>
20
+ <% end %>
21
+ </section>
22
+ </main>
@@ -0,0 +1,120 @@
1
+ (function () {
2
+ 'use strict';
3
+ const form = document.getElementById('environment-form');
4
+ if (!form) return;
5
+ const list = form.querySelector('[data-env-add-list]');
6
+ const addButton = form.querySelector('[data-env-add-row]');
7
+ const status = document.getElementById('environment-status');
8
+ const namePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ let rowIndex = 0;
10
+
11
+ function toggleBusy(nextBusy) {
12
+ form
13
+ .querySelectorAll('fieldset, [data-env-submit], [data-env-add-row]')
14
+ .forEach((control) => {
15
+ control.disabled = nextBusy;
16
+ });
17
+ }
18
+
19
+ function updateStatus(busy) {
20
+ if (busy)
21
+ status.textContent =
22
+ 'Workspace is busy. Environment updates are temporarily disabled.';
23
+ }
24
+
25
+ function syncReplace(toggle) {
26
+ const input = toggle
27
+ .closest('[data-env-existing]')
28
+ .querySelector('[data-replace-value]');
29
+ input.disabled = !toggle.checked;
30
+ if (!toggle.checked) input.value = '';
31
+ }
32
+
33
+ function labelledInput(id, label, type) {
34
+ const wrap = document.createElement('p');
35
+ const caption = document.createElement('label');
36
+ caption.className = 'env-input-label';
37
+ caption.htmlFor = id;
38
+ caption.textContent = label;
39
+ const input = document.createElement('input');
40
+ input.id = id;
41
+ input.type = type;
42
+ input.autocomplete = 'off';
43
+ wrap.append(caption, input);
44
+ return [wrap, input];
45
+ }
46
+
47
+ function addVariableRow() {
48
+ const row = document.createElement('li');
49
+ row.className = 'env-add-item';
50
+ row.dataset.envAddRow = String(rowIndex);
51
+ const [keyWrap, key] = labelledInput(
52
+ 'env-add-key-' + rowIndex,
53
+ 'Variable name',
54
+ 'text'
55
+ );
56
+ key.spellcheck = false;
57
+ key.dataset.envNewName = 'true';
58
+ key.pattern = '[A-Za-z_][A-Za-z0-9_]*';
59
+ key.title =
60
+ 'Use letters, numbers, and underscores; must not start with a number.';
61
+ const [valueWrap, value] = labelledInput(
62
+ 'env-add-value-' + rowIndex,
63
+ 'Value',
64
+ 'password'
65
+ );
66
+ value.dataset.envNewValue = 'true';
67
+ rowIndex += 1;
68
+ const fields = document.createElement('div');
69
+ fields.className = 'env-add-grid';
70
+ fields.append(keyWrap, valueWrap);
71
+ const actions = document.createElement('div');
72
+ actions.className = 'env-row-actions';
73
+ const remove = document.createElement('button');
74
+ remove.type = 'button';
75
+ remove.className = 'button button--subtle button--icon';
76
+ remove.textContent = 'Remove';
77
+ remove.setAttribute('aria-label', 'Remove variable row');
78
+ remove.addEventListener('click', () => row.remove());
79
+ actions.append(remove);
80
+ row.append(fields, actions);
81
+ list.append(row);
82
+ key.focus();
83
+ }
84
+
85
+ function appendSetValue(row) {
86
+ const key = row.querySelector('[data-env-new-name]').value.trim();
87
+ const value = row.querySelector('[data-env-new-value]');
88
+ if (key && namePattern.test(key)) {
89
+ const hidden = document.createElement('input');
90
+ hidden.type = 'hidden';
91
+ hidden.dataset.generatedSet = 'true';
92
+ hidden.name = 'set[' + key + ']';
93
+ hidden.value = value.value;
94
+ form.append(hidden);
95
+ }
96
+ }
97
+
98
+ function prepareSubmit() {
99
+ form.querySelectorAll('[data-env-existing]').forEach((row) => {
100
+ const toggle = row.querySelector('[data-replace-toggle]');
101
+ if (!toggle.checked) row.querySelector('[data-replace-value]').value = '';
102
+ });
103
+ form
104
+ .querySelectorAll('[data-generated-set]')
105
+ .forEach((input) => input.remove());
106
+ form.querySelectorAll('li[data-env-add-row]').forEach(appendSetValue);
107
+ }
108
+
109
+ form.querySelectorAll('[data-replace-toggle]').forEach((toggle) => {
110
+ toggle.addEventListener('change', () => syncReplace(toggle));
111
+ syncReplace(toggle);
112
+ });
113
+ addButton.addEventListener('click', addVariableRow);
114
+ form.addEventListener('submit', () => {
115
+ prepareSubmit();
116
+ status.textContent = 'Submitting environment overrides…';
117
+ });
118
+ window.WorkspaceEnvironment = { toggleBusy, updateStatus };
119
+ toggleBusy(document.getElementById('workspace').dataset.busy === 'true');
120
+ })();
@@ -0,0 +1,37 @@
1
+ <main class="dashboard">
2
+ <header class="dashboard__header">
3
+ <div><p class="eyebrow">PR preview</p><h1>PR #<%= escape(@number) %></h1></div>
4
+ <a href="/workspaces" class="button button--subtle">All workspaces</a>
5
+ </header>
6
+ <% unless @workspaces.empty? %>
7
+ <section class="status-panel" aria-labelledby="existing-heading">
8
+ <h2 id="existing-heading"><%= @workspaces.one? ? 'Continue in your workspace' : 'Choose a workspace' %></h2>
9
+ <p>Open an existing workspace to view its status, run it, or continue working.</p>
10
+ <% @workspaces.each do |workspace| %>
11
+ <article>
12
+ <h3><%= escape(workspace.fetch('id')) %></h3>
13
+ <p><%= escape(workspace['branch']) %></p>
14
+ <a href="/workspaces/<%= escape(workspace.fetch('id')) %>" class="button button--primary" aria-label="Open workspace <%= escape(workspace.fetch('id')) %>">Open workspace</a>
15
+ </article>
16
+ <% end %>
17
+ </section>
18
+ <% end %>
19
+ <% if @workspaces.empty? %>
20
+ <section class="status-panel" aria-labelledby="create-heading">
21
+ <h2 id="create-heading">Preview this pull request</h2>
22
+ <p>No workspaces yet for this PR.</p>
23
+ <% else %>
24
+ <details class="status-panel">
25
+ <summary>Create another workspace</summary>
26
+ <% end %>
27
+ <p>Create a fresh workspace from the PR's current head and start its server. Existing workspaces and edits stay untouched.</p>
28
+ <form method="post" action="/pr/<%= escape(@number) %>">
29
+ <button class="button <%= @workspaces.empty? ? 'button--primary' : 'button--subtle' %>">Create and start workspace</button>
30
+ </form>
31
+ <p class="muted">Creation fetches the PR. Repository setup steps continue on the workspace dashboard.</p>
32
+ <% if @workspaces.empty? %>
33
+ </section>
34
+ <% else %>
35
+ </details>
36
+ <% end %>
37
+ </main>