@hyperdreamer/pi-webui 1.15.2 → 1.16.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.
Files changed (62) hide show
  1. package/dist/client/assets/{CodeViewer-CHZziPur.js → CodeViewer-CCHq9DpL.js} +1 -1
  2. package/dist/client/assets/{UnifiedDiffViewer-j1-AFZ8f.js → UnifiedDiffViewer-JFQMCnzP.js} +1 -1
  3. package/dist/client/assets/{index-2RMTbgnM.js → index-WjewjFVt.js} +469 -469
  4. package/dist/client/index.html +1 -1
  5. package/dist/config.js +10 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/configMutationCoordinator.js +10 -1
  8. package/dist/configMutationCoordinator.js.map +1 -1
  9. package/dist/pi-webui-plugins/workspace-tasks/config.js +3 -132
  10. package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +14 -2
  11. package/dist/pi-webui-plugins/workspace-tasks/taskDomain.js +203 -0
  12. package/dist/pi-webui-plugins/workspace-tasks/taskRunner.js +3 -2
  13. package/dist/pi-webui-plugins/workspace-tasks/tasksPanelElement.js +947 -673
  14. package/dist/server/app.js +20 -4
  15. package/dist/server/app.js.map +1 -1
  16. package/dist/server/configRoutes.js +5 -0
  17. package/dist/server/configRoutes.js.map +1 -1
  18. package/dist/server/machines/machineProxyRoutes.js +4 -4
  19. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  20. package/dist/server/workspaceExplorerRoutes.js +64 -4
  21. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  22. package/dist/server/workspaceTasks/workspaceTasksCatalogService.js +318 -0
  23. package/dist/server/workspaceTasks/workspaceTasksCatalogService.js.map +1 -0
  24. package/dist/server/workspaceTasks/workspaceTasksComposition.js +67 -0
  25. package/dist/server/workspaceTasks/workspaceTasksComposition.js.map +1 -0
  26. package/dist/server/workspaceTasks/workspaceTasksErrors.js +34 -0
  27. package/dist/server/workspaceTasks/workspaceTasksErrors.js.map +1 -0
  28. package/dist/server/workspaceTasks/workspaceTasksGlobalCatalogAdapter.js +169 -0
  29. package/dist/server/workspaceTasks/workspaceTasksGlobalCatalogAdapter.js.map +1 -0
  30. package/dist/server/workspaceTasks/workspaceTasksGlobalMutationGate.js +75 -0
  31. package/dist/server/workspaceTasks/workspaceTasksGlobalMutationGate.js.map +1 -0
  32. package/dist/server/workspaceTasks/workspaceTasksMoveProtocol.js +313 -0
  33. package/dist/server/workspaceTasks/workspaceTasksMoveProtocol.js.map +1 -0
  34. package/dist/server/workspaceTasks/workspaceTasksMoveRegistry.js +352 -0
  35. package/dist/server/workspaceTasks/workspaceTasksMoveRegistry.js.map +1 -0
  36. package/dist/server/workspaceTasks/workspaceTasksProxyRoutes.js +63 -0
  37. package/dist/server/workspaceTasks/workspaceTasksProxyRoutes.js.map +1 -0
  38. package/dist/server/workspaceTasks/workspaceTasksRoutes.js +153 -0
  39. package/dist/server/workspaceTasks/workspaceTasksRoutes.js.map +1 -0
  40. package/dist/server/workspaceTasks/workspaceTasksWorkspaceCatalogAdapter.js +171 -0
  41. package/dist/server/workspaceTasks/workspaceTasksWorkspaceCatalogAdapter.js.map +1 -0
  42. package/dist/server/workspaceTasks/workspaceTasksWorkspaceFile.js +227 -0
  43. package/dist/server/workspaceTasks/workspaceTasksWorkspaceFile.js.map +1 -0
  44. package/dist/server/workspaceTasks/workspaceTasksWorkspacePathGate.js +42 -0
  45. package/dist/server/workspaceTasks/workspaceTasksWorkspacePathGate.js.map +1 -0
  46. package/dist/server/workspaces/fileContentService.js +116 -6
  47. package/dist/server/workspaces/fileContentService.js.map +1 -1
  48. package/dist/shared/apiTypes.d.ts +132 -0
  49. package/dist/shared/apiTypes.js.map +1 -1
  50. package/dist/shared/federatedRoutes.js +15 -0
  51. package/dist/shared/federatedRoutes.js.map +1 -1
  52. package/dist/shared/workspaceFiles.js +2 -0
  53. package/dist/shared/workspaceFiles.js.map +1 -1
  54. package/dist/shared/workspaceTasks.d.ts +53 -0
  55. package/dist/shared/workspaceTasks.js +203 -0
  56. package/dist/shared/workspaceTasks.js.map +1 -0
  57. package/dist/shared/workspaceTasksApi.js +379 -0
  58. package/dist/shared/workspaceTasksApi.js.map +1 -0
  59. package/docs/config.md +22 -4
  60. package/docs/plugins.md +24 -13
  61. package/package.json +1 -1
  62. package/dist/pi-webui-plugins/workspace-tasks/workspaceTasksClient.js +0 -242
@@ -1,255 +1,279 @@
1
1
  // Generated from pi-webui-plugins/workspace-tasks/tasksPanelElement.ts. Do not edit directly.
2
- import { TASKS_CONFIG_PATH, appendWorkspaceTask, emptyWorkspaceTasksConfig, removeWorkspaceTaskAt, replaceWorkspaceTaskAt, suggestWorkspaceTaskId, validateAndNormalizeDraft, } from "./config.js";
2
+ import { TASKS_CONFIG_PATH, parseWorkspaceTaskRefKey, workspaceTaskGroupKey, workspaceTaskRefKey, } from "./taskDomain.js?v=b6542ca5f17b";
3
+ import { validateAndNormalizeDraft, suggestWorkspaceTaskId } from "./config.js";
3
4
  import { runWorkspaceTaskInTerminal } from "./taskRunner.js";
4
- import { ensureWorkspaceTasksConfig, getWorkspaceTasksCacheEntry, guardedWriteWorkspaceTasksConfig, refreshWorkspaceTasksConfig, subscribeWorkspaceTasksConfig, } from "./workspaceTasksClient.js";
5
5
  export const tasksPanelTagName = "pi-webui-workspace-tasks-panel";
6
6
  export function defineTasksPanelElement() {
7
- if (!customElements.get(tasksPanelTagName))
7
+ if (typeof customElements !== "undefined" && !customElements.get(tasksPanelTagName)) {
8
8
  customElements.define(tasksPanelTagName, PiWebUiTasksPanel);
9
+ }
9
10
  }
10
11
  export function tasksPanelBadge(context) {
11
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
12
- return entry !== undefined && (entry.state.kind === "unavailable" || entry.refreshRequired) ? "!" : undefined;
12
+ // The bundled contribution receives this bridge only from the core app.
13
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
14
+ const bridge = context.workspaceTasks;
15
+ if (bridge === undefined)
16
+ return undefined;
17
+ const state = bridge.state;
18
+ if (state.move !== undefined || state.moveError !== undefined || state.mutationGate !== undefined)
19
+ return "!";
20
+ if (catalogNeedsAttention(state.workspace) || catalogNeedsAttention(state.global))
21
+ return "!";
22
+ return undefined;
23
+ }
24
+ // Non-browser plugin activation tests still import this module, so keep the
25
+ // custom element base safe when DOM globals are not installed.
26
+ // eslint-disable-next-line @typescript-eslint/no-extraneous-class
27
+ class NoopElement {
13
28
  }
14
- class PiWebUiTasksPanel extends HTMLElement {
29
+ function noopElementConstructor() {
30
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
31
+ return NoopElement;
32
+ }
33
+ const BaseElement = typeof HTMLElement === "undefined" ? noopElementConstructor() : HTMLElement;
34
+ class PiWebUiTasksPanel extends BaseElement {
15
35
  contextValue;
16
- runningTaskId;
17
- status;
36
+ stateValue = emptyPanelState();
37
+ actionsValue = noOpActions();
38
+ filter = "all";
18
39
  mode = "view";
19
40
  operation;
20
41
  editor;
21
42
  deleteState;
22
- resetState;
23
- failure;
43
+ pendingRefreshFocus;
24
44
  validationErrors;
45
+ status;
46
+ runningTaskKey;
47
+ pendingAction;
25
48
  idManuallyEdited = false;
26
- pendingRefreshFocus;
27
- operationGeneration = 0;
28
- terminalRunGeneration = 0;
29
- panelRefreshRequired = false;
30
- selectionGeneration = 0;
31
49
  connected = false;
32
- unsubscribe;
50
+ selectionGeneration = 0;
51
+ terminalGeneration = 0;
52
+ expandedGroupKeys = new Set();
53
+ moveRecoveryObserved = false;
33
54
  root;
34
- onConfigChanged = (workspaceKey) => {
35
- const context = this.contextValue;
36
- if (!this.connected || context === undefined || cacheKeyForContext(context) !== workspaceKey)
37
- return;
38
- context.host.requestRender();
39
- this.render();
40
- };
41
- onKeyDown = (event) => {
42
- if (event.key !== "Escape" || this.operation !== undefined)
43
- return;
44
- if (this.mode === "add" || this.mode === "edit" || (this.mode === "conflicted" && this.failure?.action === "editor") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "editor")) {
45
- event.preventDefault();
46
- event.stopPropagation();
47
- this.cancelEditor();
48
- return;
49
- }
50
- if (this.mode === "delete-confirm" || (this.mode === "conflicted" && this.failure?.action === "delete") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "delete")) {
51
- event.preventDefault();
52
- event.stopPropagation();
53
- this.cancelDelete();
54
- return;
55
- }
56
- if (this.mode === "reset-confirm" || (this.mode === "conflicted" && this.failure?.action === "reset") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "reset")) {
57
- event.preventDefault();
58
- event.stopPropagation();
59
- this.cancelReset();
60
- return;
61
- }
62
- if (this.mode === "refresh-discard-confirm") {
63
- event.preventDefault();
64
- event.stopPropagation();
65
- this.cancelRefreshDiscard();
66
- }
67
- };
68
55
  constructor() {
69
56
  super();
70
57
  this.root = this.attachShadow({ mode: "open" });
71
58
  this.root.addEventListener("keydown", (event) => {
72
59
  if (event instanceof KeyboardEvent)
73
- this.onKeyDown(event);
60
+ this.handleKeyDown(event);
74
61
  });
75
62
  }
76
63
  set context(value) {
77
- const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
78
- const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
64
+ const previousKey = contextKey(this.contextValue);
65
+ const nextKey = contextKey(value);
79
66
  this.contextValue = value;
80
- if (previousKey === nextKey)
67
+ if (previousKey === nextKey) {
68
+ this.render();
81
69
  return;
70
+ }
82
71
  this.selectionGeneration += 1;
83
- this.operationGeneration += 1;
84
- this.terminalRunGeneration += 1;
72
+ this.terminalGeneration += 1;
85
73
  this.operation = undefined;
86
- this.runningTaskId = undefined;
87
- this.mode = "view";
74
+ this.pendingAction = undefined;
88
75
  this.editor = undefined;
89
76
  this.deleteState = undefined;
90
- this.resetState = undefined;
91
- this.failure = undefined;
92
- this.validationErrors = undefined;
93
77
  this.pendingRefreshFocus = undefined;
94
- this.idManuallyEdited = false;
95
- this.panelRefreshRequired = false;
78
+ this.validationErrors = undefined;
96
79
  this.status = undefined;
80
+ this.runningTaskKey = undefined;
81
+ this.mode = "view";
82
+ this.filter = "all";
83
+ this.idManuallyEdited = false;
84
+ this.moveRecoveryObserved = false;
97
85
  this.render();
98
86
  }
87
+ set workspaceTasksState(value) {
88
+ const previous = this.stateValue;
89
+ this.stateValue = value;
90
+ this.recordPendingSourceObservations(value);
91
+ if (value.move !== undefined)
92
+ this.moveRecoveryObserved = true;
93
+ this.rememberOpenGroups();
94
+ this.pruneExpandedGroups();
95
+ if (this.reconcilePendingAction())
96
+ return;
97
+ if (previous.move !== undefined && value.move === undefined && this.moveRecoveryObserved && this.editor !== undefined) {
98
+ if (this.isMoveComplete(this.editor, value)) {
99
+ const target = this.editor.focusReturn;
100
+ this.editor = undefined;
101
+ this.mode = "view";
102
+ this.validationErrors = undefined;
103
+ this.moveRecoveryObserved = false;
104
+ this.status = { kind: "success", message: "Task move completed." };
105
+ this.render();
106
+ this.focusTarget(target);
107
+ return;
108
+ }
109
+ this.status = { kind: "info", message: "Move was not completed. Confirm the move again after reviewing the catalogs." };
110
+ }
111
+ this.render();
112
+ }
113
+ get workspaceTasksState() {
114
+ return this.stateValue;
115
+ }
116
+ set workspaceTasksActions(value) {
117
+ this.actionsValue = value;
118
+ this.render();
119
+ }
120
+ get workspaceTasksActions() {
121
+ return this.actionsValue;
122
+ }
99
123
  connectedCallback() {
100
124
  this.connected = true;
101
- this.unsubscribe = subscribeWorkspaceTasksConfig(this.onConfigChanged);
102
125
  this.render();
103
126
  }
104
127
  disconnectedCallback() {
105
128
  this.connected = false;
106
129
  this.selectionGeneration += 1;
107
- this.operationGeneration += 1;
108
- this.terminalRunGeneration += 1;
130
+ this.terminalGeneration += 1;
109
131
  this.operation = undefined;
110
- this.runningTaskId = undefined;
111
- this.unsubscribe?.();
112
- this.unsubscribe = undefined;
132
+ this.pendingAction = undefined;
133
+ this.runningTaskKey = undefined;
113
134
  }
114
135
  render() {
115
- const context = this.contextValue;
116
- if (context === undefined) {
136
+ this.rememberOpenGroups();
137
+ this.pruneExpandedGroups();
138
+ if (this.contextValue === undefined) {
117
139
  this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
118
140
  return;
119
141
  }
120
- const entry = ensureWorkspaceTasksConfig(context.files, cacheKeyForContext(context));
121
- const refreshDisabled = this.operation !== undefined || this.isConfirmationMode();
122
- const showAdd = this.mode === "view"
123
- && !this.isRefreshRequired(entry)
124
- && (entry.state.kind === "loaded" || entry.state.kind === "missing");
125
- const showReset = this.mode === "view" && !this.isRefreshRequired(entry) && entry.state.kind === "invalid";
142
+ const canAdd = this.canWriteScope("workspace") || this.canWriteScope("global");
143
+ const toolbarDisabled = this.operation !== undefined || this.mode === "refresh-discard-confirm";
126
144
  this.root.innerHTML = `
127
145
  ${taskStyles()}
128
146
  <main class="tasks-panel" data-panel-mode="${escapeAttr(this.mode)}">
129
147
  <section class="toolbar">
130
148
  <h2 data-panel-heading tabindex="-1">Workspace Tasks</h2>
131
149
  <span class="toolbar-tasks">
132
- ${showAdd ? `<button type="button" class="secondary" data-add-task>Add Task</button>` : ""}
133
- <button type="button" class="secondary" data-refresh-config ${refreshDisabled ? "disabled" : ""}>Refresh</button>
150
+ ${canAdd ? `<button type="button" class="secondary" data-add-task>Add Task</button>` : ""}
151
+ <button type="button" class="secondary" data-refresh ${toolbarDisabled ? "disabled" : ""}>Refresh</button>
134
152
  <button type="button" class="secondary" data-open-terminal>Open Terminal</button>
135
153
  </span>
136
154
  </section>
137
155
  ${this.renderStatus()}
138
156
  <section class="viewer tasks-viewer">
139
- ${this.renderBody(entry, showReset)}
157
+ ${this.renderBody()}
140
158
  </section>
141
159
  </main>
142
160
  `;
143
- this.bindHandlers(context);
161
+ this.bindHandlers();
144
162
  }
145
- isRefreshRequired(entry) {
146
- return this.panelRefreshRequired || entry.refreshRequired;
147
- }
148
- isConfirmationMode() {
149
- return this.mode === "delete-confirm" || this.mode === "reset-confirm" || this.mode === "refresh-discard-confirm";
163
+ renderStatus() {
164
+ const parts = [];
165
+ const gate = this.stateValue.mutationGate;
166
+ if (gate !== undefined) {
167
+ parts.push(`<div class="status warning" data-mutation-gate role="status">${escapeHtml(gate.message)}</div>`);
168
+ }
169
+ const move = this.stateValue.move;
170
+ if (move !== undefined) {
171
+ const retryDisabled = !move.retryAllowed || this.operation !== undefined;
172
+ parts.push(`<div class="status warning" data-move-state role="status">
173
+ <strong>${escapeHtml(move.kind === "partial" ? "Move recovery pending." : move.kind === "conflict" ? "Manual resolution required." : "Move outcome needs verification.")}</strong>
174
+ <p>${escapeHtml(move.message)}</p>
175
+ ${move.kind === "conflict" ? `<p data-manual-resolution>Refresh the catalogs and resolve the definitions manually.</p>` : ""}
176
+ <button type="button" class="secondary" data-retry-move ${retryDisabled ? "disabled" : ""}>Retry move</button>
177
+ </div>`);
178
+ }
179
+ const moveError = this.stateValue.moveError;
180
+ if (moveError !== undefined) {
181
+ parts.push(`<div class="status error" data-move-error role="status"><strong>Move was not applied.</strong><p>${escapeHtml(moveError.message)}</p></div>`);
182
+ }
183
+ if (this.status !== undefined) {
184
+ const detail = this.status.detail === undefined ? "" : `<pre class="diagnostic">${escapeHtml(this.status.detail)}</pre>`;
185
+ parts.push(`<div class="status panel-status ${escapeAttr(this.status.kind)}" data-panel-status role="status" aria-live="polite" tabindex="-1">${escapeHtml(this.status.message)}${detail}</div>`);
186
+ }
187
+ return parts.join("");
150
188
  }
151
- renderBody(entry, showReset) {
189
+ renderBody() {
152
190
  if (this.mode === "add" || this.mode === "edit")
153
- return this.renderEditor(entry, false);
191
+ return this.renderEditor();
154
192
  if (this.mode === "delete-confirm")
155
- return this.renderDeleteConfirmation(false);
156
- if (this.mode === "reset-confirm")
157
- return this.renderResetConfirmation(false);
193
+ return this.renderDeleteConfirmation();
194
+ if (this.mode === "move-confirm")
195
+ return this.renderMoveConfirmation();
158
196
  if (this.mode === "refresh-discard-confirm")
159
197
  return this.renderRefreshDiscardConfirmation();
160
- if (this.mode === "conflicted" || this.mode === "needs-refresh-after-write") {
161
- const action = this.failure?.action;
162
- if (action === "editor")
163
- return this.renderEditor(entry, true);
164
- if (action === "delete")
165
- return this.renderDeleteConfirmation(true);
166
- if (action === "reset")
167
- return this.renderResetConfirmation(true);
198
+ return this.renderView();
199
+ }
200
+ renderView() {
201
+ const workspaceCount = loadedCount(this.stateValue.workspace);
202
+ const globalCount = loadedCount(this.stateValue.global);
203
+ return `
204
+ <section class="scope-filters" role="group" aria-label="Task scope">
205
+ ${this.renderFilterButton("all", "All", workspaceCount + globalCount)}
206
+ ${this.renderFilterButton("global", "Global", globalCount)}
207
+ ${this.renderFilterButton("workspace", "Project", workspaceCount)}
208
+ </section>
209
+ <p class="muted helper">Tasks run as one script in one dedicated terminal rooted at the selected workspace. Use <code>set -e</code> or <code>&amp;&amp;</code> when the script should stop after a failure.</p>
210
+ <div class="catalog-list">
211
+ ${this.filter === "all" || this.filter === "global" ? this.renderCatalog("global", "Global tasks", this.stateValue.global) : ""}
212
+ ${this.filter === "all" || this.filter === "workspace" ? this.renderCatalog("workspace", "Project tasks", this.stateValue.workspace) : ""}
213
+ </div>
214
+ `;
215
+ }
216
+ renderFilterButton(filter, label, count) {
217
+ return `<button type="button" class="scope-filter ${this.filter === filter ? "selected" : ""}" data-filter="${filter}" aria-pressed="${this.filter === filter ? "true" : "false"}">${escapeHtml(label)} <span class="count">${String(count)}</span></button>`;
218
+ }
219
+ renderCatalog(scope, heading, catalog) {
220
+ const label = scopeLabel(scope);
221
+ const warning = catalog.kind === "loaded" || catalog.kind === "missing"
222
+ ? catalog.refreshError === undefined ? "" : `<p class="status warning" data-refresh-error>${escapeHtml(catalog.refreshError)}</p>`
223
+ : "";
224
+ let body;
225
+ if (catalog.kind === "loading") {
226
+ body = `<p class="muted" data-catalog-loading>Loading ${escapeHtml(label)} tasks...</p>`;
168
227
  }
169
- return this.renderConfigState(entry, showReset);
170
- }
171
- renderConfigState(entry, showReset) {
172
- const state = entry.state;
173
- if (state.kind === "loading")
174
- return `<p class="muted" data-loading>Loading ${escapeHtml(TASKS_CONFIG_PATH)}...</p>`;
175
- if (state.kind === "missing") {
176
- return `${this.renderRefreshRequired(entry)}<div class="empty-state"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p></div>`;
228
+ else if (catalog.kind === "invalid" || catalog.kind === "unavailable" || catalog.kind === "error") {
229
+ const hint = catalog.kind === "error" ? "" : `<p>${escapeHtml(catalog.hint)}</p>`;
230
+ const detail = catalog.kind === "invalid" || catalog.kind === "unavailable" && catalog.detail !== undefined
231
+ ? `<pre class="diagnostic">${escapeHtml(catalog.detail)}</pre>`
232
+ : "";
233
+ body = `<div class="status error" data-catalog-error><strong>${escapeHtml(catalog.message)}</strong>${hint}${detail}</div>`;
177
234
  }
178
- if (state.kind === "invalid") {
179
- return `
180
- ${this.renderRefreshRequired(entry)}
181
- <div class="status error" data-invalid-state>
182
- <strong>${escapeHtml(state.message)}</strong>
183
- <p>${escapeHtml(state.hint)}</p>
184
- <pre class="diagnostic">${escapeHtml(state.detail)}</pre>
185
- ${showReset ? `<button type="button" class="danger-secondary" data-reset-tasks-file>Reset Tasks File</button>` : ""}
186
- </div>
187
- `;
235
+ else if (catalog.kind === "missing") {
236
+ body = `${warning}<p class="empty-state"><strong>No Project task catalog is configured.</strong><span>Create ${escapeHtml(TASKS_CONFIG_PATH)} in this workspace or add a task here.</span></p>`;
188
237
  }
189
- if (state.kind === "unavailable") {
190
- return `
191
- <div class="status error" data-unavailable-state>
192
- <strong>${escapeHtml(state.message)}</strong>
193
- <p>${escapeHtml(state.hint)}</p>
194
- ${state.detail === undefined ? "" : `<pre class="diagnostic">${escapeHtml(state.detail)}</pre>`}
195
- </div>
196
- `;
238
+ else if (catalog.config.tasks.length === 0) {
239
+ body = `${warning}<p class="empty-state">No ${escapeHtml(label.toLowerCase())} tasks are defined.</p>`;
197
240
  }
198
- if (state.config.tasks.length === 0) {
199
- return `${this.renderRefreshRequired(entry)}<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
241
+ else {
242
+ body = `${warning}${renderTaskGroups(scope, catalog.config.tasks, this.runningTaskKey, this.isScopeDisabled(scope), this.expandedGroupKeys)}`;
200
243
  }
201
- return `
202
- ${this.renderRefreshRequired(entry)}
203
- <p class="muted">Tasks run as one script in one dedicated workspace terminal. Use <code>set -e</code> or <code>&amp;&amp;</code> when the script should stop after a failure.</p>
204
- ${renderTaskGroups(state.config.tasks, this.runningTaskId, this.isRefreshRequired(entry) || this.operation !== undefined)}
205
- `;
206
- }
207
- renderRefreshRequired(entry) {
208
- if (!this.isRefreshRequired(entry))
209
- return "";
210
- return `<div class="status warning" data-refresh-required role="status">Refresh the workspace tasks file before making another change.</div>`;
244
+ return `<section class="scope-catalog" data-catalog-scope="${scope}">
245
+ <h3>${escapeHtml(heading)} <span class="catalog-count">${String(loadedCount(catalog))}</span></h3>
246
+ ${body}
247
+ </section>`;
211
248
  }
212
- renderStatus() {
213
- if (this.status === undefined)
214
- return "";
215
- const detail = this.status.detail === undefined ? "" : `<pre class="diagnostic">${escapeHtml(this.status.detail)}</pre>`;
216
- return `<div class="status panel-status ${escapeAttr(this.status.kind)}" data-panel-status role="status" aria-live="polite" tabindex="-1">${escapeHtml(this.status.message)}${detail}</div>`;
217
- }
218
- renderEditor(entry, locked) {
249
+ renderEditor() {
219
250
  const editor = this.editor;
220
251
  if (editor === undefined)
221
252
  return "";
222
- const validation = this.validateEditor(entry);
253
+ const targetScope = editor.draft.global ? "global" : "workspace";
254
+ const validation = this.validateEditor();
223
255
  const errors = this.validationErrors ?? {};
224
- const titleError = errors.title;
225
- const commandError = errors.command;
226
- const idError = errors.id;
227
- const saveDisabled = locked
228
- || this.operation !== undefined
229
- || this.isRefreshRequired(entry)
230
- || (entry.state.kind !== "loaded" && entry.state.kind !== "missing")
231
- || !validation.ok;
232
- const titleDescribedBy = titleError === undefined ? "" : ` aria-describedby="task-title-error"`;
233
- const idDescribedBy = idError === undefined ? "" : ` aria-describedby="task-id-error"`;
234
- const commandDescribedBy = commandError === undefined ? "task-command-help" : "task-command-help task-command-error";
235
- const heading = editor.originalIndex === undefined ? "Add Task" : "Edit Task";
236
- const failureRefresh = locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : "";
256
+ const locked = this.operation !== undefined || this.stateValue.move !== undefined || this.isScopeDisabled(targetScope);
257
+ const canWrite = this.canWriteScope(targetScope);
258
+ const saveDisabled = locked || !canWrite || !validation.ok;
259
+ const heading = editor.sourceRef === undefined ? "Add Task" : "Edit Task";
260
+ const sourceLabel = editor.sourceRef === undefined ? scopeLabel(targetScope) : scopeLabel(editor.sourceRef.scope);
237
261
  return `
238
262
  <section class="task-editor" data-task-editor>
239
- <h3>${heading}</h3>
263
+ <div class="editor-heading"><h3>${heading}</h3><span class="scope-badge" data-scope-badge>${escapeHtml(`${sourceLabel} task`)}</span></div>
240
264
  <form class="task-form" data-task-form>
241
265
  <label for="task-title">Title <span class="required" aria-hidden="true">*</span></label>
242
- <input id="task-title" name="title" type="text" value="${escapeAttr(editor.draft.title)}" placeholder="Build app" data-editor-title aria-required="true"${titleError === undefined ? "" : ` aria-invalid="true"`}${titleDescribedBy} ${locked ? "disabled" : ""}>
243
- ${renderFieldError("title", titleError)}
266
+ <input id="task-title" name="title" type="text" value="${escapeAttr(editor.draft.title)}" placeholder="Build app" data-editor-title aria-required="true"${fieldAttrs("title", errors.title, locked)}>
267
+ ${renderFieldError("title", errors.title)}
244
268
 
245
269
  <label for="task-command">Command script <span class="required" aria-hidden="true">*</span></label>
246
- <textarea id="task-command" name="command" data-editor-command aria-required="true" aria-describedby="${commandDescribedBy}"${commandError === undefined ? "" : ` aria-invalid="true"`}${locked ? " disabled" : ""}>${escapeHtml(editor.draft.command)}</textarea>
270
+ <textarea id="task-command" name="command" data-editor-command aria-required="true" aria-describedby="task-command-help${errors.command === undefined ? "" : " task-command-error"}"${errors.command === undefined ? "" : " aria-invalid=\"true\""}${locked ? " disabled" : ""}>${escapeHtml(editor.draft.command)}</textarea>
247
271
  <p id="task-command-help" class="field-help">Runs once in one terminal through the server shell. Use <code>set -e</code> or <code>&amp;&amp;</code> for fail-fast behavior.</p>
248
- ${renderFieldError("command", commandError)}
272
+ ${renderFieldError("command", errors.command)}
249
273
 
250
274
  <label for="task-id">ID <span class="required" aria-hidden="true">*</span></label>
251
- <input id="task-id" name="id" type="text" value="${escapeAttr(editor.draft.id)}" placeholder="Auto-generated from title" data-editor-id aria-required="true"${idError === undefined ? "" : ` aria-invalid="true"`}${idDescribedBy} ${locked ? "disabled" : ""}>
252
- ${renderFieldError("id", idError)}
275
+ <input id="task-id" name="id" type="text" value="${escapeAttr(editor.draft.id)}" placeholder="Auto-generated from title" data-editor-id aria-required="true"${fieldAttrs("id", errors.id, locked)}>
276
+ ${renderFieldError("id", errors.id)}
253
277
 
254
278
  <label for="task-description">Description</label>
255
279
  <input id="task-description" name="description" type="text" value="${escapeAttr(editor.draft.description)}" placeholder="Optional description" data-editor-description ${locked ? "disabled" : ""}>
@@ -257,6 +281,12 @@ class PiWebUiTasksPanel extends HTMLElement {
257
281
  <label for="task-group">Group</label>
258
282
  <input id="task-group" name="group" type="text" value="${escapeAttr(editor.draft.group)}" placeholder="Optional group name" data-editor-group ${locked ? "disabled" : ""}>
259
283
 
284
+ <span class="checkbox-field">
285
+ <input id="task-global" name="global" type="checkbox" data-editor-global ${editor.draft.global ? "checked" : ""} ${locked ? "disabled" : ""}>
286
+ <label for="task-global">Available in all projects on this machine</label>
287
+ </span>
288
+ <p class="field-help" data-scope-help>Global definitions run in the currently selected workspace root. Project definitions are stored in ${escapeHtml(TASKS_CONFIG_PATH)} for this workspace.</p>
289
+
260
290
  <span class="checkbox-field">
261
291
  <input id="task-confirm" name="confirm" type="checkbox" ${editor.draft.confirm ? "checked" : ""} data-editor-confirm ${locked ? "disabled" : ""}>
262
292
  <label for="task-confirm">Require confirmation before running</label>
@@ -264,258 +294,219 @@ class PiWebUiTasksPanel extends HTMLElement {
264
294
 
265
295
  <div class="editor-actions">
266
296
  <button type="button" class="secondary" data-cancel-editor>Cancel</button>
267
- ${failureRefresh}
268
297
  <button type="button" class="primary" data-save-task ${saveDisabled ? "disabled" : ""}>Save Task</button>
269
298
  </div>
270
299
  </form>
271
300
  </section>
272
301
  `;
273
302
  }
274
- renderDeleteConfirmation(locked) {
275
- const state = this.deleteState;
276
- if (state === undefined)
303
+ renderDeleteConfirmation() {
304
+ const pending = this.deleteState;
305
+ if (pending === undefined)
277
306
  return "";
278
- return `
279
- <section class="confirmation" data-delete-confirmation>
280
- <h3>Delete Task</h3>
281
- <p>Are you sure you want to delete <strong>${escapeHtml(state.task.title)}</strong>?</p>
282
- <pre class="task-script">${escapeHtml(state.task.command)}</pre>
283
- <div class="editor-actions">
284
- <button type="button" class="secondary" data-cancel-delete>Cancel</button>
285
- ${locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : `<button type="button" class="danger" data-confirm-delete ${this.operation === undefined ? "" : "disabled"}>Delete Task</button>`}
286
- </div>
287
- </section>
288
- `;
307
+ const label = scopeLabel(pending.ref.scope);
308
+ return `<section class="confirmation" data-delete-confirmation>
309
+ <div class="editor-heading"><h3>Delete Task</h3><span class="scope-badge">${escapeHtml(`${label} task`)}</span></div>
310
+ <p>Are you sure you want to delete <strong>${escapeHtml(pending.task.title)}</strong> (${escapeHtml(label)})?</p>
311
+ <pre class="task-script">${escapeHtml(pending.task.command)}</pre>
312
+ <div class="editor-actions">
313
+ <button type="button" class="secondary" data-cancel-delete>Cancel</button>
314
+ <button type="button" class="danger" data-confirm-delete aria-label="Delete ${escapeAttr(pending.task.title)} (${escapeAttr(label)})" ${this.operation === undefined && !this.isScopeDisabled(pending.ref.scope) ? "" : "disabled"}>Delete Task</button>
315
+ </div>
316
+ </section>`;
289
317
  }
290
- renderResetConfirmation(locked) {
291
- if (this.resetState === undefined)
318
+ renderMoveConfirmation() {
319
+ const editor = this.editor;
320
+ if (editor?.sourceRef === undefined)
292
321
  return "";
293
- return `
294
- <section class="confirmation" data-reset-confirmation>
295
- <h3>Reset Tasks File</h3>
296
- <p>This replaces the invalid contents of <code>${escapeHtml(TASKS_CONFIG_PATH)}</code> with an empty version 1 tasks file.</p>
297
- <div class="editor-actions">
298
- <button type="button" class="secondary" data-cancel-reset>Cancel</button>
299
- ${locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : `<button type="button" class="danger" data-confirm-reset ${this.operation === undefined ? "" : "disabled"}>Reset Tasks File</button>`}
300
- </div>
301
- </section>
302
- `;
322
+ const source = scopeLabel(editor.sourceRef.scope);
323
+ const destination = scopeLabel(editor.draft.global ? "global" : "workspace");
324
+ const validation = this.validateEditor();
325
+ return `<section class="confirmation" data-move-confirmation>
326
+ <div class="editor-heading"><h3>Change Task Scope</h3><span class="scope-badge">${escapeHtml(`${source} to ${destination}`)}</span></div>
327
+ <p>Move <strong>${escapeHtml(editor.draft.title)}</strong> from ${escapeHtml(source)} to ${escapeHtml(destination)}? The source definition will be removed.</p>
328
+ <pre class="task-script">${escapeHtml(validation.ok ? validation.task.command : editor.draft.command)}</pre>
329
+ <div class="editor-actions">
330
+ <button type="button" class="secondary" data-cancel-move>Cancel</button>
331
+ <button type="button" class="primary" data-confirm-move ${this.operation === undefined && validation.ok ? "" : "disabled"}>Confirm move</button>
332
+ </div>
333
+ </section>`;
303
334
  }
304
335
  renderRefreshDiscardConfirmation() {
305
- return `
306
- <section class="confirmation" data-refresh-discard-confirmation>
307
- <h3>Discard draft and refresh?</h3>
308
- <p>Your unsaved task draft will be discarded. Refresh loads the authoritative workspace file.</p>
309
- <div class="editor-actions">
310
- <button type="button" class="secondary" data-cancel-refresh-discard>Cancel</button>
311
- <button type="button" class="primary" data-confirm-refresh-discard>Discard &amp; Refresh</button>
312
- </div>
313
- </section>
314
- `;
336
+ return `<section class="confirmation" data-refresh-discard-confirmation>
337
+ <h3>Discard draft and refresh?</h3>
338
+ <p>Your unsaved task draft will be discarded. Refresh loads the authoritative catalogs.</p>
339
+ <div class="editor-actions">
340
+ <button type="button" class="secondary" data-cancel-refresh-discard>Cancel</button>
341
+ <button type="button" class="primary" data-confirm-refresh-discard>Discard &amp; Refresh</button>
342
+ </div>
343
+ </section>`;
315
344
  }
316
- bindHandlers(context) {
317
- this.root.querySelector("button[data-add-task]")?.addEventListener("click", () => {
318
- this.openAddTaskEditor();
319
- });
320
- this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
321
- void this.requestRefresh(context);
322
- });
323
- this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => {
324
- this.openWorkspaceTerminal();
325
- });
326
- this.root.querySelector("button[data-reset-tasks-file]")?.addEventListener("click", () => {
327
- this.openResetConfirmation(context);
328
- });
329
- this.root.querySelector("button[data-cancel-editor]")?.addEventListener("click", () => {
330
- this.cancelEditor();
331
- });
332
- this.root.querySelector("button[data-save-task]")?.addEventListener("click", () => {
333
- void this.saveTask(context);
334
- });
335
- this.root.querySelector("button[data-cancel-delete]")?.addEventListener("click", () => {
336
- this.cancelDelete();
337
- });
338
- this.root.querySelector("button[data-confirm-delete]")?.addEventListener("click", () => {
339
- void this.confirmDelete(context);
340
- });
341
- this.root.querySelector("button[data-cancel-reset]")?.addEventListener("click", () => {
342
- this.cancelReset();
343
- });
344
- this.root.querySelector("button[data-confirm-reset]")?.addEventListener("click", () => {
345
- void this.confirmReset(context);
346
- });
347
- this.root.querySelector("button[data-cancel-refresh-discard]")?.addEventListener("click", () => {
348
- this.cancelRefreshDiscard();
349
- });
350
- this.root.querySelector("button[data-confirm-refresh-discard]")?.addEventListener("click", () => {
351
- void this.confirmRefreshDiscard(context);
352
- });
353
- this.root.querySelector("button[data-refresh-after-failure]")?.addEventListener("click", () => {
354
- void this.requestRefresh(context, true);
355
- });
356
- for (const button of this.root.querySelectorAll("button[data-task-id]")) {
345
+ bindHandlers() {
346
+ this.root.querySelector("[data-add-task]")?.addEventListener("click", () => { this.openAddTaskEditor(); });
347
+ this.root.querySelector("[data-refresh]")?.addEventListener("click", () => { void this.requestRefresh(); });
348
+ this.root.querySelector("[data-open-terminal]")?.addEventListener("click", () => { this.openWorkspaceTerminal(); });
349
+ this.root.querySelector("[data-retry-move]")?.addEventListener("click", () => { void this.retryMove(); });
350
+ this.root.querySelector("[data-cancel-editor]")?.addEventListener("click", () => { this.cancelEditor(); });
351
+ this.root.querySelector("[data-save-task]")?.addEventListener("click", () => { void this.saveTask(); });
352
+ this.root.querySelector("[data-cancel-delete]")?.addEventListener("click", () => { this.cancelDelete(); });
353
+ this.root.querySelector("[data-confirm-delete]")?.addEventListener("click", () => { void this.confirmDelete(); });
354
+ this.root.querySelector("[data-cancel-move]")?.addEventListener("click", () => { this.cancelMove(); });
355
+ this.root.querySelector("[data-confirm-move]")?.addEventListener("click", () => { void this.confirmMove(); });
356
+ this.root.querySelector("[data-cancel-refresh-discard]")?.addEventListener("click", () => { this.cancelRefreshDiscard(); });
357
+ this.root.querySelector("[data-confirm-refresh-discard]")?.addEventListener("click", () => { void this.confirmRefreshDiscard(); });
358
+ for (const button of this.root.querySelectorAll("button[data-filter]")) {
357
359
  button.addEventListener("click", () => {
358
- void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
360
+ const value = button.getAttribute("data-filter");
361
+ if (value === "all" || value === "global" || value === "workspace") {
362
+ this.filter = value;
363
+ this.render();
364
+ }
359
365
  });
360
366
  }
361
367
  for (const button of this.root.querySelectorAll("button[data-edit-task]")) {
362
- button.addEventListener("click", () => {
363
- this.openEditTaskEditor(context, button.getAttribute("data-edit-task"));
364
- });
368
+ button.addEventListener("click", () => { this.openEditTaskEditor(button.getAttribute("data-edit-task")); });
365
369
  }
366
370
  for (const button of this.root.querySelectorAll("button[data-delete-task]")) {
367
- button.addEventListener("click", () => {
368
- this.openDeleteConfirmation(context, button.getAttribute("data-delete-task"));
371
+ button.addEventListener("click", () => { this.openDeleteConfirmation(button.getAttribute("data-delete-task")); });
372
+ }
373
+ for (const button of this.root.querySelectorAll("button[data-run-task]")) {
374
+ button.addEventListener("click", () => { void this.dispatchTask(button.getAttribute("data-run-task")); });
375
+ }
376
+ for (const details of this.root.querySelectorAll("details[data-group-key]")) {
377
+ details.addEventListener("toggle", () => {
378
+ const key = details.getAttribute("data-group-key");
379
+ if (key === null)
380
+ return;
381
+ if (details.open)
382
+ this.expandedGroupKeys.add(key);
383
+ else
384
+ this.expandedGroupKeys.delete(key);
369
385
  });
370
386
  }
371
- const title = this.root.querySelector("input[data-editor-title]");
387
+ this.bindEditorInputs();
388
+ }
389
+ bindEditorInputs() {
390
+ const title = this.root.querySelector("[data-editor-title]");
372
391
  title?.addEventListener("input", (event) => {
373
392
  if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
374
393
  return;
375
394
  this.editor.draft.title = event.target.value;
376
395
  if (!this.idManuallyEdited) {
377
396
  this.editor.draft.id = suggestWorkspaceTaskId(event.target.value);
378
- const id = this.root.querySelector("input[data-editor-id]");
397
+ const id = this.root.querySelector("[data-editor-id]");
379
398
  if (id !== null)
380
399
  id.value = this.editor.draft.id;
381
400
  }
382
- this.updateValidationInPlace(context);
401
+ this.updateValidationInPlace();
383
402
  });
384
- const id = this.root.querySelector("input[data-editor-id]");
403
+ const id = this.root.querySelector("[data-editor-id]");
385
404
  id?.addEventListener("input", (event) => {
386
405
  if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
387
406
  return;
388
407
  this.editor.draft.id = event.target.value;
389
408
  this.idManuallyEdited = true;
390
- this.updateValidationInPlace(context);
409
+ this.updateValidationInPlace();
391
410
  });
392
- const command = this.root.querySelector("textarea[data-editor-command]");
411
+ const command = this.root.querySelector("[data-editor-command]");
393
412
  command?.addEventListener("input", (event) => {
394
413
  if (!(event.target instanceof HTMLTextAreaElement) || this.editor === undefined)
395
414
  return;
396
415
  this.editor.draft.command = event.target.value;
397
- this.updateValidationInPlace(context);
416
+ this.updateValidationInPlace();
398
417
  });
399
- const description = this.root.querySelector("input[data-editor-description]");
400
- description?.addEventListener("input", (event) => {
401
- if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
402
- return;
403
- this.editor.draft.description = event.target.value;
404
- this.updateValidationInPlace(context);
405
- });
406
- const group = this.root.querySelector("input[data-editor-group]");
407
- group?.addEventListener("input", (event) => {
418
+ for (const [selector, field] of [["[data-editor-description]", "description"], ["[data-editor-group]", "group"]]) {
419
+ this.root.querySelector(selector)?.addEventListener("input", (event) => {
420
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
421
+ return;
422
+ this.editor.draft[field] = event.target.value;
423
+ this.updateValidationInPlace();
424
+ });
425
+ }
426
+ this.root.querySelector("[data-editor-global]")?.addEventListener("change", (event) => {
408
427
  if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
409
428
  return;
410
- this.editor.draft.group = event.target.value;
411
- this.updateValidationInPlace(context);
429
+ this.editor.draft.global = event.target.checked;
430
+ this.updateValidationInPlace();
431
+ const badge = this.root.querySelector("[data-scope-badge]");
432
+ if (badge !== null)
433
+ badge.textContent = `${scopeLabel(this.editor.draft.global ? "global" : "workspace")} task`;
412
434
  });
413
- const confirm = this.root.querySelector("input[data-editor-confirm]");
414
- confirm?.addEventListener("change", (event) => {
435
+ this.root.querySelector("[data-editor-confirm]")?.addEventListener("change", (event) => {
415
436
  if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
416
437
  return;
417
438
  this.editor.draft.confirm = event.target.checked;
418
- this.updateValidationInPlace(context);
439
+ this.updateValidationInPlace();
419
440
  });
420
- this.root.querySelector("form[data-task-form]")?.addEventListener("submit", (event) => {
441
+ this.root.querySelector("[data-task-form]")?.addEventListener("submit", (event) => {
421
442
  event.preventDefault();
422
- void this.saveTask(context);
443
+ void this.saveTask();
423
444
  });
424
445
  }
425
446
  openAddTaskEditor() {
426
- const context = this.contextValue;
427
- if (context === undefined || this.operation !== undefined)
428
- return;
429
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
430
- if (entry === undefined || this.isRefreshRequired(entry) || (entry.state.kind !== "loaded" && entry.state.kind !== "missing"))
447
+ if (this.contextValue === undefined || this.operation !== undefined || !this.canWriteScope("workspace") && !this.canWriteScope("global"))
431
448
  return;
432
449
  const draft = emptyDraft();
433
- this.editor = {
434
- draft,
435
- initialDraft: cloneDraft(draft),
436
- sourceSnapshot: entry.state.snapshot,
437
- originalIndex: undefined,
438
- focusReturn: { kind: "add" },
439
- };
450
+ this.editor = { draft, initialDraft: cloneDraft(draft), sourceRef: undefined, originalIndex: undefined, focusReturn: { kind: "add" } };
440
451
  this.mode = "add";
441
- this.failure = undefined;
452
+ this.status = undefined;
442
453
  this.validationErrors = undefined;
443
454
  this.idManuallyEdited = false;
444
- this.status = undefined;
455
+ this.moveRecoveryObserved = false;
445
456
  this.render();
446
- this.focusSelector("input[data-editor-title]");
457
+ this.focusSelector("[data-editor-title]");
447
458
  }
448
- openEditTaskEditor(context, taskId) {
449
- if (!this.isCurrentContext(context) || this.operation !== undefined || taskId === null)
459
+ openEditTaskEditor(key) {
460
+ if (this.operation !== undefined || key === null)
450
461
  return;
451
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
452
- if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "loaded")
462
+ const ref = parseRef(key);
463
+ if (ref === undefined)
453
464
  return;
454
- const index = entry.state.config.tasks.findIndex((task) => task.id === taskId);
455
- const task = index < 0 ? undefined : entry.state.config.tasks[index];
456
- if (task === undefined) {
457
- this.setError("That task is no longer available. Click Refresh, then try again.");
465
+ const found = this.findTask(ref);
466
+ if (found === undefined) {
467
+ this.setError("That task is no longer available. Refresh, then try again.");
458
468
  return;
459
469
  }
460
470
  const draft = {
461
- id: task.id,
462
- title: task.title,
463
- command: task.command,
464
- description: task.description ?? "",
465
- group: task.group ?? "",
466
- confirm: task.confirm,
471
+ id: found.task.id,
472
+ title: found.task.title,
473
+ command: found.task.command,
474
+ description: found.task.description ?? "",
475
+ group: found.task.group ?? "",
476
+ confirm: found.task.confirm,
477
+ global: ref.scope === "global",
467
478
  };
468
479
  this.editor = {
469
480
  draft,
470
481
  initialDraft: cloneDraft(draft),
471
- sourceSnapshot: entry.state.snapshot,
472
- originalIndex: index,
473
- focusReturn: { kind: "edit", id: task.id },
482
+ sourceRef: ref,
483
+ originalIndex: found.index,
484
+ focusReturn: { kind: "edit", ref },
474
485
  };
475
486
  this.mode = "edit";
476
- this.failure = undefined;
487
+ this.status = undefined;
477
488
  this.validationErrors = undefined;
478
489
  this.idManuallyEdited = true;
479
- this.status = undefined;
490
+ this.moveRecoveryObserved = false;
480
491
  this.render();
481
- this.focusSelector("input[data-editor-title]");
492
+ this.focusSelector("[data-editor-title]");
482
493
  }
483
- openDeleteConfirmation(context, taskId) {
484
- if (!this.isCurrentContext(context) || this.operation !== undefined || taskId === null)
494
+ openDeleteConfirmation(key) {
495
+ if (this.operation !== undefined || key === null)
485
496
  return;
486
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
487
- if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "loaded")
497
+ const ref = parseRef(key);
498
+ if (ref === undefined)
488
499
  return;
489
- const index = entry.state.config.tasks.findIndex((task) => task.id === taskId);
490
- const task = index < 0 ? undefined : entry.state.config.tasks[index];
491
- if (task === undefined) {
492
- this.setError("That task is no longer available. Click Refresh, then try again.");
500
+ const found = this.findTask(ref);
501
+ if (found === undefined) {
502
+ this.setError("That task is no longer available. Refresh, then try again.");
493
503
  return;
494
504
  }
495
- this.deleteState = {
496
- task,
497
- index,
498
- sourceSnapshot: entry.state.snapshot,
499
- focusReturn: { kind: "delete", id: task.id },
500
- };
505
+ this.deleteState = { ref, task: cloneTask(found.task), focusReturn: { kind: "delete", ref } };
501
506
  this.mode = "delete-confirm";
502
- this.failure = undefined;
503
- this.status = undefined;
504
- this.render();
505
- this.focusSelector("button[data-cancel-delete]");
506
- }
507
- openResetConfirmation(context) {
508
- if (!this.isCurrentContext(context) || this.operation !== undefined)
509
- return;
510
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
511
- if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "invalid")
512
- return;
513
- this.resetState = { sourceSnapshot: entry.state.snapshot, focusReturn: { kind: "reset" } };
514
- this.mode = "reset-confirm";
515
- this.failure = undefined;
516
507
  this.status = undefined;
517
508
  this.render();
518
- this.focusSelector("button[data-cancel-reset]");
509
+ this.focusSelector("[data-cancel-delete]");
519
510
  }
520
511
  cancelEditor() {
521
512
  if (this.operation !== undefined)
@@ -524,7 +515,7 @@ class PiWebUiTasksPanel extends HTMLElement {
524
515
  this.editor = undefined;
525
516
  this.validationErrors = undefined;
526
517
  this.idManuallyEdited = false;
527
- this.failure = undefined;
518
+ this.moveRecoveryObserved = false;
528
519
  this.mode = "view";
529
520
  this.status = undefined;
530
521
  this.render();
@@ -535,104 +526,94 @@ class PiWebUiTasksPanel extends HTMLElement {
535
526
  return;
536
527
  const target = this.deleteState?.focusReturn;
537
528
  this.deleteState = undefined;
538
- this.failure = undefined;
539
529
  this.mode = "view";
540
530
  this.status = undefined;
541
531
  this.render();
542
532
  this.focusTarget(target);
543
533
  }
544
- cancelReset() {
545
- if (this.operation !== undefined)
534
+ cancelMove() {
535
+ if (this.operation !== undefined || this.editor === undefined)
546
536
  return;
547
- const target = this.resetState?.focusReturn;
548
- this.resetState = undefined;
549
- this.failure = undefined;
550
- this.mode = "view";
551
- this.status = undefined;
537
+ this.mode = this.editor.sourceRef === undefined ? "add" : "edit";
552
538
  this.render();
553
- this.focusTarget(target);
539
+ this.focusSelector("[data-editor-title]");
554
540
  }
555
541
  cancelRefreshDiscard() {
556
542
  if (this.operation !== undefined)
557
543
  return;
558
544
  const target = this.editor?.focusReturn;
559
- this.mode = this.editor === undefined ? "view" : this.editor.originalIndex === undefined ? "add" : "edit";
545
+ this.mode = this.editor?.sourceRef === undefined ? "add" : "edit";
560
546
  this.pendingRefreshFocus = undefined;
561
547
  this.render();
562
548
  this.focusTarget(target);
563
549
  }
564
- async confirmRefreshDiscard(context) {
550
+ async confirmRefreshDiscard() {
565
551
  if (this.mode !== "refresh-discard-confirm")
566
552
  return;
567
553
  const target = this.pendingRefreshFocus ?? { kind: "refresh" };
568
554
  this.editor = undefined;
569
555
  this.deleteState = undefined;
570
- this.resetState = undefined;
571
- this.failure = undefined;
572
556
  this.validationErrors = undefined;
573
557
  this.idManuallyEdited = false;
558
+ this.moveRecoveryObserved = false;
559
+ this.mode = "view";
574
560
  this.pendingRefreshFocus = target;
575
- await this.requestRefresh(context, true);
561
+ await this.requestRefresh(true);
576
562
  }
577
- async requestRefresh(context, force = false) {
578
- if (!this.isCurrentContext(context) || this.operation !== undefined)
579
- return;
580
- if (!force && this.isConfirmationMode())
563
+ async requestRefresh(force = false) {
564
+ if (this.operation !== undefined)
581
565
  return;
582
- if (!force && (this.mode === "add" || this.mode === "edit") && this.editor !== undefined && this.isEditorDirty()) {
583
- this.pendingRefreshFocus = this.editor.focusReturn;
566
+ if (!force && this.isEditorDirty()) {
567
+ this.pendingRefreshFocus = this.editor?.focusReturn;
584
568
  this.mode = "refresh-discard-confirm";
585
569
  this.status = undefined;
586
570
  this.render();
587
- this.focusSelector("button[data-cancel-refresh-discard]");
571
+ this.focusSelector("[data-cancel-refresh-discard]");
588
572
  return;
589
573
  }
590
574
  const target = this.pendingRefreshFocus ?? this.editor?.focusReturn ?? { kind: "refresh" };
591
575
  this.pendingRefreshFocus = undefined;
592
576
  this.editor = undefined;
593
577
  this.deleteState = undefined;
594
- this.resetState = undefined;
595
- this.failure = undefined;
596
578
  this.validationErrors = undefined;
597
579
  this.idManuallyEdited = false;
580
+ this.moveRecoveryObserved = false;
598
581
  this.mode = "view";
599
- this.operation = "refresh";
600
- const operationGeneration = ++this.operationGeneration;
601
- const selectionGeneration = this.selectionGeneration;
602
- this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}...` };
582
+ this.operation = { kind: "refresh", scopes: [] };
583
+ const observation = this.captureActionState();
584
+ const generation = this.selectionGeneration;
585
+ const pending = {
586
+ kind: "refresh",
587
+ generation,
588
+ scopes: ["workspace", "global"],
589
+ observation,
590
+ focusReturn: target,
591
+ settled: false,
592
+ };
593
+ this.pendingAction = pending;
594
+ this.status = { kind: "info", message: "Refreshing workspace task catalogs..." };
603
595
  this.render();
604
596
  try {
605
- const state = await refreshWorkspaceTasksConfig(context.files, cacheKeyForContext(context));
606
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
597
+ await this.actionsValue.refresh();
598
+ if (!this.ownsSelection(generation) || this.pendingAction !== pending)
607
599
  return;
608
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
609
- if (entry?.state !== state) {
610
- this.operation = undefined;
611
- this.status = undefined;
612
- this.render();
613
- return;
614
- }
615
- this.panelRefreshRequired = false;
616
- this.operation = undefined;
617
- this.status = refreshStatus(state);
618
- this.render();
619
- this.focusTarget(target);
600
+ pending.settled = true;
601
+ this.reconcilePendingAction();
620
602
  }
621
603
  catch (error) {
622
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
604
+ if (!this.ownsSelection(generation) || this.pendingAction !== pending)
623
605
  return;
606
+ this.pendingAction = undefined;
624
607
  this.operation = undefined;
625
- this.status = { kind: "error", message: `Could not refresh ${TASKS_CONFIG_PATH}.`, detail: formatError(error) };
608
+ this.status = { kind: "error", message: "Could not refresh workspace task catalogs.", detail: formatError(error) };
626
609
  this.render();
627
610
  }
628
611
  }
629
- async saveTask(context) {
630
- if (!this.isCurrentContext(context) || this.operation !== undefined || this.editor === undefined || (this.mode !== "add" && this.mode !== "edit"))
631
- return;
632
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
633
- if (entry === undefined)
612
+ async saveTask() {
613
+ const editor = this.editor;
614
+ if (editor === undefined || this.operation !== undefined || (this.mode !== "add" && this.mode !== "edit"))
634
615
  return;
635
- const validation = this.validateEditor(entry);
616
+ const validation = this.validateEditor();
636
617
  if (!validation.ok) {
637
618
  this.validationErrors = validation.errors;
638
619
  this.status = { kind: "error", message: "Fix the highlighted fields before saving." };
@@ -640,231 +621,249 @@ class PiWebUiTasksPanel extends HTMLElement {
640
621
  this.focusFirstInvalid(validation.errors);
641
622
  return;
642
623
  }
643
- if (entry.state.kind !== "loaded" && entry.state.kind !== "missing") {
644
- this.enterFailure("preflight-unavailable", "The current tasks file could not be verified. Refresh before trying again.", "editor");
624
+ const destinationScope = editor.draft.global ? "global" : "workspace";
625
+ if (!this.canWriteScope(destinationScope))
626
+ return;
627
+ if (editor.sourceRef !== undefined && editor.sourceRef.scope !== destinationScope) {
628
+ this.mode = "move-confirm";
629
+ this.status = undefined;
630
+ this.render();
631
+ this.focusSelector("[data-cancel-move]");
632
+ return;
633
+ }
634
+ if (editor.sourceRef !== undefined && !this.isEditorDirty()) {
635
+ const target = editor.focusReturn;
636
+ this.editor = undefined;
637
+ this.mode = "view";
638
+ this.validationErrors = undefined;
639
+ this.idManuallyEdited = false;
640
+ this.status = { kind: "info", message: "No changes to save." };
641
+ this.render();
642
+ this.focusTarget(target);
645
643
  return;
646
644
  }
645
+ const generation = this.selectionGeneration;
646
+ const observation = this.captureActionState();
647
+ const pending = {
648
+ kind: "save",
649
+ generation,
650
+ scopes: [destinationScope],
651
+ observation,
652
+ focusReturn: editor.focusReturn,
653
+ scope: destinationScope,
654
+ task: cloneTask(validation.task),
655
+ settled: false,
656
+ };
657
+ this.pendingAction = pending;
658
+ this.operation = { kind: "mutation", scopes: [destinationScope] };
659
+ this.status = { kind: "info", message: editor.sourceRef === undefined ? "Creating workspace task..." : "Saving workspace task..." };
660
+ this.render();
661
+ try {
662
+ if (editor.sourceRef === undefined)
663
+ await this.actionsValue.create(destinationScope, validation.task);
664
+ else
665
+ await this.actionsValue.update(editor.sourceRef, validation.task);
666
+ if (!this.ownsSelection(generation) || this.pendingAction !== pending)
667
+ return;
668
+ pending.settled = true;
669
+ this.reconcilePendingAction();
670
+ }
671
+ catch (error) {
672
+ if (!this.ownsSelection(generation) || this.pendingAction !== pending)
673
+ return;
674
+ this.pendingAction = undefined;
675
+ this.operation = undefined;
676
+ this.status = { kind: "error", message: "Could not save workspace task.", detail: formatError(error) };
677
+ this.render();
678
+ }
679
+ }
680
+ async confirmMove() {
647
681
  const editor = this.editor;
648
- const nextConfig = this.mode === "add"
649
- ? appendWorkspaceTask(entry.state.kind === "loaded" ? entry.state.config : emptyWorkspaceTasksConfig, validation.task)
650
- : this.buildEditedConfig(entry, validation.task);
651
- if (nextConfig === undefined)
682
+ if (editor?.sourceRef === undefined || this.operation !== undefined || this.mode !== "move-confirm")
683
+ return;
684
+ const validation = this.validateEditor();
685
+ if (!validation.ok)
652
686
  return;
653
- const operationGeneration = ++this.operationGeneration;
654
- const selectionGeneration = this.selectionGeneration;
655
- this.operation = "mutation";
656
- this.status = { kind: "info", message: "Saving workspace task..." };
687
+ const generation = this.selectionGeneration;
688
+ this.operation = { kind: "mutation", scopes: ["workspace", "global"] };
689
+ this.status = { kind: "info", message: "Moving workspace task..." };
657
690
  this.render();
658
691
  try {
659
- const result = await guardedWriteWorkspaceTasksConfig(context.files, cacheKeyForContext(context), editor.sourceSnapshot, nextConfig);
660
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
692
+ await this.actionsValue.move(editor.sourceRef, validation.task);
693
+ if (!this.ownsSelection(generation))
661
694
  return;
662
695
  this.operation = undefined;
663
- if (result.kind === "written") {
664
- const title = validation.task.title;
665
- this.mode = "view";
666
- this.editor = undefined;
667
- this.validationErrors = undefined;
668
- this.idManuallyEdited = false;
669
- this.failure = undefined;
670
- this.status = { kind: "success", message: `Saved task "${title}".` };
696
+ if (this.stateValue.move !== undefined || this.stateValue.moveError !== undefined || this.mutationBlocked("workspace") || this.mutationBlocked("global")) {
697
+ if (this.stateValue.moveError === undefined) {
698
+ this.status = this.stateValue.move === undefined ? { kind: "error", message: this.stateValue.mutationGate?.message ?? "Refresh before trying another task change." } : undefined;
699
+ }
671
700
  this.render();
672
- this.focusTarget(editor.focusReturn);
673
701
  return;
674
702
  }
675
- this.applyWriteFailure(result, "editor");
703
+ if (this.moveRecoveryObserved && !this.isMoveComplete(editor, this.stateValue)) {
704
+ this.status = { kind: "info", message: "Move was not completed. Confirm the move again after reviewing the catalogs." };
705
+ this.render();
706
+ return;
707
+ }
708
+ const target = editor.focusReturn;
709
+ this.editor = undefined;
710
+ this.mode = "view";
711
+ this.validationErrors = undefined;
712
+ this.moveRecoveryObserved = false;
713
+ this.status = { kind: "success", message: `Moved task "${validation.task.title}".` };
714
+ this.render();
715
+ this.focusTarget(target);
676
716
  }
677
717
  catch (error) {
678
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
718
+ if (!this.ownsSelection(generation))
679
719
  return;
680
720
  this.operation = undefined;
681
- this.applyWriteFailure({ kind: "write-failed", detail: `Unable to write ${TASKS_CONFIG_PATH}: ${formatError(error)}` }, "editor");
682
- }
683
- }
684
- buildEditedConfig(entry, task) {
685
- const editor = this.editor;
686
- if (entry.state.kind !== "loaded" || editor?.originalIndex === undefined)
687
- return undefined;
688
- const original = entry.state.config.tasks[editor.originalIndex];
689
- if (original?.id !== editor.initialDraft.id) {
690
- this.enterFailure("conflict", "The task list changed outside this panel. Refresh before trying again.", "editor");
691
- return undefined;
721
+ this.status = { kind: "error", message: "Could not move workspace task.", detail: formatError(error) };
722
+ this.render();
692
723
  }
693
- return replaceWorkspaceTaskAt(entry.state.config, editor.originalIndex, task);
694
724
  }
695
- async confirmDelete(context) {
696
- if (!this.isCurrentContext(context) || this.operation !== undefined || this.deleteState === undefined || this.mode !== "delete-confirm")
697
- return;
698
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
725
+ async confirmDelete() {
699
726
  const pending = this.deleteState;
700
- if (entry?.state.kind !== "loaded" || this.isRefreshRequired(entry) || entry.state.config.tasks[pending.index]?.id !== pending.task.id) {
701
- this.enterFailure("conflict", "The task list changed outside this panel. Refresh before trying again.", "delete");
727
+ if (pending === undefined || this.operation !== undefined || this.isScopeDisabled(pending.ref.scope))
702
728
  return;
703
- }
704
- let nextConfig;
729
+ const generation = this.selectionGeneration;
730
+ const observation = this.captureActionState();
731
+ const pendingAction = {
732
+ kind: "delete",
733
+ generation,
734
+ scopes: [pending.ref.scope],
735
+ observation,
736
+ focusReturn: pending.focusReturn,
737
+ ref: { ...pending.ref },
738
+ task: cloneTask(pending.task),
739
+ settled: false,
740
+ };
741
+ this.pendingAction = pendingAction;
742
+ this.operation = { kind: "mutation", scopes: [pending.ref.scope] };
743
+ this.status = { kind: "info", message: "Deleting workspace task..." };
744
+ this.render();
705
745
  try {
706
- nextConfig = removeWorkspaceTaskAt(entry.state.config, pending.index);
746
+ await this.actionsValue.remove(pending.ref);
747
+ if (!this.ownsSelection(generation) || this.pendingAction !== pendingAction)
748
+ return;
749
+ pendingAction.settled = true;
750
+ this.reconcilePendingAction();
707
751
  }
708
752
  catch (error) {
709
- this.enterFailure("conflict", formatError(error), "delete");
710
- return;
753
+ if (!this.ownsSelection(generation) || this.pendingAction !== pendingAction)
754
+ return;
755
+ this.pendingAction = undefined;
756
+ this.operation = undefined;
757
+ this.status = { kind: "error", message: "Could not delete workspace task.", detail: formatError(error) };
758
+ this.render();
711
759
  }
712
- await this.performMutation(context, pending.sourceSnapshot, nextConfig, "delete", pending.task.title, pending.focusReturn);
713
760
  }
714
- async confirmReset(context) {
715
- if (!this.isCurrentContext(context) || this.operation !== undefined || this.resetState === undefined || this.mode !== "reset-confirm")
761
+ async retryMove() {
762
+ if (this.operation !== undefined || this.stateValue.move?.retryAllowed !== true)
716
763
  return;
717
- const pending = this.resetState;
718
- await this.performMutation(context, pending.sourceSnapshot, emptyWorkspaceTasksConfig, "reset", undefined, pending.focusReturn);
719
- }
720
- async performMutation(context, sourceSnapshot, nextConfig, action, title, focusReturn) {
721
- const operationGeneration = ++this.operationGeneration;
722
- const selectionGeneration = this.selectionGeneration;
723
- this.operation = "mutation";
724
- this.status = { kind: "info", message: action === "delete" ? "Deleting workspace task..." : action === "reset" ? "Resetting workspace tasks file..." : "Saving workspace task..." };
764
+ const generation = this.selectionGeneration;
765
+ this.operation = { kind: "mutation", scopes: ["workspace", "global"] };
766
+ this.status = { kind: "info", message: "Retrying workspace task move..." };
725
767
  this.render();
726
768
  try {
727
- const result = await guardedWriteWorkspaceTasksConfig(context.files, cacheKeyForContext(context), sourceSnapshot, nextConfig);
728
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
769
+ await this.actionsValue.retryMove();
770
+ if (!this.ownsSelection(generation))
729
771
  return;
730
772
  this.operation = undefined;
731
- if (result.kind === "written") {
732
- this.mode = "view";
733
- this.editor = undefined;
734
- this.deleteState = undefined;
735
- this.resetState = undefined;
736
- this.failure = undefined;
737
- this.validationErrors = undefined;
738
- this.idManuallyEdited = false;
739
- this.status = action === "delete" && title !== undefined
740
- ? { kind: "success", message: `Deleted task "${title}".` }
741
- : action === "reset"
742
- ? { kind: "success", message: "Reset workspace tasks file." }
743
- : { kind: "success", message: "Saved workspace task." };
744
- this.render();
745
- this.focusTarget(focusReturn);
746
- return;
747
- }
748
- this.applyWriteFailure(result, action === "delete" ? "delete" : "reset");
773
+ this.status = undefined;
774
+ this.render();
749
775
  }
750
776
  catch (error) {
751
- if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
777
+ if (!this.ownsSelection(generation))
752
778
  return;
753
779
  this.operation = undefined;
754
- this.applyWriteFailure({ kind: "write-failed", detail: `Unable to write ${TASKS_CONFIG_PATH}: ${formatError(error)}` }, action === "delete" ? "delete" : "reset");
755
- }
756
- }
757
- applyWriteFailure(result, action) {
758
- this.panelRefreshRequired = true;
759
- this.failure = { kind: result.kind, detail: result.detail, action };
760
- this.mode = result.kind === "conflict" || result.kind === "preflight-unavailable" ? "conflicted" : "needs-refresh-after-write";
761
- this.status = { kind: "error", message: failureMessage(result.kind), detail: result.detail };
762
- this.render();
763
- }
764
- enterFailure(kind, detail, action) {
765
- this.panelRefreshRequired = true;
766
- this.failure = { kind, detail, action };
767
- this.mode = kind === "conflict" || kind === "preflight-unavailable" ? "conflicted" : "needs-refresh-after-write";
768
- this.status = { kind: "error", message: failureMessage(kind), detail };
769
- this.render();
770
- }
771
- dispatchTaskById(context, taskId) {
772
- if (!this.isCurrentContext(context) || taskId === null)
773
- return Promise.resolve();
774
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
775
- const task = entry?.state.kind === "loaded" ? entry.state.config.tasks.find((candidate) => candidate.id === taskId) : undefined;
776
- if (task === undefined) {
777
- this.setError("That task is no longer available. Click Refresh, then try again.");
778
- return Promise.resolve();
780
+ this.status = { kind: "error", message: "Could not retry workspace task move.", detail: formatError(error) };
781
+ this.render();
779
782
  }
780
- return this.dispatchTask(context, task);
781
783
  }
782
- async dispatchTask(context, task) {
783
- if (this.runningTaskId !== undefined || this.operation !== undefined)
784
+ async dispatchTask(key) {
785
+ if (this.contextValue === undefined || key === null || this.runningTaskKey !== undefined)
786
+ return;
787
+ const ref = parseRef(key);
788
+ if (ref === undefined)
789
+ return;
790
+ const found = this.findTask(ref);
791
+ if (found === undefined) {
792
+ this.setError("That task is no longer available. Refresh, then try again.");
784
793
  return;
785
- if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) {
786
- this.status = { kind: "info", message: `Cancelled ${task.title}.` };
794
+ }
795
+ if (found.task.confirm && !window.confirm(`Run ${found.task.title} (${scopeLabel(ref.scope)})?\n\n${found.task.command}`)) {
796
+ this.status = { kind: "info", message: `Cancelled ${found.task.title}.` };
787
797
  this.render();
788
798
  return;
789
799
  }
790
- const selectionGeneration = this.selectionGeneration;
791
- const terminalRunGeneration = ++this.terminalRunGeneration;
792
- this.runningTaskId = task.id;
793
- this.status = { kind: "info", message: `Starting ${task.title}...` };
800
+ const selection = this.selectionGeneration;
801
+ const terminal = ++this.terminalGeneration;
802
+ this.runningTaskKey = workspaceTaskRefKey(ref);
803
+ this.status = { kind: "info", message: `Starting ${found.task.title}...` };
794
804
  this.render();
795
805
  try {
796
- const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
797
- if (!this.ownsTerminalRun(context, selectionGeneration, terminalRunGeneration))
806
+ const handle = await runWorkspaceTaskInTerminal(this.contextValue.terminal, ref, found.task);
807
+ if (!this.ownsTerminal(selection, terminal))
798
808
  return;
799
- this.runningTaskId = undefined;
800
- this.renderTerminalStatus({ kind: "success", message: `Started terminal command "${handle.run.title}".`, detail: task.command });
809
+ this.runningTaskKey = undefined;
810
+ this.status = { kind: "success", message: `Started terminal command "${handle.run.title}".`, detail: found.task.command };
811
+ this.render();
801
812
  }
802
813
  catch (error) {
803
- if (!this.ownsTerminalRun(context, selectionGeneration, terminalRunGeneration))
814
+ if (!this.ownsTerminal(selection, terminal))
804
815
  return;
805
- this.runningTaskId = undefined;
806
- this.renderTerminalStatus({ kind: "error", message: formatError(error) });
807
- }
808
- }
809
- renderTerminalStatus(status) {
810
- if (this.operation !== undefined) {
816
+ this.runningTaskKey = undefined;
817
+ this.status = { kind: "error", message: formatError(error) };
811
818
  this.render();
812
- return;
813
819
  }
814
- this.status = status;
815
- this.render();
816
820
  }
817
- openWorkspaceTerminal(terminalId) {
818
- const context = this.contextValue;
819
- if (context === undefined) {
821
+ openWorkspaceTerminal() {
822
+ if (this.contextValue === undefined) {
820
823
  this.setError("Select a workspace before opening a terminal.");
821
824
  return;
822
825
  }
823
- if (terminalId === undefined)
824
- context.terminal.open();
825
- else
826
- context.terminal.open({ terminalId });
826
+ this.contextValue.terminal.open();
827
827
  }
828
- validateEditor(entry) {
828
+ validateEditor() {
829
829
  const editor = this.editor;
830
830
  if (editor === undefined)
831
831
  return { ok: false, errors: { title: "Title is required." } };
832
- const existingTasks = entry.state.kind === "loaded" ? entry.state.config.tasks : [];
833
- return validateAndNormalizeDraft(editor.draft, existingTasks, editor.originalIndex);
832
+ const scope = editor.draft.global ? "global" : "workspace";
833
+ const catalog = this.catalog(scope);
834
+ const existing = catalog.kind === "loaded" ? catalog.config.tasks : [];
835
+ const originalIndex = editor.sourceRef?.scope === scope ? editor.originalIndex : undefined;
836
+ return validateAndNormalizeDraft(editor.draft, existing, originalIndex);
834
837
  }
835
- updateValidationInPlace(context) {
836
- const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
837
- if (entry === undefined || this.editor === undefined)
838
+ updateValidationInPlace() {
839
+ if (this.editor === undefined)
838
840
  return;
839
- const result = this.validateEditor(entry);
840
- this.validationErrors = result.ok ? undefined : result.errors;
841
+ const validation = this.validateEditor();
842
+ this.validationErrors = validation.ok ? undefined : validation.errors;
841
843
  const errors = this.validationErrors ?? {};
842
- const controls = [["title", "task-title"], ["command", "task-command"], ["id", "task-id"]];
843
- for (const [field, controlId] of controls) {
844
- const control = this.root.querySelector(`#${controlId}`);
845
- const error = errors[field];
844
+ for (const [field, id] of [["title", "task-title"], ["command", "task-command"], ["id", "task-id"]]) {
845
+ const control = this.root.querySelector(`#${id}`);
846
846
  const errorElement = this.root.querySelector(`[data-field-error="${field}"]`);
847
847
  if (control === null || errorElement === null)
848
848
  continue;
849
+ const error = errors[field];
849
850
  if (error === undefined) {
850
851
  control.removeAttribute("aria-invalid");
851
- if (field === "command")
852
- control.setAttribute("aria-describedby", "task-command-help");
853
- else
852
+ if (field !== "command")
854
853
  control.removeAttribute("aria-describedby");
855
854
  errorElement.textContent = "";
856
855
  errorElement.hidden = true;
857
856
  }
858
857
  else {
859
858
  control.setAttribute("aria-invalid", "true");
860
- control.setAttribute("aria-describedby", field === "command" ? `task-command-help task-command-error` : `task-${field}-error`);
859
+ control.setAttribute("aria-describedby", field === "command" ? "task-command-help task-command-error" : `task-${field}-error`);
861
860
  errorElement.textContent = error;
862
861
  errorElement.hidden = false;
863
862
  }
864
863
  }
865
- const save = this.root.querySelector("button[data-save-task]");
864
+ const save = this.root.querySelector("[data-save-task]");
866
865
  if (save !== null)
867
- save.disabled = this.operation !== undefined || this.isRefreshRequired(entry) || !result.ok || this.failure !== undefined;
866
+ save.disabled = this.operation !== undefined || !this.canWriteScope(this.editor.draft.global ? "global" : "workspace") || !validation.ok || this.isScopeDisabled(this.editor.draft.global ? "global" : "workspace");
868
867
  }
869
868
  isEditorDirty() {
870
869
  const editor = this.editor;
@@ -875,11 +874,12 @@ class PiWebUiTasksPanel extends HTMLElement {
875
874
  || editor.draft.command !== editor.initialDraft.command
876
875
  || editor.draft.description !== editor.initialDraft.description
877
876
  || editor.draft.group !== editor.initialDraft.group
878
- || editor.draft.confirm !== editor.initialDraft.confirm;
877
+ || editor.draft.confirm !== editor.initialDraft.confirm
878
+ || editor.draft.global !== editor.initialDraft.global;
879
879
  }
880
880
  focusFirstInvalid(errors) {
881
881
  const field = errors.title === undefined ? errors.command === undefined ? "id" : "command" : "title";
882
- this.focusSelector(`input[data-editor-${field}], textarea[data-editor-${field}]`);
882
+ this.focusSelector(`[data-editor-${field}]`);
883
883
  }
884
884
  focusSelector(selector) {
885
885
  if (!this.connected)
@@ -889,117 +889,377 @@ class PiWebUiTasksPanel extends HTMLElement {
889
889
  focusTarget(target) {
890
890
  if (!this.connected)
891
891
  return;
892
- let element;
892
+ let element = null;
893
893
  if (target?.kind === "add")
894
- element = this.root.querySelector("button[data-add-task]") ?? undefined;
894
+ element = this.root.querySelector("[data-add-task]");
895
895
  if (target?.kind === "edit")
896
- element = findButtonByValue(this.root, "data-edit-task", target.id);
896
+ element = findButtonByValue(this.root, "data-edit-task", workspaceTaskRefKey(target.ref));
897
897
  if (target?.kind === "delete")
898
- element = findButtonByValue(this.root, "data-delete-task", target.id);
899
- if (target?.kind === "reset")
900
- element = this.root.querySelector("button[data-reset-tasks-file]") ?? undefined;
898
+ element = findButtonByValue(this.root, "data-delete-task", workspaceTaskRefKey(target.ref));
901
899
  if (target?.kind === "refresh")
902
- element = this.root.querySelector("button[data-refresh-config]") ?? undefined;
903
- if (target?.kind === "heading" || element === undefined)
904
- element = this.root.querySelector("[data-panel-heading]") ?? undefined;
900
+ element = this.root.querySelector("[data-refresh]");
901
+ if (target?.kind === "heading" || element === null || element instanceof HTMLButtonElement && element.disabled)
902
+ element = this.root.querySelector("[data-panel-heading]");
905
903
  element?.focus();
906
904
  }
907
- isCurrentContext(context) {
908
- return this.connected && this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
905
+ handleKeyDown(event) {
906
+ if (event.key !== "Escape" || this.operation !== undefined)
907
+ return;
908
+ if (this.mode === "add" || this.mode === "edit") {
909
+ event.preventDefault();
910
+ event.stopPropagation();
911
+ this.cancelEditor();
912
+ }
913
+ else if (this.mode === "delete-confirm") {
914
+ event.preventDefault();
915
+ event.stopPropagation();
916
+ this.cancelDelete();
917
+ }
918
+ else if (this.mode === "move-confirm") {
919
+ event.preventDefault();
920
+ event.stopPropagation();
921
+ this.cancelMove();
922
+ }
923
+ else if (this.mode === "refresh-discard-confirm") {
924
+ event.preventDefault();
925
+ event.stopPropagation();
926
+ this.cancelRefreshDiscard();
927
+ }
928
+ }
929
+ findTask(ref) {
930
+ const catalog = this.catalog(ref.scope);
931
+ if (catalog.kind !== "loaded")
932
+ return undefined;
933
+ const index = catalog.config.tasks.findIndex((task) => task.id === ref.id);
934
+ const task = index < 0 ? undefined : catalog.config.tasks[index];
935
+ return task === undefined ? undefined : { task: cloneTask(task), index };
936
+ }
937
+ catalog(scope) {
938
+ return scope === "workspace" ? this.stateValue.workspace : this.stateValue.global;
939
+ }
940
+ captureActionState() {
941
+ return {
942
+ sources: {
943
+ workspace: {
944
+ lastKey: catalogStateKey(this.stateValue.workspace),
945
+ generation: sourceGeneration(this.stateValue, "workspace"),
946
+ delivered: false,
947
+ },
948
+ global: {
949
+ lastKey: catalogStateKey(this.stateValue.global),
950
+ generation: sourceGeneration(this.stateValue, "global"),
951
+ delivered: false,
952
+ },
953
+ },
954
+ mutationGateKey: mutationGateKey(this.stateValue.mutationGate),
955
+ };
956
+ }
957
+ recordPendingSourceObservations(next) {
958
+ const pending = this.pendingAction;
959
+ if (pending === undefined || !this.ownsSelection(pending.generation))
960
+ return;
961
+ for (const scope of ["workspace", "global"]) {
962
+ if (!pending.scopes.includes(scope))
963
+ continue;
964
+ const nextCatalog = scope === "workspace" ? next.workspace : next.global;
965
+ const source = pending.observation.sources[scope];
966
+ const nextKey = catalogStateKey(nextCatalog);
967
+ if (source.generation < sourceGeneration(next, scope)
968
+ || source.lastKey !== nextKey && hasAuthoritativeCatalogState(nextCatalog)) {
969
+ source.delivered = true;
970
+ }
971
+ source.lastKey = nextKey;
972
+ }
973
+ }
974
+ reconcilePendingAction() {
975
+ const pending = this.pendingAction;
976
+ if (pending === undefined || !pending.settled || !this.ownsSelection(pending.generation))
977
+ return false;
978
+ if (pending.kind === "refresh") {
979
+ if (!pending.scopes.every((scope) => this.sourceSnapshotDelivered(scope, pending.observation)))
980
+ return false;
981
+ if (pending.scopes.some((scope) => !hasAuthoritativeCatalogState(this.catalog(scope))))
982
+ return false;
983
+ for (const scope of pending.scopes) {
984
+ const failure = catalogFailure(this.catalog(scope));
985
+ if (failure !== undefined)
986
+ return this.finishPendingFailure(pending, failure.message);
987
+ }
988
+ this.pendingAction = undefined;
989
+ this.operation = undefined;
990
+ this.status = { kind: "success", message: "Workspace task catalogs refreshed." };
991
+ this.render();
992
+ this.focusTarget(pending.focusReturn);
993
+ return true;
994
+ }
995
+ const scope = pending.kind === "save" ? pending.scope : pending.ref.scope;
996
+ const delivered = this.sourceSnapshotDelivered(scope, pending.observation);
997
+ const gateChanged = this.mutationGateChanged(scope, pending.observation);
998
+ if (!delivered && !gateChanged)
999
+ return false;
1000
+ if (!hasAuthoritativeCatalogState(this.catalog(scope)) && !gateChanged)
1001
+ return false;
1002
+ const failure = catalogFailure(this.catalog(scope));
1003
+ if (failure !== undefined)
1004
+ return this.finishPendingFailure(pending, failure.message);
1005
+ if (gateChanged || this.mutationBlocked(scope)) {
1006
+ return this.finishPendingFailure(pending, this.stateValue.mutationGate?.message ?? "Refresh before trying another task change.");
1007
+ }
1008
+ const confirmed = pending.kind === "save"
1009
+ ? this.catalogContainsTask(scope, pending.task)
1010
+ : this.catalogDoesNotContainTask(pending.ref);
1011
+ if (!confirmed)
1012
+ return false;
1013
+ this.pendingAction = undefined;
1014
+ this.operation = undefined;
1015
+ if (pending.kind === "save") {
1016
+ this.editor = undefined;
1017
+ this.mode = "view";
1018
+ this.validationErrors = undefined;
1019
+ this.idManuallyEdited = false;
1020
+ this.status = { kind: "success", message: `Saved task "${pending.task.title}".` };
1021
+ }
1022
+ else {
1023
+ this.deleteState = undefined;
1024
+ this.mode = "view";
1025
+ this.status = { kind: "success", message: `Deleted task "${pending.task.title}".` };
1026
+ }
1027
+ this.render();
1028
+ this.focusTarget(pending.focusReturn);
1029
+ return true;
1030
+ }
1031
+ sourceSnapshotDelivered(scope, observation) {
1032
+ return observation.sources[scope].delivered;
1033
+ }
1034
+ mutationGateChanged(scope, observation) {
1035
+ const gate = this.stateValue.mutationGate;
1036
+ return mutationGateKey(gate) !== observation.mutationGateKey && gate?.scopes.includes(scope) === true;
1037
+ }
1038
+ finishPendingFailure(pending, detail) {
1039
+ if (this.pendingAction !== pending)
1040
+ return false;
1041
+ this.pendingAction = undefined;
1042
+ this.operation = undefined;
1043
+ const message = pending.kind === "refresh"
1044
+ ? "Could not refresh workspace task catalogs."
1045
+ : pending.kind === "save"
1046
+ ? "Could not save workspace task."
1047
+ : "Could not delete workspace task.";
1048
+ this.status = { kind: "error", message, detail };
1049
+ this.render();
1050
+ if (pending.kind === "refresh")
1051
+ this.focusTarget(pending.focusReturn);
1052
+ return true;
909
1053
  }
910
- ownsTerminalRun(context, selectionGeneration, terminalRunGeneration) {
911
- return this.isCurrentContext(context)
912
- && this.selectionGeneration === selectionGeneration
913
- && this.terminalRunGeneration === terminalRunGeneration;
1054
+ catalogContainsTask(scope, expected) {
1055
+ const catalog = this.catalog(scope);
1056
+ return catalog.kind === "loaded" && catalog.config.tasks.some((task) => sameTask(task, expected));
914
1057
  }
915
- ownsOperation(context, selectionGeneration, operationGeneration) {
916
- return this.isCurrentContext(context)
917
- && this.selectionGeneration === selectionGeneration
918
- && this.operationGeneration === operationGeneration;
1058
+ catalogDoesNotContainTask(ref) {
1059
+ const catalog = this.catalog(ref.scope);
1060
+ return catalog.kind === "loaded" && !catalog.config.tasks.some((task) => task.id === ref.id);
1061
+ }
1062
+ canWriteScope(scope) {
1063
+ const catalog = this.catalog(scope);
1064
+ return catalog.kind === "loaded" || scope === "workspace" && catalog.kind === "missing";
1065
+ }
1066
+ isScopeDisabled(scope) {
1067
+ return this.operation?.scopes.includes(scope) === true
1068
+ || this.stateValue.mutationGate?.scopes.includes(scope) === true
1069
+ || this.stateValue.move !== undefined;
1070
+ }
1071
+ mutationBlocked(scope) {
1072
+ return this.stateValue.mutationGate?.scopes.includes(scope) === true || this.stateValue.move !== undefined;
1073
+ }
1074
+ ownsSelection(generation) {
1075
+ return this.connected && generation === this.selectionGeneration;
1076
+ }
1077
+ ownsTerminal(selection, terminal) {
1078
+ return this.ownsSelection(selection) && terminal === this.terminalGeneration;
919
1079
  }
920
1080
  setError(message) {
921
1081
  this.status = { kind: "error", message };
922
1082
  this.render();
923
1083
  }
1084
+ rememberOpenGroups() {
1085
+ for (const details of this.root.querySelectorAll("details[data-group-key]")) {
1086
+ const key = details.getAttribute("data-group-key");
1087
+ if (key === null)
1088
+ continue;
1089
+ if (details.open)
1090
+ this.expandedGroupKeys.add(key);
1091
+ else
1092
+ this.expandedGroupKeys.delete(key);
1093
+ }
1094
+ }
1095
+ pruneExpandedGroups() {
1096
+ const valid = new Set();
1097
+ for (const scope of ["global", "workspace"]) {
1098
+ const catalog = this.catalog(scope);
1099
+ if (catalog.kind !== "loaded")
1100
+ continue;
1101
+ for (const task of catalog.config.tasks) {
1102
+ if (task.group !== undefined)
1103
+ valid.add(workspaceTaskGroupKey(scope, task.group));
1104
+ }
1105
+ }
1106
+ for (const key of this.expandedGroupKeys)
1107
+ if (!valid.has(key))
1108
+ this.expandedGroupKeys.delete(key);
1109
+ }
1110
+ isMoveComplete(editor, state) {
1111
+ const source = editor.sourceRef;
1112
+ if (source === undefined)
1113
+ return false;
1114
+ const destination = { scope: editor.draft.global ? "global" : "workspace", id: editor.draft.id.trim() };
1115
+ return !this.catalogHasRef(state, source) && this.catalogHasRef(state, destination);
1116
+ }
1117
+ catalogHasRef(state, ref) {
1118
+ const catalog = ref.scope === "workspace" ? state.workspace : state.global;
1119
+ return catalog.kind === "loaded" && catalog.config.tasks.some((task) => task.id === ref.id);
1120
+ }
1121
+ }
1122
+ function renderTaskGroups(scope, tasks, runningTaskKey, scopeDisabled, expanded) {
1123
+ const groups = new Map();
1124
+ for (const sourceTask of tasks) {
1125
+ const task = cloneTask(sourceTask);
1126
+ const group = groups.get(task.group);
1127
+ if (group === undefined)
1128
+ groups.set(task.group, [task]);
1129
+ else
1130
+ group.push(task);
1131
+ }
1132
+ return `<div class="tasks">${[...groups.entries()].map(([group, groupedTasks]) => {
1133
+ if (group === undefined)
1134
+ return `<section class="task-group ungrouped">${groupedTasks.map((task) => renderTask(scope, task, runningTaskKey, scopeDisabled, true)).join("")}</section>`;
1135
+ const key = workspaceTaskGroupKey(scope, group);
1136
+ const open = expanded.has(key);
1137
+ return `<details class="task-group" data-group-key="${escapeAttr(key)}" data-group-scope="${scope}"${open ? " open" : ""}>
1138
+ <summary><span class="disclosure-chevron" aria-hidden="true">›</span><span class="group-title">${escapeHtml(group)}</span><span class="group-count">${String(groupedTasks.length)} ${groupedTasks.length === 1 ? "task" : "tasks"}</span></summary>
1139
+ <div class="task-group-body">${groupedTasks.map((task) => renderTask(scope, task, runningTaskKey, scopeDisabled, true)).join("")}</div>
1140
+ </details>`;
1141
+ }).join("")}</div>`;
1142
+ }
1143
+ function renderTask(scope, task, runningTaskKey, scopeDisabled, showScopeBadge) {
1144
+ const ref = { scope, id: task.id };
1145
+ const key = workspaceTaskRefKey(ref);
1146
+ const label = scopeLabel(scope);
1147
+ const running = runningTaskKey === key;
1148
+ const description = task.description === undefined ? "" : `<span class="task-description">${escapeHtml(task.description)}</span>`;
1149
+ return `<article class="task-card" data-task-ref="${escapeAttr(key)}">
1150
+ <div class="task-copy">
1151
+ <div class="task-title-line"><strong>${escapeHtml(task.title)}</strong>${showScopeBadge ? `<span class="scope-badge">${escapeHtml(label)}</span>` : ""}</div>
1152
+ ${description}
1153
+ <pre class="task-script" data-task-script>${escapeHtml(task.command)}</pre>
1154
+ </div>
1155
+ <div class="task-actions">
1156
+ <button type="button" class="secondary" data-edit-task="${escapeAttr(key)}" aria-label="Edit ${escapeAttr(task.title)} (${escapeAttr(label)})" ${scopeDisabled ? "disabled" : ""}>Edit</button>
1157
+ <button type="button" class="secondary danger-secondary" data-delete-task="${escapeAttr(key)}" aria-label="Delete ${escapeAttr(task.title)} (${escapeAttr(label)})" ${scopeDisabled ? "disabled" : ""}>Delete</button>
1158
+ <button type="button" data-run-task="${escapeAttr(key)}" aria-label="Run ${escapeAttr(task.title)} (${escapeAttr(label)})" ${runningTaskKey !== undefined ? "disabled" : ""}>${running ? "Dispatching..." : "Run"}</button>
1159
+ </div>
1160
+ </article>`;
1161
+ }
1162
+ function fieldAttrs(field, error, locked) {
1163
+ return `${error === undefined ? "" : " aria-invalid=\"true\" aria-describedby=\"task-${field}-error\""}${locked ? " disabled" : ""}`;
924
1164
  }
925
1165
  function renderFieldError(field, error) {
926
1166
  return `<p id="task-${field}-error" data-field-error="${field}" class="field-error"${error === undefined ? " hidden" : ""}>${error === undefined ? "" : escapeHtml(error)}</p>`;
927
1167
  }
928
- function renderTaskGroups(tasks, runningTaskId, actionsDisabled) {
929
- return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId, actionsDisabled)).join("")}</div>`;
1168
+ function loadedCount(catalog) {
1169
+ return catalog.kind === "loaded" ? catalog.config.tasks.length : 0;
930
1170
  }
931
- function groupTasks(tasks) {
932
- const groups = [];
933
- for (const task of tasks) {
934
- let group = groups.find((candidate) => candidate.title === task.group);
935
- if (group === undefined) {
936
- group = { title: task.group, tasks: [] };
937
- groups.push(group);
938
- }
939
- group.tasks.push(task);
1171
+ function catalogNeedsAttention(catalog) {
1172
+ return catalog.kind === "invalid" || catalog.kind === "unavailable" || catalog.kind === "error" || catalog.kind === "loaded" && catalog.refreshError !== undefined || catalog.kind === "missing" && catalog.refreshError !== undefined;
1173
+ }
1174
+ function catalogFailure(catalog) {
1175
+ if ((catalog.kind === "loaded" || catalog.kind === "missing") && catalog.refreshError !== undefined) {
1176
+ return { message: catalog.refreshError };
940
1177
  }
941
- return groups;
1178
+ if (catalog.kind === "invalid" || catalog.kind === "unavailable" || catalog.kind === "error") {
1179
+ return { message: catalog.message };
1180
+ }
1181
+ return undefined;
942
1182
  }
943
- function renderTaskGroup(group, runningTaskId, actionsDisabled) {
944
- const title = group.title === undefined ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
945
- return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId, actionsDisabled)).join("")}</section>`;
1183
+ function sourceGeneration(state, scope) {
1184
+ const generation = state.sourceGenerations?.[scope];
1185
+ return typeof generation === "number" && Number.isFinite(generation) ? generation : 0;
946
1186
  }
947
- function renderTask(task, runningTaskId, actionsDisabled) {
948
- const running = runningTaskId === task.id;
949
- const runDisabled = runningTaskId !== undefined || actionsDisabled;
950
- const mutationDisabled = runningTaskId !== undefined || actionsDisabled;
951
- const description = task.description === undefined ? "" : `<span>${escapeHtml(task.description)}</span>`;
952
- return `
953
- <article class="task-card">
954
- <div class="task-copy">
955
- <strong>${escapeHtml(task.title)}</strong>
956
- ${description}
957
- <pre class="task-script" data-task-script>${escapeHtml(task.command)}</pre>
958
- </div>
959
- <div class="task-actions">
960
- <button type="button" class="secondary" data-edit-task="${escapeAttr(task.id)}" ${mutationDisabled ? "disabled" : ""}>Edit</button>
961
- <button type="button" class="secondary danger-secondary" data-delete-task="${escapeAttr(task.id)}" ${mutationDisabled ? "disabled" : ""}>Delete</button>
962
- <button type="button" data-task-id="${escapeAttr(task.id)}" ${runDisabled ? "disabled" : ""}>${running ? "Dispatching..." : "Run"}</button>
963
- </div>
964
- </article>
965
- `;
1187
+ function catalogStateKey(catalog) {
1188
+ return JSON.stringify(catalog);
1189
+ }
1190
+ function mutationGateKey(gate) {
1191
+ return gate === undefined ? undefined : JSON.stringify({ scopes: [...gate.scopes], message: gate.message });
1192
+ }
1193
+ function hasAuthoritativeCatalogState(catalog) {
1194
+ if (catalog.kind === "loading")
1195
+ return false;
1196
+ if (catalog.kind === "loaded" || catalog.kind === "missing")
1197
+ return !catalog.refreshing;
1198
+ return true;
1199
+ }
1200
+ function sameTask(left, right) {
1201
+ return left.id === right.id
1202
+ && left.title === right.title
1203
+ && left.command === right.command
1204
+ && left.description === right.description
1205
+ && left.group === right.group
1206
+ && left.confirm === right.confirm;
1207
+ }
1208
+ function scopeLabel(scope) {
1209
+ return scope === "global" ? "Global" : "Project";
1210
+ }
1211
+ function parseRef(value) {
1212
+ try {
1213
+ return parseWorkspaceTaskRefKey(value);
1214
+ }
1215
+ catch {
1216
+ return undefined;
1217
+ }
966
1218
  }
967
1219
  function findButtonByValue(root, attribute, value) {
968
1220
  for (const button of root.querySelectorAll(`button[${attribute}]`)) {
969
1221
  if (button.getAttribute(attribute) === value)
970
1222
  return button;
971
1223
  }
972
- return undefined;
1224
+ return null;
1225
+ }
1226
+ function contextKey(context) {
1227
+ if (context === undefined)
1228
+ return undefined;
1229
+ return JSON.stringify([context.machine.id, context.workspace.projectId, context.workspace.id, context.workspace.path]);
973
1230
  }
974
1231
  function emptyDraft() {
975
- return { id: "", title: "", command: "", description: "", group: "", confirm: false };
1232
+ return { id: "", title: "", command: "", description: "", group: "", confirm: false, global: false };
976
1233
  }
977
1234
  function cloneDraft(draft) {
978
1235
  return { ...draft };
979
1236
  }
980
- function cacheKeyForContext(context) {
981
- return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
1237
+ function cloneTask(task) {
1238
+ return {
1239
+ id: task.id,
1240
+ title: task.title,
1241
+ command: task.command,
1242
+ ...(task.description === undefined ? {} : { description: task.description }),
1243
+ ...(task.group === undefined ? {} : { group: task.group }),
1244
+ confirm: task.confirm,
1245
+ };
982
1246
  }
983
- function refreshStatus(state) {
984
- if (state.kind === "loaded")
985
- return { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` };
986
- if (state.kind === "missing")
987
- return { kind: "info", message: "No workspace tasks file is configured." };
988
- if (state.kind === "invalid")
989
- return { kind: "error", message: "Workspace tasks configuration is invalid.", detail: state.detail };
990
- const detail = state.detail;
991
- return detail === undefined
992
- ? { kind: "error", message: state.message }
993
- : { kind: "error", message: state.message, detail };
1247
+ function emptyPanelState() {
1248
+ return {
1249
+ workspace: { kind: "loading" },
1250
+ global: { kind: "loading" },
1251
+ sourceGenerations: { workspace: 0, global: 0 },
1252
+ };
994
1253
  }
995
- function failureMessage(kind) {
996
- if (kind === "conflict")
997
- return "The tasks file changed outside this panel. Refresh before trying again.";
998
- if (kind === "preflight-unavailable")
999
- return "The current tasks file could not be verified. Refresh before trying again.";
1000
- if (kind === "write-failed")
1001
- return "The task write failed. Refresh before trying again; the result may be unknown.";
1002
- return "The task was written, but the new file could not be verified. Refresh before trying again.";
1254
+ function noOpActions() {
1255
+ return {
1256
+ create: () => Promise.resolve(),
1257
+ update: () => Promise.resolve(),
1258
+ remove: () => Promise.resolve(),
1259
+ move: () => Promise.resolve(),
1260
+ retryMove: () => Promise.resolve(),
1261
+ refresh: () => Promise.resolve(),
1262
+ };
1003
1263
  }
1004
1264
  function formatError(error) {
1005
1265
  return error instanceof Error ? error.message : String(error);
@@ -1011,62 +1271,76 @@ function escapeAttr(value) {
1011
1271
  return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1012
1272
  }
1013
1273
  function taskStyles() {
1014
- return `
1015
- <style>
1016
- :host { display: block; min-width: 0; container-type: inline-size; }
1017
- .tasks-panel { min-width: 0; container-type: inline-size; color: var(--pi-text); }
1018
- .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
1019
- .toolbar h2 { margin: 0; font-size: 15px; }
1020
- .toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1021
- .viewer { box-sizing: border-box; min-width: 0; min-height: 0; overflow: auto; padding: 12px; }
1022
- .tasks-viewer { display: grid; align-content: start; gap: 12px; }
1023
- .tasks { display: grid; gap: 14px; min-width: 0; }
1024
- .task-group { display: grid; gap: 10px; min-width: 0; }
1025
- .task-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
1026
- .task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); padding: 12px; }
1027
- .task-copy { display: grid; min-width: 0; gap: 5px; }
1028
- .task-copy span, .muted, .field-help { color: var(--pi-muted); }
1029
- .task-script, .diagnostic { box-sizing: border-box; max-width: 100%; margin: 0; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
1030
- .task-script { max-height: 12rem; overflow-y: auto; padding: 8px; }
1031
- .diagnostic { max-height: 12rem; overflow: auto; padding: 8px; }
1032
- code { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1033
- button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; }
1034
- button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
1035
- button.danger, button.danger-secondary { border-color: var(--pi-danger); }
1036
- button.danger { background: var(--pi-danger); color: var(--pi-bg); }
1037
- button.danger-secondary { background: var(--pi-surface); color: var(--pi-danger); }
1038
- button:disabled { cursor: wait; opacity: 0.65; }
1039
- button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible { outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
1040
- .task-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1041
- .task-editor, .confirmation { width: min(100%, 620px); box-sizing: border-box; padding: 16px; background: var(--pi-surface); border: 1px solid var(--pi-border); border-radius: 8px; }
1042
- .task-editor h3, .confirmation h3 { margin: 0 0 16px; font-size: 18px; }
1043
- .confirmation p { margin: 0 0 10px; line-height: 1.5; }
1044
- .confirmation .task-script { margin: 10px 0 16px; }
1045
- .task-form { display: grid; gap: 6px; }
1046
- .task-form > label, .checkbox-field > label { font-size: 14px; font-weight: 500; }
1047
- .task-form .required { color: var(--pi-danger); }
1048
- .task-form input[type="text"], .task-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; background: var(--pi-bg); border: 1px solid var(--pi-border); border-radius: 6px; font-size: 14px; color: var(--pi-text); font-family: inherit; }
1049
- .task-form textarea { min-height: 9rem; max-height: 24rem; resize: vertical; line-height: 1.45; }
1050
- .task-form input[type="text"]::placeholder, .task-form textarea::placeholder { color: var(--pi-muted); }
1051
- .checkbox-field { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px; margin-top: 8px; }
1052
- .checkbox-field input { width: 18px; height: 18px; }
1053
- .field-help { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
1054
- .field-error { margin: 0 0 8px; color: var(--pi-danger); font-size: 12px; }
1055
- .editor-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
1056
- .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
1057
- .empty-state p { margin: 6px 0 0; }
1058
- .panel-status { margin: 12px 12px 0; }
1059
- .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
1060
- .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
1061
- .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }
1062
- .status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
1063
- .status.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
1064
- .status p { margin: 6px 0; }
1065
- .empty { padding: 16px; color: var(--pi-muted); }
1066
- @container (max-width: 600px) {
1067
- .task-card { grid-template-columns: minmax(0, 1fr); }
1068
- .task-actions { justify-content: flex-start; }
1069
- }
1070
- </style>
1071
- `;
1274
+ return `<style>
1275
+ :host { display: block; min-width: 0; container-type: inline-size; }
1276
+ .tasks-panel { min-width: 0; container-type: inline-size; color: var(--pi-text); }
1277
+ .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
1278
+ .toolbar h2 { margin: 0; font-size: 15px; }
1279
+ .toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1280
+ .viewer { box-sizing: border-box; min-width: 0; min-height: 0; overflow: auto; padding: 12px; }
1281
+ .tasks-viewer { display: grid; align-content: start; gap: 12px; }
1282
+ .scope-filters { display: inline-flex; flex-wrap: wrap; gap: 6px; }
1283
+ .scope-filter { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
1284
+ .scope-filter.selected { border-color: var(--pi-accent-border); background: var(--pi-accent); color: var(--pi-bg); }
1285
+ .count, .catalog-count, .group-count { color: var(--pi-muted); font-size: 12px; white-space: nowrap; }
1286
+ .scope-filter.selected .count { color: inherit; }
1287
+ .helper, .muted, .field-help { color: var(--pi-muted); }
1288
+ .helper { margin: 0; line-height: 1.45; }
1289
+ .catalog-list { display: grid; gap: 16px; min-width: 0; }
1290
+ .scope-catalog { display: grid; gap: 10px; min-width: 0; }
1291
+ .scope-catalog > h3 { margin: 0; color: var(--pi-text-secondary); font-size: 13px; }
1292
+ .tasks { display: grid; gap: 10px; min-width: 0; }
1293
+ .task-group { min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); }
1294
+ .task-group[open] { background: var(--pi-surface); }
1295
+ .task-group summary { display: flex; align-items: center; gap: 8px; padding: 10px 12px; cursor: pointer; list-style: none; }
1296
+ .task-group summary::-webkit-details-marker { display: none; }
1297
+ .task-group summary::marker { content: ""; }
1298
+ .disclosure-chevron { color: var(--pi-muted); font-size: 18px; line-height: 1; transition: transform .15s ease; }
1299
+ .task-group[open] .disclosure-chevron { transform: rotate(90deg); }
1300
+ .group-title { flex: 1 1 auto; min-width: 0; font-weight: 600; }
1301
+ .task-group-body { display: grid; gap: 10px; padding: 0 10px 10px; }
1302
+ .ungrouped { display: grid; gap: 10px; }
1303
+ .task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); padding: 12px; }
1304
+ .task-copy { display: grid; min-width: 0; gap: 5px; }
1305
+ .task-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
1306
+ .task-description { color: var(--pi-muted); }
1307
+ .scope-badge { display: inline-block; border: 1px solid var(--pi-border-muted); border-radius: 4px; padding: 2px 6px; color: var(--pi-text-secondary); font-size: 11px; white-space: nowrap; }
1308
+ .task-script, .diagnostic { box-sizing: border-box; max-width: 100%; margin: 0; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
1309
+ .task-script { max-height: 12rem; overflow-y: auto; padding: 8px; }
1310
+ .diagnostic { max-height: 12rem; overflow: auto; padding: 8px; }
1311
+ .task-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1312
+ .task-editor, .confirmation { width: min(100%, 620px); box-sizing: border-box; padding: 16px; background: var(--pi-surface); border: 1px solid var(--pi-border); border-radius: 8px; }
1313
+ .editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
1314
+ .task-editor h3, .confirmation h3 { margin: 0 0 16px; font-size: 18px; }
1315
+ .confirmation p { margin: 0 0 10px; line-height: 1.5; }
1316
+ .confirmation .task-script { margin: 10px 0 16px; }
1317
+ .task-form { display: grid; gap: 6px; }
1318
+ .task-form > label, .checkbox-field > label { font-size: 14px; font-weight: 500; }
1319
+ .task-form .required { color: var(--pi-danger); }
1320
+ .task-form input[type="text"], .task-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; background: var(--pi-bg); border: 1px solid var(--pi-border); border-radius: 6px; font-size: 14px; color: var(--pi-text); font-family: inherit; }
1321
+ .task-form textarea { min-height: 9rem; max-height: 24rem; resize: vertical; line-height: 1.45; }
1322
+ .task-form input[type="text"]::placeholder, .task-form textarea::placeholder { color: var(--pi-muted); }
1323
+ .checkbox-field { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px; margin-top: 8px; }
1324
+ .checkbox-field input { width: 18px; height: 18px; }
1325
+ .field-help { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
1326
+ .field-error { margin: 0 0 8px; color: var(--pi-danger); font-size: 12px; }
1327
+ .editor-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
1328
+ .empty-state { display: grid; gap: 6px; border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
1329
+ .panel-status { margin: 12px 12px 0; }
1330
+ .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
1331
+ .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
1332
+ .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }
1333
+ .status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
1334
+ .status.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
1335
+ .status p { margin: 6px 0; }
1336
+ .empty { padding: 16px; color: var(--pi-muted); }
1337
+ button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; }
1338
+ button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
1339
+ button.danger, button.danger-secondary { border-color: var(--pi-danger); }
1340
+ button.danger { background: var(--pi-danger); color: var(--pi-bg); }
1341
+ button.danger-secondary { background: var(--pi-surface); color: var(--pi-danger); }
1342
+ button:disabled { cursor: wait; opacity: .65; }
1343
+ button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible { outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
1344
+ @container (max-width: 600px) { .task-card { grid-template-columns: minmax(0, 1fr); } .task-actions { justify-content: flex-start; } }
1345
+ </style>`;
1072
1346
  }