@evanbobo/colco4dsh 0.0.0-stage → 0.1.4
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.
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/client.js +1320 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/index.mjs +39 -0
- package/lib/cli.mjs +134 -0
- package/lib/dashboard.mjs +109 -0
- package/lib/format.mjs +174 -0
- package/lib/plugin.mjs +158 -0
- package/lib/routes.mjs +496 -0
- package/lib/skill.mjs +43 -0
- package/lib/tasks.mjs +158 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/client.js
ADDED
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
// colco4dsh browser half: the "Colco" row in DSH's left sidebar, above More, and the page it opens in the main
|
|
2
|
+
// column. DSH loads this file as a classic script from exports["./client"]; it only registers a lazy factory, and
|
|
3
|
+
// React comes from DSH's shared module table. The page talks to the Host routes in lib/routes.mjs and nothing
|
|
4
|
+
// else; it never runs git or reaches the Colco server.
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: "@evanbobo/colco4dsh",
|
|
7
|
+
factory(require) {
|
|
8
|
+
const React = require("react");
|
|
9
|
+
const { createElement: h, Fragment, useCallback, useEffect, useState } = React;
|
|
10
|
+
|
|
11
|
+
/** Base path of the Host routes. DSH serves plugin bundles under /plugins; this longer prefix belongs to us. */
|
|
12
|
+
const API = "/plugins/colco4dsh/api";
|
|
13
|
+
/** Header every POST carries so a cross-site form cannot trigger an action. */
|
|
14
|
+
const ACTION_HEADER = "x-colco4dsh";
|
|
15
|
+
/** Id of the left-sidebar row and key of the main panel it opens. */
|
|
16
|
+
const PANEL_ID = "colco";
|
|
17
|
+
/** Row position in the left sidebar: after Plugins (0) and Automation tasks (10), so directly above More. */
|
|
18
|
+
const PANEL_ORDER = 20;
|
|
19
|
+
/** Locale namespace of this plugin's copy. */
|
|
20
|
+
const NS = "colco4dsh";
|
|
21
|
+
/** How often status, runs and decisions refresh while the tab is visible. */
|
|
22
|
+
const POLL_MS = 10000;
|
|
23
|
+
/** How long one request may take before the tab gives up on it. */
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 65000;
|
|
25
|
+
|
|
26
|
+
/** English copy. Chinese has the same keys; it is written with \u escapes so the repository's English-only check, which scans this file, stays green. */
|
|
27
|
+
const en = {
|
|
28
|
+
"panel.title": "Colco",
|
|
29
|
+
"panel.description": "Runner, active runs and open decisions",
|
|
30
|
+
"runner.title": "Runner",
|
|
31
|
+
"state.running": "Running",
|
|
32
|
+
"state.stopped": "Stopped",
|
|
33
|
+
"state.not_configured": "Not configured",
|
|
34
|
+
"state.rules_error": "Rules error",
|
|
35
|
+
"state.unknown": "Unknown",
|
|
36
|
+
"state.loading": "Loading",
|
|
37
|
+
"runner.steps": "{busy} of {concurrency} steps running",
|
|
38
|
+
"runner.stepsNow": "{busy} steps running",
|
|
39
|
+
"runner.name": "Runner {name}, version {version}",
|
|
40
|
+
"runner.start": "Start",
|
|
41
|
+
"runner.stop": "Stop",
|
|
42
|
+
"runner.refresh": "Refresh",
|
|
43
|
+
"runner.editRules": "Edit rules",
|
|
44
|
+
"runner.startHint": "Start installs the runner as a login service; Stop stops it.",
|
|
45
|
+
"runner.notConfigured": "No runner is set up on this computer. Log in with colco add, run colco runner init in a terminal, then finish the setup below.",
|
|
46
|
+
"runner.updated": "Updated {time}",
|
|
47
|
+
"error.section": "Could not load {section}: {message}",
|
|
48
|
+
"section.runner": "the runner",
|
|
49
|
+
"section.runs": "runs",
|
|
50
|
+
"section.decisions": "decisions",
|
|
51
|
+
"runs.title": "Active runs",
|
|
52
|
+
"runs.empty": "No active runs.",
|
|
53
|
+
"phase.queued": "Queued",
|
|
54
|
+
"phase.running": "Running",
|
|
55
|
+
"phase.waiting": "Waiting for you",
|
|
56
|
+
"phase.uncertain": "Uncertain",
|
|
57
|
+
"run.meta": "{workflow} / {step}",
|
|
58
|
+
"run.agent": "Agent {agent}",
|
|
59
|
+
"run.for": "Running for {duration}",
|
|
60
|
+
"run.openTask": "Open task",
|
|
61
|
+
"run.openResult": "Open merge request",
|
|
62
|
+
"run.task": "Task #{id}",
|
|
63
|
+
"duration.s": "{n}s",
|
|
64
|
+
"duration.m": "{n}m",
|
|
65
|
+
"duration.h": "{h}h {m}m",
|
|
66
|
+
"duration.d": "{d}d {h}h",
|
|
67
|
+
"decisions.title": "Decisions",
|
|
68
|
+
"decisions.empty": "Nothing is waiting for your decision.",
|
|
69
|
+
"decisions.recommended": "Recommended",
|
|
70
|
+
"decisions.note": "Note (optional)",
|
|
71
|
+
"decisions.answer": "Answer",
|
|
72
|
+
"decisions.answering": "Answering",
|
|
73
|
+
"decisions.pick": "Choose an option first.",
|
|
74
|
+
"decisions.open": "Open in Colco",
|
|
75
|
+
"setup.title": "Runner setup",
|
|
76
|
+
"setup.loading": "Loading the rules",
|
|
77
|
+
"setup.missing": "There is no rules file yet. Run colco runner init in a terminal to create it from the template, then open this setup again.",
|
|
78
|
+
"setup.unreadable": "The rules file at {path} is not a JSON object. Fix or remove it in an editor; saving here would lose its agents and workflows.",
|
|
79
|
+
"setup.invalid": "The rules on disk have these errors:",
|
|
80
|
+
"setup.optionsFailed": "Could not load labels and repositories: {message}",
|
|
81
|
+
"setup.labels": "Labels",
|
|
82
|
+
"setup.labelsHint": "Tasks with a label run its workflow.",
|
|
83
|
+
"setup.label": "Label",
|
|
84
|
+
"setup.workflow": "Workflow",
|
|
85
|
+
"setup.chooseLabel": "Choose a label",
|
|
86
|
+
"setup.chooseWorkflow": "Choose a workflow",
|
|
87
|
+
"setup.addLabel": "Add label",
|
|
88
|
+
"setup.remove": "Remove",
|
|
89
|
+
"setup.folders": "Folders",
|
|
90
|
+
"setup.foldersHint": "The git repositories the runner may work in.",
|
|
91
|
+
"setup.path": "Folder path",
|
|
92
|
+
"setup.addFolder": "Add folder",
|
|
93
|
+
"setup.dsh": "Let workflow steps use DSH",
|
|
94
|
+
"setup.dshHint": "Adds a DSH agent to the rules. A step uses it only when its rules name it; the plugin alone never claims or reassigns tasks.",
|
|
95
|
+
"setup.ok": "Git repository, origin {origin}",
|
|
96
|
+
"setup.unchecked": "Not checked yet; saving checks it.",
|
|
97
|
+
"setup.missingFolder": "The folder does not exist.",
|
|
98
|
+
"setup.notGit": "The folder is not a git repository.",
|
|
99
|
+
"setup.noOrigin": "The repository has no origin remote.",
|
|
100
|
+
"setup.repository": "Colco repository",
|
|
101
|
+
"setup.chooseRepository": "Choose the repository for the fix",
|
|
102
|
+
"setup.fix": "Run in a terminal:",
|
|
103
|
+
"setup.copy": "Copy",
|
|
104
|
+
"setup.copied": "Copied",
|
|
105
|
+
"setup.save": "Save rules",
|
|
106
|
+
"setup.saving": "Saving",
|
|
107
|
+
"setup.cancel": "Close",
|
|
108
|
+
"setup.saved": "Saved {path}",
|
|
109
|
+
"setup.savedBackup": "Saved {path}; the previous file is at {backup}",
|
|
110
|
+
"setup.saveFailed": "The rules were not saved: {message}",
|
|
111
|
+
"setup.duplicateLabel": "The label {label} appears twice.",
|
|
112
|
+
"setup.incomplete": "Every label row needs both a label and a workflow.",
|
|
113
|
+
"setup.notOnServer": "{label} (not on the server)",
|
|
114
|
+
"panel.openDecision": "{count} open decision waiting for you",
|
|
115
|
+
"panel.openDecisions": "{count} open decisions waiting for you",
|
|
116
|
+
"runner.editRulesFile": "Edit rules.json",
|
|
117
|
+
"file.title": "Edit rules.json",
|
|
118
|
+
"file.loading": "Loading rules.json",
|
|
119
|
+
"file.loadFailed": "Could not load rules.json: {message}",
|
|
120
|
+
"file.path": "File: {path}",
|
|
121
|
+
"file.missing": "There is no rules.json yet; saving creates it.",
|
|
122
|
+
"file.diskInvalid": "The file on disk has these problems:",
|
|
123
|
+
"file.label": "rules.json contents",
|
|
124
|
+
"file.hint": "Saving checks the rules, keeps a backup of the old file and refuses if the file changed after it was loaded.",
|
|
125
|
+
"file.save": "Save",
|
|
126
|
+
"file.saving": "Saving",
|
|
127
|
+
"file.close": "Close",
|
|
128
|
+
"file.saved": "Saved {path}",
|
|
129
|
+
"file.savedBackup": "Saved {path}; the previous file is at {backup}",
|
|
130
|
+
"file.saveFailed": "The rules were not saved: {message}",
|
|
131
|
+
"file.conflict": "rules.json changed outside this dialog. Review the file on disk, then choose which version to keep.",
|
|
132
|
+
"file.diskLabel": "Current file on disk",
|
|
133
|
+
"file.useDisk": "Use the file on disk",
|
|
134
|
+
"file.overwrite": "Overwrite with my version",
|
|
135
|
+
"tasks.title": "My tasks",
|
|
136
|
+
"tasks.loading": "Loading your tasks",
|
|
137
|
+
"tasks.error": "Could not load your tasks: {message}",
|
|
138
|
+
"tasks.refresh": "Refresh",
|
|
139
|
+
"tasks.empty": "No tasks are assigned to you.",
|
|
140
|
+
"tasks.emptyFiltered": "No tasks match these filters.",
|
|
141
|
+
"tasks.pageEmpty": "There are no tasks on this page.",
|
|
142
|
+
"tasks.filterProject": "Project",
|
|
143
|
+
"tasks.filterStatus": "Status",
|
|
144
|
+
"tasks.allProjects": "All projects",
|
|
145
|
+
"tasks.allStatuses": "All statuses",
|
|
146
|
+
"tasks.optionCount": "{name} ({count})",
|
|
147
|
+
"tasks.projectGone": "Project #{id}",
|
|
148
|
+
"tasks.status.in_progress": "In progress",
|
|
149
|
+
"tasks.status.blocked": "Blocked",
|
|
150
|
+
"tasks.status.review": "In review",
|
|
151
|
+
"tasks.status.todo": "To do",
|
|
152
|
+
"tasks.status.backlog": "Backlog",
|
|
153
|
+
"tasks.status.done": "Done",
|
|
154
|
+
"tasks.priority.high": "High",
|
|
155
|
+
"tasks.priority.medium": "Medium",
|
|
156
|
+
"tasks.priority.low": "Low",
|
|
157
|
+
"tasks.priorityTag": "{priority} priority",
|
|
158
|
+
"tasks.due": "Due {date}",
|
|
159
|
+
"tasks.noDue": "No due date",
|
|
160
|
+
"tasks.groupCount": "{shown} of {total}",
|
|
161
|
+
"tasks.range": "{from}–{to} of {total}",
|
|
162
|
+
"tasks.pageOf": "Page {page} of {pages}",
|
|
163
|
+
"tasks.previous": "Previous",
|
|
164
|
+
"tasks.next": "Next",
|
|
165
|
+
"tasks.pager": "Task pages",
|
|
166
|
+
"tasks.untitled": "Task #{id}",
|
|
167
|
+
"tasks.refreshLabel": "Refresh my tasks",
|
|
168
|
+
};
|
|
169
|
+
const zh = {
|
|
170
|
+
"panel.title": "Colco",
|
|
171
|
+
"panel.description": "Runner\u3001\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c\u548c\u5f85\u51b3\u7b56",
|
|
172
|
+
"runner.title": "Runner",
|
|
173
|
+
"state.running": "\u8fd0\u884c\u4e2d",
|
|
174
|
+
"state.stopped": "\u5df2\u505c\u6b62",
|
|
175
|
+
"state.not_configured": "\u672a\u914d\u7f6e",
|
|
176
|
+
"state.rules_error": "\u89c4\u5219\u6709\u8bef",
|
|
177
|
+
"state.unknown": "\u672a\u77e5",
|
|
178
|
+
"state.loading": "\u52a0\u8f7d\u4e2d",
|
|
179
|
+
"runner.steps": "{busy}/{concurrency} \u4e2a\u6b65\u9aa4\u6b63\u5728\u8fd0\u884c",
|
|
180
|
+
"runner.stepsNow": "{busy} \u4e2a\u6b65\u9aa4\u6b63\u5728\u8fd0\u884c",
|
|
181
|
+
"runner.name": "Runner {name}\uff0c\u7248\u672c {version}",
|
|
182
|
+
"runner.start": "\u542f\u52a8",
|
|
183
|
+
"runner.stop": "\u505c\u6b62",
|
|
184
|
+
"runner.refresh": "\u5237\u65b0",
|
|
185
|
+
"runner.editRules": "\u7f16\u8f91\u89c4\u5219",
|
|
186
|
+
"runner.startHint": "\u542f\u52a8\u4f1a\u628a Runner \u5b89\u88c5\u4e3a\u767b\u5f55\u670d\u52a1\uff1b\u505c\u6b62\u4f1a\u505c\u6389\u5b83\u3002",
|
|
187
|
+
"runner.notConfigured": "\u8fd9\u53f0\u7535\u8111\u8fd8\u6ca1\u6709\u8bbe\u7f6e Runner\u3002\u8bf7\u5148\u7528 colco add \u767b\u5f55\uff0c\u518d\u5728\u7ec8\u7aef\u8fd0\u884c colco runner init\uff0c\u7136\u540e\u5b8c\u6210\u4e0b\u9762\u7684\u8bbe\u7f6e\u3002",
|
|
188
|
+
"runner.updated": "\u66f4\u65b0\u4e8e {time}",
|
|
189
|
+
"error.section": "\u65e0\u6cd5\u52a0\u8f7d{section}\uff1a{message}",
|
|
190
|
+
"section.runner": "Runner",
|
|
191
|
+
"section.runs": "\u8fd0\u884c",
|
|
192
|
+
"section.decisions": "\u51b3\u7b56",
|
|
193
|
+
"runs.title": "\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c",
|
|
194
|
+
"runs.empty": "\u6ca1\u6709\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c\u3002",
|
|
195
|
+
"phase.queued": "\u6392\u961f\u4e2d",
|
|
196
|
+
"phase.running": "\u8fd0\u884c\u4e2d",
|
|
197
|
+
"phase.waiting": "\u7b49\u4f60\u51b3\u5b9a",
|
|
198
|
+
"phase.uncertain": "\u72b6\u6001\u4e0d\u660e",
|
|
199
|
+
"run.meta": "{workflow} / {step}",
|
|
200
|
+
"run.agent": "Agent {agent}",
|
|
201
|
+
"run.for": "\u5df2\u8fd0\u884c {duration}",
|
|
202
|
+
"run.openTask": "\u6253\u5f00\u4efb\u52a1",
|
|
203
|
+
"run.openResult": "\u6253\u5f00\u5408\u5e76\u8bf7\u6c42",
|
|
204
|
+
"run.task": "\u4efb\u52a1 #{id}",
|
|
205
|
+
"duration.s": "{n} \u79d2",
|
|
206
|
+
"duration.m": "{n} \u5206\u949f",
|
|
207
|
+
"duration.h": "{h} \u5c0f\u65f6 {m} \u5206\u949f",
|
|
208
|
+
"duration.d": "{d} \u5929 {h} \u5c0f\u65f6",
|
|
209
|
+
"decisions.title": "\u51b3\u7b56",
|
|
210
|
+
"decisions.empty": "\u6ca1\u6709\u7b49\u4f60\u51b3\u5b9a\u7684\u4e8b\u3002",
|
|
211
|
+
"decisions.recommended": "\u63a8\u8350",
|
|
212
|
+
"decisions.note": "\u5907\u6ce8\uff08\u53ef\u9009\uff09",
|
|
213
|
+
"decisions.answer": "\u56de\u7b54",
|
|
214
|
+
"decisions.answering": "\u6b63\u5728\u56de\u7b54",
|
|
215
|
+
"decisions.pick": "\u8bf7\u5148\u9009\u62e9\u4e00\u4e2a\u9009\u9879\u3002",
|
|
216
|
+
"decisions.open": "\u5728 Colco \u4e2d\u6253\u5f00",
|
|
217
|
+
"setup.title": "Runner \u8bbe\u7f6e",
|
|
218
|
+
"setup.loading": "\u6b63\u5728\u52a0\u8f7d\u89c4\u5219",
|
|
219
|
+
"setup.missing": "\u8fd8\u6ca1\u6709\u89c4\u5219\u6587\u4ef6\u3002\u8bf7\u5728\u7ec8\u7aef\u8fd0\u884c colco runner init \u4ece\u6a21\u677f\u521b\u5efa\uff0c\u7136\u540e\u91cd\u65b0\u6253\u5f00\u6b64\u8bbe\u7f6e\u3002",
|
|
220
|
+
"setup.unreadable": "{path} \u5904\u7684\u89c4\u5219\u6587\u4ef6\u4e0d\u662f JSON \u5bf9\u8c61\u3002\u8bf7\u5728\u7f16\u8f91\u5668\u4e2d\u4fee\u590d\u6216\u5220\u9664\u5b83\uff1b\u5728\u8fd9\u91cc\u4fdd\u5b58\u4f1a\u4e22\u5931\u5176\u4e2d\u7684 agents \u548c workflows\u3002",
|
|
221
|
+
"setup.invalid": "\u78c1\u76d8\u4e0a\u7684\u89c4\u5219\u6709\u4ee5\u4e0b\u9519\u8bef\uff1a",
|
|
222
|
+
"setup.optionsFailed": "\u65e0\u6cd5\u52a0\u8f7d\u6807\u7b7e\u548c\u4ed3\u5e93\uff1a{message}",
|
|
223
|
+
"setup.labels": "\u6807\u7b7e",
|
|
224
|
+
"setup.labelsHint": "\u5e26\u6709\u8be5\u6807\u7b7e\u7684\u4efb\u52a1\u4f1a\u8fd0\u884c\u5bf9\u5e94\u7684\u5de5\u4f5c\u6d41\u3002",
|
|
225
|
+
"setup.label": "\u6807\u7b7e",
|
|
226
|
+
"setup.workflow": "\u5de5\u4f5c\u6d41",
|
|
227
|
+
"setup.chooseLabel": "\u9009\u62e9\u6807\u7b7e",
|
|
228
|
+
"setup.chooseWorkflow": "\u9009\u62e9\u5de5\u4f5c\u6d41",
|
|
229
|
+
"setup.addLabel": "\u6dfb\u52a0\u6807\u7b7e",
|
|
230
|
+
"setup.remove": "\u79fb\u9664",
|
|
231
|
+
"setup.folders": "\u6587\u4ef6\u5939",
|
|
232
|
+
"setup.foldersHint": "Runner \u53ef\u4ee5\u5de5\u4f5c\u7684 git \u4ed3\u5e93\u76ee\u5f55\u3002",
|
|
233
|
+
"setup.path": "\u6587\u4ef6\u5939\u8def\u5f84",
|
|
234
|
+
"setup.addFolder": "\u6dfb\u52a0\u6587\u4ef6\u5939",
|
|
235
|
+
"setup.dsh": "\u8ba9\u5de5\u4f5c\u6d41\u6b65\u9aa4\u53ef\u4ee5\u4f7f\u7528 DSH",
|
|
236
|
+
"setup.dshHint": "\u5728\u89c4\u5219\u91cc\u52a0\u5165\u4e00\u4e2a DSH agent\u3002\u53ea\u6709\u89c4\u5219\u91cc\u660e\u786e\u6307\u5b9a\u5b83\u7684\u6b65\u9aa4\u624d\u4f1a\u7528\u5230\uff1b\u4ec5\u5b89\u88c5\u63d2\u4ef6\u4e0d\u4f1a\u9886\u53d6\u6216\u6539\u6d3e\u4efb\u52a1\u3002",
|
|
237
|
+
"setup.ok": "git \u4ed3\u5e93\uff0corigin \u4e3a {origin}",
|
|
238
|
+
"setup.unchecked": "\u5c1a\u672a\u68c0\u67e5\uff1b\u4fdd\u5b58\u540e\u4f1a\u68c0\u67e5\u3002",
|
|
239
|
+
"setup.missingFolder": "\u6587\u4ef6\u5939\u4e0d\u5b58\u5728\u3002",
|
|
240
|
+
"setup.notGit": "\u8be5\u6587\u4ef6\u5939\u4e0d\u662f git \u4ed3\u5e93\u3002",
|
|
241
|
+
"setup.noOrigin": "\u8be5\u4ed3\u5e93\u6ca1\u6709 origin remote\u3002",
|
|
242
|
+
"setup.repository": "Colco \u4ed3\u5e93",
|
|
243
|
+
"setup.chooseRepository": "\u9009\u62e9\u7528\u4e8e\u4fee\u590d\u7684\u4ed3\u5e93",
|
|
244
|
+
"setup.fix": "\u5728\u7ec8\u7aef\u8fd0\u884c\uff1a",
|
|
245
|
+
"setup.copy": "\u590d\u5236",
|
|
246
|
+
"setup.copied": "\u5df2\u590d\u5236",
|
|
247
|
+
"setup.save": "\u4fdd\u5b58\u89c4\u5219",
|
|
248
|
+
"setup.saving": "\u6b63\u5728\u4fdd\u5b58",
|
|
249
|
+
"setup.cancel": "\u5173\u95ed",
|
|
250
|
+
"setup.saved": "\u5df2\u4fdd\u5b58 {path}",
|
|
251
|
+
"setup.savedBackup": "\u5df2\u4fdd\u5b58 {path}\uff1b\u539f\u6587\u4ef6\u5728 {backup}",
|
|
252
|
+
"setup.saveFailed": "\u89c4\u5219\u672a\u4fdd\u5b58\uff1a{message}",
|
|
253
|
+
"setup.duplicateLabel": "\u6807\u7b7e {label} \u51fa\u73b0\u4e86\u4e24\u6b21\u3002",
|
|
254
|
+
"setup.incomplete": "\u6bcf\u4e00\u884c\u6807\u7b7e\u90fd\u9700\u8981\u540c\u65f6\u9009\u62e9\u6807\u7b7e\u548c\u5de5\u4f5c\u6d41\u3002",
|
|
255
|
+
"setup.notOnServer": "{label}\uff08\u670d\u52a1\u5668\u4e0a\u6ca1\u6709\uff09",
|
|
256
|
+
"panel.openDecision": "{count} \u4e2a\u5f85\u4f60\u51b3\u7b56",
|
|
257
|
+
"panel.openDecisions": "{count} \u4e2a\u5f85\u4f60\u51b3\u7b56",
|
|
258
|
+
"runner.editRulesFile": "\u7f16\u8f91 rules.json",
|
|
259
|
+
"file.title": "\u7f16\u8f91 rules.json",
|
|
260
|
+
"file.loading": "\u6b63\u5728\u52a0\u8f7d rules.json",
|
|
261
|
+
"file.loadFailed": "\u65e0\u6cd5\u52a0\u8f7d rules.json\uff1a{message}",
|
|
262
|
+
"file.path": "\u6587\u4ef6\uff1a{path}",
|
|
263
|
+
"file.missing": "\u8fd8\u6ca1\u6709 rules.json\uff1b\u4fdd\u5b58\u65f6\u4f1a\u521b\u5efa\u5b83\u3002",
|
|
264
|
+
"file.diskInvalid": "\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6\u6709\u4ee5\u4e0b\u95ee\u9898\uff1a",
|
|
265
|
+
"file.label": "rules.json \u5185\u5bb9",
|
|
266
|
+
"file.hint": "\u4fdd\u5b58\u524d\u4f1a\u68c0\u67e5\u89c4\u5219\u5e76\u5907\u4efd\u539f\u6587\u4ef6\uff1b\u5982\u679c\u6587\u4ef6\u5728\u52a0\u8f7d\u540e\u88ab\u6539\u8fc7\uff0c\u4fdd\u5b58\u4f1a\u88ab\u62d2\u7edd\u3002",
|
|
267
|
+
"file.save": "\u4fdd\u5b58",
|
|
268
|
+
"file.saving": "\u6b63\u5728\u4fdd\u5b58",
|
|
269
|
+
"file.close": "\u5173\u95ed",
|
|
270
|
+
"file.saved": "\u5df2\u4fdd\u5b58 {path}",
|
|
271
|
+
"file.savedBackup": "\u5df2\u4fdd\u5b58 {path}\uff1b\u539f\u6587\u4ef6\u5728 {backup}",
|
|
272
|
+
"file.saveFailed": "\u89c4\u5219\u672a\u4fdd\u5b58\uff1a{message}",
|
|
273
|
+
"file.conflict": "rules.json \u5728\u6b64\u5bf9\u8bdd\u6846\u4e4b\u5916\u88ab\u6539\u8fc7\u3002\u8bf7\u5148\u67e5\u770b\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6\uff0c\u518d\u9009\u62e9\u4fdd\u7559\u54ea\u4e2a\u7248\u672c\u3002",
|
|
274
|
+
"file.diskLabel": "\u78c1\u76d8\u4e0a\u7684\u5f53\u524d\u6587\u4ef6",
|
|
275
|
+
"file.useDisk": "\u4f7f\u7528\u78c1\u76d8\u4e0a\u7684\u6587\u4ef6",
|
|
276
|
+
"file.overwrite": "\u7528\u6211\u7684\u7248\u672c\u8986\u76d6",
|
|
277
|
+
"tasks.title": "\u6211\u7684\u4efb\u52a1",
|
|
278
|
+
"tasks.loading": "\u6b63\u5728\u52a0\u8f7d\u4f60\u7684\u4efb\u52a1",
|
|
279
|
+
"tasks.error": "\u65e0\u6cd5\u52a0\u8f7d\u4f60\u7684\u4efb\u52a1\uff1a{message}",
|
|
280
|
+
"tasks.refresh": "\u5237\u65b0",
|
|
281
|
+
"tasks.empty": "\u6ca1\u6709\u5206\u914d\u7ed9\u4f60\u7684\u4efb\u52a1\u3002",
|
|
282
|
+
"tasks.emptyFiltered": "\u6ca1\u6709\u7b26\u5408\u7b5b\u9009\u6761\u4ef6\u7684\u4efb\u52a1\u3002",
|
|
283
|
+
"tasks.pageEmpty": "\u8fd9\u4e00\u9875\u6ca1\u6709\u4efb\u52a1\u3002",
|
|
284
|
+
"tasks.filterProject": "\u9879\u76ee",
|
|
285
|
+
"tasks.filterStatus": "\u72b6\u6001",
|
|
286
|
+
"tasks.allProjects": "\u5168\u90e8\u9879\u76ee",
|
|
287
|
+
"tasks.allStatuses": "\u5168\u90e8\u72b6\u6001",
|
|
288
|
+
"tasks.optionCount": "{name}\uff08{count} \u4e2a\uff09",
|
|
289
|
+
"tasks.projectGone": "\u9879\u76ee #{id}",
|
|
290
|
+
"tasks.status.in_progress": "\u8fdb\u884c\u4e2d",
|
|
291
|
+
"tasks.status.blocked": "\u963b\u585e",
|
|
292
|
+
"tasks.status.review": "\u5f85\u9a8c\u6536",
|
|
293
|
+
"tasks.status.todo": "\u5f85\u529e",
|
|
294
|
+
"tasks.status.backlog": "\u5f85\u6574\u7406",
|
|
295
|
+
"tasks.status.done": "\u5df2\u5b8c\u6210",
|
|
296
|
+
"tasks.priority.high": "\u9ad8",
|
|
297
|
+
"tasks.priority.medium": "\u4e2d",
|
|
298
|
+
"tasks.priority.low": "\u4f4e",
|
|
299
|
+
"tasks.priorityTag": "{priority}\u4f18\u5148\u7ea7",
|
|
300
|
+
"tasks.due": "\u622a\u6b62 {date}",
|
|
301
|
+
"tasks.noDue": "\u65e0\u622a\u6b62\u65e5\u671f",
|
|
302
|
+
"tasks.groupCount": "\u672c\u9875 {shown} \u4e2a\uff0c\u5171 {total} \u4e2a",
|
|
303
|
+
"tasks.range": "\u7b2c {from}\u2013{to} \u4e2a\uff0c\u5171 {total} \u4e2a",
|
|
304
|
+
"tasks.pageOf": "\u7b2c {page} / {pages} \u9875",
|
|
305
|
+
"tasks.previous": "\u4e0a\u4e00\u9875",
|
|
306
|
+
"tasks.next": "\u4e0b\u4e00\u9875",
|
|
307
|
+
"tasks.pager": "\u4efb\u52a1\u5206\u9875",
|
|
308
|
+
"tasks.untitled": "\u4efb\u52a1 #{id}",
|
|
309
|
+
"tasks.refreshLabel": "\u5237\u65b0\u6211\u7684\u4efb\u52a1",
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Fill `{name}` placeholders the way DSH's locale service does.
|
|
314
|
+
* @param {string} template
|
|
315
|
+
* @param {Record<string, unknown> | undefined} params
|
|
316
|
+
*/
|
|
317
|
+
function fill(template, params) {
|
|
318
|
+
return params ? template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : template;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A translate function over one dictionary, for when DSH's locale service is absent (tests, other hosts).
|
|
323
|
+
* @param {Record<string, string>} dict
|
|
324
|
+
* @returns {(key: string, params?: Record<string, unknown>) => string}
|
|
325
|
+
*/
|
|
326
|
+
function translator(dict) {
|
|
327
|
+
return (key, params) => fill(dict[key] ?? en[key] ?? key, params);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A failed route call: its HTTP status, its message and any validation errors the route returned. */
|
|
331
|
+
class ApiError extends Error {
|
|
332
|
+
constructor(message, status, errors) {
|
|
333
|
+
super(message);
|
|
334
|
+
this.status = status;
|
|
335
|
+
this.errors = errors;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Call one Host route and return its JSON reply.
|
|
341
|
+
* @param {string} name - route name, e.g. "status".
|
|
342
|
+
* @param {{ method?: "GET" | "POST", body?: unknown, signal?: AbortSignal }} [options]
|
|
343
|
+
*/
|
|
344
|
+
async function call(name, { method = "GET", body, signal } = {}) {
|
|
345
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
346
|
+
const headers = { accept: "application/json" };
|
|
347
|
+
if (method === "POST") Object.assign(headers, { "content-type": "application/json", [ACTION_HEADER]: "1" });
|
|
348
|
+
const response = await fetch(`${API}/${name}`, {
|
|
349
|
+
method, headers, credentials: "same-origin", cache: "no-store",
|
|
350
|
+
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined,
|
|
351
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
352
|
+
});
|
|
353
|
+
let reply;
|
|
354
|
+
try {
|
|
355
|
+
reply = await response.json();
|
|
356
|
+
} catch {
|
|
357
|
+
reply = undefined;
|
|
358
|
+
}
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
const message = typeof reply?.error?.message === "string" ? reply.error.message : `HTTP ${response.status}`;
|
|
361
|
+
throw new ApiError(message, response.status, Array.isArray(reply?.errors) ? reply.errors.filter((item) => typeof item === "string") : []);
|
|
362
|
+
}
|
|
363
|
+
return reply ?? {};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Quote one shell word so a path or URL from Colco is copied as itself and runs nothing.
|
|
368
|
+
* @param {string} value
|
|
369
|
+
*/
|
|
370
|
+
function shellQuote(value) {
|
|
371
|
+
return /^[\w@%+=:,./-]+$/.test(value) && !value.startsWith("-") ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The command that fixes a folder the runner cannot use, or undefined when there is nothing to fix.
|
|
376
|
+
* Built from the repository the user picked; the plugin only shows it and never runs it.
|
|
377
|
+
* @param {{ exists: boolean, git: boolean, origin: string | null, path: string, resolvedPath?: string }} check
|
|
378
|
+
* @param {string | undefined} url - clone URL of the chosen Colco repository.
|
|
379
|
+
*/
|
|
380
|
+
function fixCommand(check, url) {
|
|
381
|
+
if (!url || (check.exists && check.git && check.origin)) return undefined;
|
|
382
|
+
const path = shellQuote(check.resolvedPath || check.path);
|
|
383
|
+
const remote = shellQuote(url);
|
|
384
|
+
if (!check.exists) return `git clone -- ${remote} ${path}`;
|
|
385
|
+
if (!check.git) return `git -C ${path} init && git -C ${path} remote add origin ${remote}`;
|
|
386
|
+
return `git -C ${path} remote add origin ${remote}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A compact duration from milliseconds.
|
|
391
|
+
* @param {number} ms
|
|
392
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} t
|
|
393
|
+
*/
|
|
394
|
+
function formatDuration(ms, t) {
|
|
395
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
396
|
+
if (seconds < 60) return t("duration.s", { n: seconds });
|
|
397
|
+
const minutes = Math.floor(seconds / 60);
|
|
398
|
+
if (minutes < 60) return t("duration.m", { n: minutes });
|
|
399
|
+
const hours = Math.floor(minutes / 60);
|
|
400
|
+
if (hours < 24) return t("duration.h", { h: hours, m: minutes % 60 });
|
|
401
|
+
return t("duration.d", { d: Math.floor(hours / 24), h: hours % 24 });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The editable rows of a rules object: label → workflow pairs and folders (with their on-disk index).
|
|
406
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk.
|
|
407
|
+
*/
|
|
408
|
+
function rulesToForm(rules) {
|
|
409
|
+
const labels = rules && rules.labels && typeof rules.labels === "object" && !Array.isArray(rules.labels)
|
|
410
|
+
? Object.entries(rules.labels).map(([label, workflow]) => ({ label, workflow: typeof workflow === "string" ? workflow : "" }))
|
|
411
|
+
: [];
|
|
412
|
+
const folders = rules && Array.isArray(rules.folders)
|
|
413
|
+
? rules.folders.map((folder, index) => ({ index, path: folder && typeof folder.path === "string" ? folder.path : "" }))
|
|
414
|
+
: [];
|
|
415
|
+
return { labels, folders };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The whole rules object to save: the one on disk with its labels and folders replaced by the form's rows.
|
|
420
|
+
* Agents, workflows, concurrency and each kept folder's other fields (such as `setup`) stay as they are.
|
|
421
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk, or null when there is none.
|
|
422
|
+
* @param {{ labels: { label: string, workflow: string }[], folders: { index?: number, path: string }[] }} form
|
|
423
|
+
* @returns {{ rules?: Record<string, unknown>, error?: { key: string, params?: Record<string, unknown> } }}
|
|
424
|
+
*/
|
|
425
|
+
function formToRules(rules, form) {
|
|
426
|
+
const labels = {};
|
|
427
|
+
for (const row of form.labels) {
|
|
428
|
+
const label = row.label.trim();
|
|
429
|
+
if (!label && !row.workflow) continue;
|
|
430
|
+
if (!label || !row.workflow) return { error: { key: "setup.incomplete" } };
|
|
431
|
+
if (Object.prototype.hasOwnProperty.call(labels, label)) return { error: { key: "setup.duplicateLabel", params: { label } } };
|
|
432
|
+
Object.defineProperty(labels, label, { value: row.workflow, enumerable: true, writable: true, configurable: true });
|
|
433
|
+
}
|
|
434
|
+
const onDisk = rules && Array.isArray(rules.folders) ? rules.folders : [];
|
|
435
|
+
const folders = form.folders.filter((row) => row.path.trim()).map((row) => {
|
|
436
|
+
const original = row.index !== undefined && onDisk[row.index] && typeof onDisk[row.index] === "object" ? onDisk[row.index] : {};
|
|
437
|
+
return { ...original, path: row.path.trim() };
|
|
438
|
+
});
|
|
439
|
+
return { rules: { ...(rules ?? {}), labels, folders } };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Whether the rules define a DSH agent (an agent whose `use` is `dsh`).
|
|
444
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk.
|
|
445
|
+
*/
|
|
446
|
+
function hasDshAgent(rules) {
|
|
447
|
+
const agents = rules && rules.agents && typeof rules.agents === "object" && !Array.isArray(rules.agents) ? Object.values(rules.agents) : [];
|
|
448
|
+
return agents.some((agent) => agent && typeof agent === "object" && agent.use === "dsh");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The rules with a DSH agent added (`on`) or every DSH agent removed. Nothing else changes: other agents, workflows
|
|
453
|
+
* and labels stay, a DSH agent that already exists keeps its settings, and a name already taken by another agent
|
|
454
|
+
* is never overwritten. A step that still names a removed agent is refused by the CLI's rules check on save.
|
|
455
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk, or null when there is none.
|
|
456
|
+
* @param {boolean} on - whether workflow steps may use DSH.
|
|
457
|
+
*/
|
|
458
|
+
function withDshAgent(rules, on) {
|
|
459
|
+
const agents = rules && rules.agents && typeof rules.agents === "object" && !Array.isArray(rules.agents) ? rules.agents : {};
|
|
460
|
+
if (on === hasDshAgent(rules)) return rules ?? { agents: on ? { dsh: { use: "dsh", permissionMode: "workspace-write" } } : {} };
|
|
461
|
+
if (!on) return { ...rules, agents: Object.fromEntries(Object.entries(agents).filter(([, agent]) => !(agent && typeof agent === "object" && agent.use === "dsh"))) };
|
|
462
|
+
let name = "dsh";
|
|
463
|
+
for (let n = 2; Object.prototype.hasOwnProperty.call(agents, name); n += 1) name = `dsh-${n}`;
|
|
464
|
+
return { ...(rules ?? {}), agents: { ...agents, [name]: { use: "dsh", permissionMode: "workspace-write" } } };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Page styles: DSH theme tokens with plain fallbacks, scoped to .c4d-page and .c4d and removed with the page. */
|
|
468
|
+
const CSS = `
|
|
469
|
+
.c4d-page{display:flex;flex-direction:column;flex:1;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary,#1f2328);background:var(--dsw-alias-bg-base,transparent)}
|
|
470
|
+
.c4d-page-scroll{flex:1;min-height:0;overflow:auto;scrollbar-gutter:stable}
|
|
471
|
+
.c4d-page-content{max-width:960px;margin:0 auto;padding:0 clamp(24px,4vw,48px) 48px}
|
|
472
|
+
.c4d-page-heading{display:flex;flex-direction:column;gap:4px;padding-top:28px;margin-bottom:8px}
|
|
473
|
+
.c4d-page-heading h1{margin:0;font-size:20px;line-height:28px;font-weight:500}
|
|
474
|
+
.c4d-page-heading p{margin:0;font-size:13px;color:var(--dsw-alias-label-secondary,#57606a)}
|
|
475
|
+
@media (max-width:767px){.c4d-page-content{padding-inline:20px}}
|
|
476
|
+
.c4d{display:flex;flex-direction:column;gap:16px;padding:12px 0 24px;font:var(--dsw-font-xs-13,13px/20px system-ui);color:var(--dsw-alias-label-primary,#1f2328)}
|
|
477
|
+
.c4d h2{margin:0;font-size:13px;font-weight:600}
|
|
478
|
+
.c4d h3{margin:0;font-size:13px;font-weight:600}
|
|
479
|
+
.c4d p{margin:0}
|
|
480
|
+
.c4d ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
|
|
481
|
+
.c4d-section{display:flex;flex-direction:column;gap:8px}
|
|
482
|
+
.c4d-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
|
483
|
+
.c4d-card{border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:var(--dsw-radius-md,12px);padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-1,transparent)}
|
|
484
|
+
.c4d-muted{color:var(--dsw-alias-label-secondary,#57606a)}
|
|
485
|
+
.c4d-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--dsw-alias-label-tertiary,#8c959f)}
|
|
486
|
+
.c4d-dot[data-tone=ok]{background:var(--dsw-alias-state-success-primary,#1a7f37)}
|
|
487
|
+
.c4d-dot[data-tone=warn]{background:var(--dsw-alias-state-warn-primary,#9a6700)}
|
|
488
|
+
.c4d-dot[data-tone=error]{background:var(--dsw-alias-state-error-primary,#cf222e)}
|
|
489
|
+
.c4d-tag{display:inline-block;border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:6px;padding:0 6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#57606a)}
|
|
490
|
+
.c4d-tag[data-tone=warn]{color:var(--dsw-alias-state-warn-primary,#9a6700);border-color:currentColor}
|
|
491
|
+
.c4d-error{color:var(--dsw-alias-state-error-primary,#cf222e)}
|
|
492
|
+
.c4d-ok{color:var(--dsw-alias-state-success-primary,#1a7f37)}
|
|
493
|
+
.c4d button,.c4d select,.c4d input,.c4d textarea{font:inherit;color:inherit}
|
|
494
|
+
.c4d button{border:1px solid var(--dsw-alias-border-l2,#d0d7de);background:transparent;border-radius:8px;padding:3px 10px;cursor:pointer}
|
|
495
|
+
.c4d button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
|
|
496
|
+
.c4d button:disabled{opacity:.5;cursor:default}
|
|
497
|
+
.c4d button[aria-pressed=true]{border-color:var(--dsw-alias-state-business-primary,#0969da);background:var(--dsw-alias-interactive-bg-hover,rgba(9,105,218,.08))}
|
|
498
|
+
.c4d button.c4d-primary{background:var(--dsw-alias-label-primary,#1f2328);color:var(--dsw-alias-label-primary-inverted,#fff);border-color:transparent}
|
|
499
|
+
.c4d select,.c4d input,.c4d textarea{border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:8px;padding:3px 8px;background:transparent;min-width:0}
|
|
500
|
+
.c4d textarea{width:100%;box-sizing:border-box;resize:vertical}
|
|
501
|
+
.c4d input.c4d-path{flex:1 1 160px}
|
|
502
|
+
.c4d :focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,#0969da);outline-offset:1px}
|
|
503
|
+
.c4d a{color:var(--dsw-alias-link,#0969da)}
|
|
504
|
+
.c4d code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;user-select:all;word-break:break-all;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));border-radius:6px;padding:2px 6px}
|
|
505
|
+
.c4d-options{display:flex;flex-direction:column;align-items:stretch;gap:6px}
|
|
506
|
+
.c4d-options button{text-align:left}
|
|
507
|
+
`;
|
|
508
|
+
|
|
509
|
+
/** Tone of the runner's state dot. */
|
|
510
|
+
const STATE_TONE = { running: "ok", stopped: "idle", not_configured: "warn", rules_error: "error", unknown: "idle" };
|
|
511
|
+
|
|
512
|
+
/** One error message, announced to screen readers. */
|
|
513
|
+
function ErrorLine({ message }) {
|
|
514
|
+
return message ? h("p", { role: "alert", className: "c4d-error" }, message) : null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** A link to Colco or a merge request; only http(s) URLs become links. */
|
|
518
|
+
function ExternalLink({ href, children }) {
|
|
519
|
+
return typeof href === "string" && /^https?:\/\//i.test(href) ? h("a", { href, target: "_blank", rel: "noopener noreferrer" }, children) : null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The header: runner state, steps running now, and Start, Stop, Refresh and Edit rules.
|
|
524
|
+
* @param {{ t: Function, runner: object | null | undefined, loading: boolean, busy: string | undefined, actionError: string | undefined, setupOpen: boolean, onAction: (name: "start" | "stop") => void, onRefresh: () => void, onEditRules: () => void, onEditRulesFile?: () => void, updatedAt: number | undefined }} props
|
|
525
|
+
*/
|
|
526
|
+
function RunnerHeader({ t, runner, loading, busy, actionError, setupOpen, onAction, onRefresh, onEditRules, onEditRulesFile, updatedAt }) {
|
|
527
|
+
const display = runner ? runner.display : loading ? "loading" : "unknown";
|
|
528
|
+
const running = display === "running";
|
|
529
|
+
const steps = running && typeof runner.busy === "number"
|
|
530
|
+
? typeof runner.concurrency === "number" ? t("runner.steps", { busy: runner.busy, concurrency: runner.concurrency }) : t("runner.stepsNow", { busy: runner.busy })
|
|
531
|
+
: undefined;
|
|
532
|
+
return h("section", { className: "c4d-section", "aria-label": t("runner.title") },
|
|
533
|
+
h("div", { className: "c4d-row" },
|
|
534
|
+
h("span", { className: "c4d-dot", "data-tone": STATE_TONE[display] ?? "idle", "aria-hidden": true }),
|
|
535
|
+
h("h2", null, t("runner.title")),
|
|
536
|
+
h("span", { "data-colco4dsh-state": display }, t(`state.${display}`)),
|
|
537
|
+
steps ? h("span", { className: "c4d-muted" }, steps) : null),
|
|
538
|
+
runner && runner.name ? h("p", { className: "c4d-muted" }, t("runner.name", { name: runner.name, version: runner.version ?? "?" })) : null,
|
|
539
|
+
display === "not_configured" ? h("p", null, t("runner.notConfigured")) : null,
|
|
540
|
+
display === "rules_error" && runner.rulesError ? h("p", { className: "c4d-error" }, runner.rulesError) : null,
|
|
541
|
+
runner && Array.isArray(runner.warnings) ? runner.warnings.map((warning, index) => h("p", { key: index, className: "c4d-muted" }, warning)) : null,
|
|
542
|
+
h("div", { className: "c4d-row" },
|
|
543
|
+
h("button", { type: "button", className: "c4d-primary", disabled: busy !== undefined || running || display === "not_configured", "aria-busy": busy === "start", onClick: () => onAction("start"), title: t("runner.startHint") }, t("runner.start")),
|
|
544
|
+
h("button", { type: "button", disabled: busy !== undefined || display === "not_configured", "aria-busy": busy === "stop", onClick: () => onAction("stop") }, t("runner.stop")),
|
|
545
|
+
h("button", { type: "button", onClick: onRefresh }, t("runner.refresh")),
|
|
546
|
+
setupOpen ? null : h("button", { type: "button", onClick: onEditRules }, t("runner.editRules")),
|
|
547
|
+
onEditRulesFile ? h("button", { type: "button", "aria-haspopup": "dialog", onClick: onEditRulesFile }, t("runner.editRulesFile")) : null),
|
|
548
|
+
h(ErrorLine, { message: actionError }),
|
|
549
|
+
updatedAt ? h("p", { className: "c4d-muted" }, t("runner.updated", { time: new Date(updatedAt).toLocaleTimeString() })) : null);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* One active run.
|
|
554
|
+
* @param {{ t: Function, run: object, now: number }} props
|
|
555
|
+
*/
|
|
556
|
+
function RunCard({ t, run, now }) {
|
|
557
|
+
const started = run.createdAt ? Date.parse(run.createdAt) : NaN;
|
|
558
|
+
const meta = [run.workflow || run.step ? t("run.meta", { workflow: run.workflow ?? "?", step: run.step ?? "?" }) : "", run.agent ? t("run.agent", { agent: run.agent }) : "", Number.isFinite(started) ? t("run.for", { duration: formatDuration(now - started, t) }) : ""].filter(Boolean);
|
|
559
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-run": run.id, "data-phase": run.phase },
|
|
560
|
+
h("div", { className: "c4d-row" },
|
|
561
|
+
h("h3", null, run.taskTitle || (run.taskId !== undefined ? t("run.task", { id: run.taskId }) : "")),
|
|
562
|
+
h("span", { className: "c4d-tag", "data-tone": run.phase === "waiting" || run.phase === "uncertain" ? "warn" : undefined }, t(`phase.${run.phase}`))),
|
|
563
|
+
meta.length ? h("p", { className: "c4d-muted" }, meta.join(" · ")) : null,
|
|
564
|
+
h("div", { className: "c4d-row" },
|
|
565
|
+
h(ExternalLink, { href: run.taskLink }, t("run.openTask")),
|
|
566
|
+
h(ExternalLink, { href: run.resultLink }, t("run.openResult"))));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* One open decision: its options in Colco's order with the recommended one marked (never moved), a note and Answer.
|
|
571
|
+
* @param {{ t: Function, decision: object, onAnswer: (id: number, index: number, text: string | undefined) => Promise<void> }} props
|
|
572
|
+
*/
|
|
573
|
+
function DecisionCard({ t, decision, onAnswer }) {
|
|
574
|
+
const [choice, setChoice] = useState(undefined);
|
|
575
|
+
const [note, setNote] = useState("");
|
|
576
|
+
const [pending, setPending] = useState(false);
|
|
577
|
+
const [error, setError] = useState(undefined);
|
|
578
|
+
const submit = async () => {
|
|
579
|
+
if (choice === undefined) {
|
|
580
|
+
setError(t("decisions.pick"));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
setPending(true);
|
|
584
|
+
setError(undefined);
|
|
585
|
+
try {
|
|
586
|
+
await onAnswer(decision.id, choice, note.trim() || undefined);
|
|
587
|
+
} catch (failure) {
|
|
588
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
589
|
+
} finally {
|
|
590
|
+
setPending(false);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
const task = decision.taskId !== undefined ? `${t("run.task", { id: decision.taskId })}${decision.taskTitle ? ` ${decision.taskTitle}` : ""}` : "";
|
|
594
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-decision": decision.id },
|
|
595
|
+
h("h3", null, decision.title || `#${decision.id}`),
|
|
596
|
+
task ? h("p", { className: "c4d-muted" }, task) : null,
|
|
597
|
+
decision.text ? h("p", { style: { whiteSpace: "pre-wrap" } }, decision.text) : null,
|
|
598
|
+
h("div", { className: "c4d-options", role: "group", "aria-label": decision.title || `#${decision.id}` },
|
|
599
|
+
decision.options.map((option, index) => h("button", {
|
|
600
|
+
key: index, type: "button", "aria-pressed": choice === index, disabled: pending,
|
|
601
|
+
"data-colco4dsh-option": index, onClick: () => setChoice(choice === index ? undefined : index),
|
|
602
|
+
}, option || `#${index + 1}`, index === decision.recommend ? h(Fragment, null, " ", h("span", { className: "c4d-tag" }, t("decisions.recommended"))) : null))),
|
|
603
|
+
h("label", { className: "c4d-section" },
|
|
604
|
+
h("span", { className: "c4d-muted" }, t("decisions.note")),
|
|
605
|
+
h("textarea", { rows: 2, maxLength: 2000, value: note, disabled: pending, onChange: (event) => setNote(event.target.value) })),
|
|
606
|
+
h("div", { className: "c4d-row" },
|
|
607
|
+
h("button", { type: "button", className: "c4d-primary", disabled: pending, "aria-busy": pending, onClick: submit }, pending ? t("decisions.answering") : t("decisions.answer")),
|
|
608
|
+
h(ExternalLink, { href: decision.link }, t("decisions.open"))),
|
|
609
|
+
h(ErrorLine, { message: error }));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* One folder row: path, its check result and, when the runner cannot use it, a copyable fix built from the
|
|
614
|
+
* Colco repository picked in the row.
|
|
615
|
+
* @param {{ t: Function, row: { index?: number, path: string }, check: object | undefined, repositories: object[], repo: string, onRepo: (url: string) => void, onPath: (path: string) => void, onRemove: () => void }} props
|
|
616
|
+
*/
|
|
617
|
+
function FolderRow({ t, row, check, repositories, repo, onRepo, onPath, onRemove }) {
|
|
618
|
+
const [copied, setCopied] = useState(false);
|
|
619
|
+
const current = check && check.path === row.path ? check : undefined;
|
|
620
|
+
const broken = current && !(current.exists && current.git && current.origin);
|
|
621
|
+
const problem = !current ? t("setup.unchecked") : !current.exists ? t("setup.missingFolder") : !current.git ? t("setup.notGit") : !current.origin ? t("setup.noOrigin") : undefined;
|
|
622
|
+
const command = broken ? fixCommand(current, repo || undefined) : undefined;
|
|
623
|
+
const copy = async () => {
|
|
624
|
+
try {
|
|
625
|
+
await navigator.clipboard.writeText(command);
|
|
626
|
+
setCopied(true);
|
|
627
|
+
setTimeout(() => setCopied(false), 1500);
|
|
628
|
+
} catch {
|
|
629
|
+
setCopied(false);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-folder": row.index ?? "new" },
|
|
633
|
+
h("div", { className: "c4d-row" },
|
|
634
|
+
h("input", { className: "c4d-path", "aria-label": t("setup.path"), value: row.path, spellCheck: false, onChange: (event) => onPath(event.target.value) }),
|
|
635
|
+
h("button", { type: "button", onClick: onRemove }, t("setup.remove"))),
|
|
636
|
+
problem
|
|
637
|
+
? h("p", { className: current ? "c4d-error" : "c4d-muted" }, problem, current && current.error ? ` (${current.error})` : "")
|
|
638
|
+
: h("p", { className: "c4d-ok" }, t("setup.ok", { origin: current.origin })),
|
|
639
|
+
broken ? h("label", { className: "c4d-row" },
|
|
640
|
+
h("span", { className: "c4d-muted" }, t("setup.repository")),
|
|
641
|
+
h("select", { value: repo, onChange: (event) => onRepo(event.target.value) },
|
|
642
|
+
h("option", { value: "" }, t("setup.chooseRepository")),
|
|
643
|
+
repositories.map((repository) => h("option", { key: repository.url, value: repository.url }, repository.name)))) : null,
|
|
644
|
+
command ? h("div", { className: "c4d-section" },
|
|
645
|
+
h("span", { className: "c4d-muted" }, t("setup.fix")),
|
|
646
|
+
h("code", { "data-colco4dsh-fix": true }, command),
|
|
647
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: copy }, copied ? t("setup.copied") : t("setup.copy")))) : null);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The setup form over one loaded `GET rules` reply: label → workflow rows and folder rows. Save writes the whole
|
|
652
|
+
* rules object (agents, workflows and the rest as they are on disk) and shows the CLI's validation errors.
|
|
653
|
+
* @param {{ t: Function, data: object, options: object, optionsError: string | undefined, notice: string | undefined, canClose: boolean, onSave: (rules: object) => Promise<void>, onClose: () => void }} props
|
|
654
|
+
*/
|
|
655
|
+
function SetupForm({ t, data, options, optionsError, notice, canClose, onSave, onClose }) {
|
|
656
|
+
const [form, setForm] = useState(() => rulesToForm(data.rules));
|
|
657
|
+
const [repos, setRepos] = useState({});
|
|
658
|
+
const [dsh, setDsh] = useState(() => hasDshAgent(data.rules));
|
|
659
|
+
const [saving, setSaving] = useState(false);
|
|
660
|
+
const [error, setError] = useState(undefined);
|
|
661
|
+
const [errors, setErrors] = useState([]);
|
|
662
|
+
const unreadable = data.exists && !data.rules;
|
|
663
|
+
const workflows = data.rules && data.rules.workflows && typeof data.rules.workflows === "object" ? Object.keys(data.rules.workflows) : [];
|
|
664
|
+
const serverLabels = options.labels.map((label) => label.name);
|
|
665
|
+
const checks = new Map(data.folders.map((folder) => [folder.index, folder]));
|
|
666
|
+
const setLabel = (index, patch) => setForm((prev) => ({ ...prev, labels: prev.labels.map((row, at) => (at === index ? { ...row, ...patch } : row)) }));
|
|
667
|
+
const setFolder = (index, path) => setForm((prev) => ({ ...prev, folders: prev.folders.map((row, at) => (at === index ? { ...row, path } : row)) }));
|
|
668
|
+
const save = async () => {
|
|
669
|
+
const built = formToRules(data.rules, form);
|
|
670
|
+
if (built.error) {
|
|
671
|
+
setError(t(built.error.key, built.error.params));
|
|
672
|
+
setErrors([]);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
setSaving(true);
|
|
676
|
+
setError(undefined);
|
|
677
|
+
setErrors([]);
|
|
678
|
+
try {
|
|
679
|
+
await onSave(withDshAgent(built.rules, dsh));
|
|
680
|
+
} catch (failure) {
|
|
681
|
+
setError(t("setup.saveFailed", { message: failure instanceof Error ? failure.message : String(failure) }));
|
|
682
|
+
setErrors(failure instanceof ApiError ? failure.errors : []);
|
|
683
|
+
} finally {
|
|
684
|
+
setSaving(false);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
return h("section", { className: "c4d-section", "aria-label": t("setup.title"), "data-colco4dsh-setup": true },
|
|
688
|
+
h("h2", null, t("setup.title")),
|
|
689
|
+
notice ? h("p", { role: "status", className: "c4d-ok" }, notice) : null,
|
|
690
|
+
!data.exists ? h("p", null, t("setup.missing")) : null,
|
|
691
|
+
unreadable ? h("p", { className: "c4d-error" }, t("setup.unreadable", { path: data.path ?? "rules.json" })) : null,
|
|
692
|
+
data.exists && !data.valid && data.errors.length ? h(Fragment, null, h("p", null, t("setup.invalid")), h("ul", { className: "c4d-error" }, data.errors.map((line, index) => h("li", { key: index }, line)))) : null,
|
|
693
|
+
h(ErrorLine, { message: optionsError ? t("setup.optionsFailed", { message: optionsError }) : undefined }),
|
|
694
|
+
h("h3", null, t("setup.labels")),
|
|
695
|
+
h("p", { className: "c4d-muted" }, t("setup.labelsHint")),
|
|
696
|
+
h("ul", null, form.labels.map((row, index) => {
|
|
697
|
+
const labelChoices = row.label && !serverLabels.includes(row.label) ? [...serverLabels, row.label] : serverLabels;
|
|
698
|
+
const workflowChoices = row.workflow && !workflows.includes(row.workflow) ? [...workflows, row.workflow] : workflows;
|
|
699
|
+
return h("li", { key: index, className: "c4d-row", "data-colco4dsh-label-row": index },
|
|
700
|
+
h("select", { "aria-label": t("setup.label"), value: row.label, onChange: (event) => setLabel(index, { label: event.target.value }) },
|
|
701
|
+
h("option", { value: "" }, t("setup.chooseLabel")),
|
|
702
|
+
labelChoices.map((name) => h("option", { key: name, value: name }, serverLabels.includes(name) ? name : t("setup.notOnServer", { label: name })))),
|
|
703
|
+
h("span", { "aria-hidden": true }, "→"),
|
|
704
|
+
h("select", { "aria-label": t("setup.workflow"), value: row.workflow, onChange: (event) => setLabel(index, { workflow: event.target.value }) },
|
|
705
|
+
h("option", { value: "" }, t("setup.chooseWorkflow")),
|
|
706
|
+
workflowChoices.map((name) => h("option", { key: name, value: name }, name))),
|
|
707
|
+
h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, labels: prev.labels.filter((_, at) => at !== index) })) }, t("setup.remove")));
|
|
708
|
+
})),
|
|
709
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, labels: [...prev.labels, { label: "", workflow: "" }] })) }, t("setup.addLabel"))),
|
|
710
|
+
h("h3", null, t("setup.folders")),
|
|
711
|
+
h("p", { className: "c4d-muted" }, t("setup.foldersHint")),
|
|
712
|
+
h("ul", null, form.folders.map((row, index) => h(FolderRow, {
|
|
713
|
+
key: row.index ?? `new-${index}`, t, row, check: row.index !== undefined ? checks.get(row.index) : undefined,
|
|
714
|
+
repositories: options.repositories, repo: repos[index] ?? "",
|
|
715
|
+
onRepo: (url) => setRepos((prev) => ({ ...prev, [index]: url })),
|
|
716
|
+
onPath: (path) => setFolder(index, path),
|
|
717
|
+
onRemove: () => setForm((prev) => ({ ...prev, folders: prev.folders.filter((_, at) => at !== index) })),
|
|
718
|
+
}))),
|
|
719
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, folders: [...prev.folders, { path: "" }] })) }, t("setup.addFolder"))),
|
|
720
|
+
h("label", { className: "c4d-row" },
|
|
721
|
+
h("input", { type: "checkbox", "data-colco4dsh-dsh": true, checked: dsh, onChange: (event) => setDsh(event.target.checked) }),
|
|
722
|
+
h("span", null, t("setup.dsh"))),
|
|
723
|
+
h("p", { className: "c4d-muted" }, t("setup.dshHint")),
|
|
724
|
+
h(ErrorLine, { message: error }),
|
|
725
|
+
errors.length ? h("ul", { className: "c4d-error" }, errors.map((line, index) => h("li", { key: index }, line))) : null,
|
|
726
|
+
h("div", { className: "c4d-row" },
|
|
727
|
+
h("button", { type: "button", className: "c4d-primary", disabled: saving || unreadable, "aria-busy": saving, onClick: save }, saving ? t("setup.saving") : t("setup.save")),
|
|
728
|
+
canClose ? h("button", { type: "button", onClick: onClose }, t("setup.cancel")) : null));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Loads `GET rules` and `GET options` when Setup opens, and again after a save; then shows SetupForm.
|
|
733
|
+
* `initial` skips the first load (tests render the form without a network).
|
|
734
|
+
* @param {{ t: Function, initial?: { data: object, options: object }, canClose: boolean, onSaved: () => void, onClose: () => void }} props
|
|
735
|
+
*/
|
|
736
|
+
function SetupPanel({ t, initial, canClose, onSaved, onClose }) {
|
|
737
|
+
const [loaded, setLoaded] = useState(initial ? { ...initial, optionsError: undefined } : undefined);
|
|
738
|
+
const [loadError, setLoadError] = useState(undefined);
|
|
739
|
+
const [notice, setNotice] = useState(undefined);
|
|
740
|
+
const [revision, setRevision] = useState(initial ? -1 : 0);
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
if (revision < 0) return undefined;
|
|
743
|
+
const controller = new AbortController();
|
|
744
|
+
Promise.allSettled([call("rules", { signal: controller.signal }), call("options", { signal: controller.signal })]).then(([rules, options]) => {
|
|
745
|
+
if (controller.signal.aborted) return;
|
|
746
|
+
if (rules.status === "rejected") {
|
|
747
|
+
setLoadError(rules.reason instanceof Error ? rules.reason.message : String(rules.reason));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
setLoadError(undefined);
|
|
751
|
+
const opts = options.status === "fulfilled" ? options.value : {};
|
|
752
|
+
setLoaded({
|
|
753
|
+
data: { path: rules.value.path, exists: rules.value.exists === true, valid: rules.value.valid === true, errors: rules.value.errors ?? [], rules: rules.value.rules ?? null, folders: rules.value.folders ?? [] },
|
|
754
|
+
options: { labels: opts.labels ?? [], repositories: opts.repositories ?? [], workflows: opts.workflows ?? [] },
|
|
755
|
+
optionsError: options.status === "rejected" ? (options.reason instanceof Error ? options.reason.message : String(options.reason)) : undefined,
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
return () => controller.abort();
|
|
759
|
+
}, [revision]);
|
|
760
|
+
const save = useCallback(async (rules) => {
|
|
761
|
+
const reply = await call("rules", { method: "POST", body: rules });
|
|
762
|
+
setNotice(reply.backup ? t("setup.savedBackup", { path: reply.path, backup: reply.backup }) : t("setup.saved", { path: reply.path ?? "rules.json" }));
|
|
763
|
+
setRevision((value) => Math.max(value, 0) + 1);
|
|
764
|
+
onSaved();
|
|
765
|
+
}, [t, onSaved]);
|
|
766
|
+
if (loadError) return h("section", { className: "c4d-section", "aria-label": t("setup.title") }, h("h2", null, t("setup.title")), h(ErrorLine, { message: loadError }));
|
|
767
|
+
if (!loaded) return h("section", { className: "c4d-section", "aria-label": t("setup.title") }, h("h2", null, t("setup.title")), h("p", { className: "c4d-muted" }, t("setup.loading")));
|
|
768
|
+
return h(SetupForm, { key: revision, t, data: loaded.data, options: loaded.options, optionsError: loaded.optionsError, notice, canClose, onSave: save, onClose });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---- Edit rules.json dialog -------------------------------------------------------------------------------
|
|
772
|
+
// A native <dialog> over the raw ~/.colco/rules.json. It loads the text with its content hash, saves through the
|
|
773
|
+
// CLI's validation and backup, and sends the hash back: if the file changed in the meantime the save is refused and
|
|
774
|
+
// the dialog shows the file as it is now, so an outside edit is reviewed and never overwritten silently.
|
|
775
|
+
const { useRef } = React;
|
|
776
|
+
|
|
777
|
+
/** Dialog styles: capped by the viewport so the dialog fits narrow screens; the controls wrap. */
|
|
778
|
+
const DIALOG_CSS = `
|
|
779
|
+
.c4d-dialog{box-sizing:border-box;width:min(760px,calc(100vw - 24px));max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);margin:auto;padding:0;overflow:hidden;border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2328)}
|
|
780
|
+
.c4d-dialog::backdrop{background:rgba(0,0,0,.4)}
|
|
781
|
+
.c4d-dialog-body{display:flex;flex-direction:column;gap:10px;max-height:calc(100dvh - 26px);overflow:auto;padding:16px}
|
|
782
|
+
.c4d-dialog textarea.c4d-file{min-height:min(320px,40dvh);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:18px;white-space:pre;overflow:auto}
|
|
783
|
+
.c4d-dialog-actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
784
|
+
@media (max-width:767px){.c4d-dialog{width:calc(100vw - 16px);max-width:calc(100vw - 16px)}.c4d-dialog-body{padding:12px}.c4d-dialog-actions button{flex:1 1 140px}}
|
|
785
|
+
`;
|
|
786
|
+
|
|
787
|
+
/** Read the raw rules.json text, its hash and its check result. */
|
|
788
|
+
function loadRulesFile() {
|
|
789
|
+
return call("rules/file");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Save the text. `baseHash` is the hash of the file as loaded (null when there was no file). A file that changed
|
|
794
|
+
* since then is not written: the result is `{ status: "conflict", current }` with the file as it is now. Invalid
|
|
795
|
+
* text and denied access throw an ApiError.
|
|
796
|
+
* @param {string} text
|
|
797
|
+
* @param {string | null} baseHash
|
|
798
|
+
*/
|
|
799
|
+
async function saveRulesFile(text, baseHash) {
|
|
800
|
+
try {
|
|
801
|
+
const reply = await call("rules/file", { method: "POST", body: { text, baseHash } });
|
|
802
|
+
return { status: "saved", path: reply.path, backup: reply.backup ?? null, hash: reply.hash ?? null };
|
|
803
|
+
} catch (failure) {
|
|
804
|
+
if (failure instanceof ApiError && failure.status === 409) return { status: "conflict", current: await loadRulesFile() };
|
|
805
|
+
throw failure;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The Edit rules.json dialog. `initial` seeds the state for tests; otherwise it loads the file when it opens.
|
|
811
|
+
* @param {{ t: Function, initial?: { file?: object, loadError?: string, draft?: string, error?: string, errors?: string[], conflict?: object, notice?: string }, onSaved: () => void, onClose: () => void }} props
|
|
812
|
+
*/
|
|
813
|
+
function RulesFileDialog({ t, initial = {}, onSaved, onClose }) {
|
|
814
|
+
const ref = useRef(null);
|
|
815
|
+
const [file, setFile] = useState(initial.file);
|
|
816
|
+
const [loadError, setLoadError] = useState(initial.loadError);
|
|
817
|
+
const [draft, setDraft] = useState(initial.draft ?? initial.file?.text ?? "");
|
|
818
|
+
const [saving, setSaving] = useState(false);
|
|
819
|
+
const [error, setError] = useState(initial.error);
|
|
820
|
+
const [errors, setErrors] = useState(initial.errors ?? []);
|
|
821
|
+
const [conflict, setConflict] = useState(initial.conflict);
|
|
822
|
+
const [notice, setNotice] = useState(initial.notice);
|
|
823
|
+
const message = (failure) => (failure instanceof Error ? failure.message : String(failure));
|
|
824
|
+
// Open as a modal dialog: the browser traps focus, closes on Escape and restores focus to the button.
|
|
825
|
+
useEffect(() => {
|
|
826
|
+
const element = ref.current;
|
|
827
|
+
if (element && typeof element.showModal === "function" && !element.open) element.showModal();
|
|
828
|
+
return () => { if (element && element.open) element.close(); };
|
|
829
|
+
}, []);
|
|
830
|
+
useEffect(() => {
|
|
831
|
+
if (file || loadError) return undefined;
|
|
832
|
+
let live = true;
|
|
833
|
+
loadRulesFile().then((loaded) => {
|
|
834
|
+
if (!live) return;
|
|
835
|
+
setFile(loaded);
|
|
836
|
+
setDraft(loaded.text);
|
|
837
|
+
}, (failure) => { if (live) setLoadError(t("file.loadFailed", { message: message(failure) })); });
|
|
838
|
+
return () => { live = false; };
|
|
839
|
+
}, [file, loadError, t]);
|
|
840
|
+
/** Save the draft against the hash `base`; a conflict opens the review, an error stays in the dialog. */
|
|
841
|
+
const submit = async (base) => {
|
|
842
|
+
setSaving(true);
|
|
843
|
+
setError(undefined);
|
|
844
|
+
setErrors([]);
|
|
845
|
+
setNotice(undefined);
|
|
846
|
+
try {
|
|
847
|
+
const result = await saveRulesFile(draft, base);
|
|
848
|
+
if (result.status === "conflict") {
|
|
849
|
+
setConflict(result.current);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
setConflict(undefined);
|
|
853
|
+
let reloaded;
|
|
854
|
+
try {
|
|
855
|
+
reloaded = await loadRulesFile();
|
|
856
|
+
} catch {
|
|
857
|
+
reloaded = undefined;
|
|
858
|
+
}
|
|
859
|
+
setFile(reloaded ?? { ...file, exists: true, text: draft, hash: result.hash, valid: true, errors: [] });
|
|
860
|
+
if (reloaded) setDraft(reloaded.text);
|
|
861
|
+
setNotice(result.backup ? t("file.savedBackup", { path: result.path, backup: result.backup }) : t("file.saved", { path: result.path ?? "rules.json" }));
|
|
862
|
+
onSaved();
|
|
863
|
+
} catch (failure) {
|
|
864
|
+
setError(t("file.saveFailed", { message: message(failure) }));
|
|
865
|
+
setErrors(failure instanceof ApiError ? failure.errors : []);
|
|
866
|
+
} finally {
|
|
867
|
+
setSaving(false);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
/** Drop the draft and take the file as it is now. */
|
|
871
|
+
const useDisk = () => {
|
|
872
|
+
setFile(conflict);
|
|
873
|
+
setDraft(conflict.text);
|
|
874
|
+
setConflict(undefined);
|
|
875
|
+
};
|
|
876
|
+
/** Save the draft over the file as it is now (its new hash), after the user has reviewed it. */
|
|
877
|
+
const overwrite = () => {
|
|
878
|
+
const current = conflict;
|
|
879
|
+
setFile(current);
|
|
880
|
+
setConflict(undefined);
|
|
881
|
+
void submit(current.hash ?? null);
|
|
882
|
+
};
|
|
883
|
+
const failed = Boolean(error) || errors.length > 0;
|
|
884
|
+
return h("dialog", { ref, className: "c4d-dialog", "aria-labelledby": "c4d-file-title", "data-colco4dsh-rules-file": true, onClose },
|
|
885
|
+
h("style", null, DIALOG_CSS),
|
|
886
|
+
h("div", { className: "c4d-dialog-body" },
|
|
887
|
+
h("h2", { id: "c4d-file-title" }, t("file.title")),
|
|
888
|
+
loadError ? h(ErrorLine, { message: loadError }) : null,
|
|
889
|
+
!file && !loadError ? h("p", { className: "c4d-muted", role: "status" }, t("file.loading")) : null,
|
|
890
|
+
file ? h(Fragment, null,
|
|
891
|
+
h("p", { className: "c4d-muted" }, t("file.path", { path: file.path ?? "rules.json" })),
|
|
892
|
+
!file.exists ? h("p", null, t("file.missing")) : null,
|
|
893
|
+
file.exists && !file.valid && file.errors.length ? h(Fragment, null, h("p", null, t("file.diskInvalid")), h("ul", { className: "c4d-error" }, file.errors.map((line, index) => h("li", { key: index }, line)))) : null,
|
|
894
|
+
notice ? h("p", { role: "status", className: "c4d-ok" }, notice) : null,
|
|
895
|
+
conflict ? h("div", { className: "c4d-section" },
|
|
896
|
+
h("p", { role: "alert", className: "c4d-error" }, t("file.conflict")),
|
|
897
|
+
h("label", { htmlFor: "c4d-file-disk", className: "c4d-muted" }, t("file.diskLabel")),
|
|
898
|
+
h("textarea", { id: "c4d-file-disk", className: "c4d-file", rows: 10, readOnly: true, spellCheck: false, value: conflict.text }),
|
|
899
|
+
conflict.exists && !conflict.valid && conflict.errors.length ? h("ul", { className: "c4d-error" }, conflict.errors.map((line, index) => h("li", { key: index }, line))) : null) : null,
|
|
900
|
+
h("label", { htmlFor: "c4d-file-text", className: "c4d-muted" }, t("file.label")),
|
|
901
|
+
h("textarea", {
|
|
902
|
+
id: "c4d-file-text", className: "c4d-file", rows: 14, spellCheck: false, value: draft, readOnly: Boolean(conflict) || saving,
|
|
903
|
+
"aria-invalid": failed ? "true" : undefined, "aria-describedby": failed ? "c4d-file-errors" : undefined,
|
|
904
|
+
onChange: (event) => setDraft(event.target.value),
|
|
905
|
+
}),
|
|
906
|
+
failed ? h("div", { id: "c4d-file-errors", role: "alert", className: "c4d-error" },
|
|
907
|
+
error ? h("p", null, error) : null,
|
|
908
|
+
errors.length ? h("ul", null, errors.map((line, index) => h("li", { key: index }, line))) : null) : null,
|
|
909
|
+
conflict ? null : h("p", { className: "c4d-muted" }, t("file.hint"))) : null,
|
|
910
|
+
h("div", { className: "c4d-dialog-actions" },
|
|
911
|
+
file && !conflict ? h("button", { type: "button", className: "c4d-primary", disabled: saving, "aria-busy": saving, onClick: () => void submit(file.hash ?? null) }, saving ? t("file.saving") : t("file.save")) : null,
|
|
912
|
+
conflict ? h("button", { type: "button", onClick: useDisk }, t("file.useDisk")) : null,
|
|
913
|
+
conflict ? h("button", { type: "button", className: "c4d-primary", disabled: saving, onClick: overwrite }, t("file.overwrite")) : null,
|
|
914
|
+
h("button", { type: "button", onClick: onClose }, t("file.close")))));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---- Shared status store (open-decision dot) -------------------------------------------------------------
|
|
918
|
+
// One store holds the last `GET status` read and one poller refreshes it, for the sidebar icon and the Colco page
|
|
919
|
+
// alike. It polls while at least one of them is mounted and DSH is visible, so the icon's dot follows the
|
|
920
|
+
// runner's decisions even when the page is closed.
|
|
921
|
+
const { useSyncExternalStore } = React;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Count the open decisions of a status reply. A reply whose decisions section failed counts as none.
|
|
925
|
+
* @param {object | undefined} status
|
|
926
|
+
*/
|
|
927
|
+
function countOpen(status) {
|
|
928
|
+
if (!status || (status.errors && status.errors.decisions) || !Array.isArray(status.decisions)) return 0;
|
|
929
|
+
return status.decisions.filter((decision) => decision.state === undefined || decision.state === "open").length;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Build one status store.
|
|
934
|
+
* `snapshot.openCount` is the number behind the dot; it is 0 after a failed read, so a failure never shows a dot.
|
|
935
|
+
* @param {{ fetchStatus?: (signal: AbortSignal) => Promise<object>, initialStatus?: object, doc?: { visibilityState?: string, addEventListener: Function, removeEventListener: Function } | undefined, timers?: { setInterval: Function, clearInterval: Function }, pollMs?: number }} [options]
|
|
936
|
+
*/
|
|
937
|
+
function createStatusStore({ fetchStatus = (signal) => call("status", { signal }), initialStatus, doc = globalThis.document, timers = globalThis, pollMs = POLL_MS } = {}) {
|
|
938
|
+
const listeners = new Set();
|
|
939
|
+
/** Ids answered here; a read that started earlier may still list them, so they are filtered out of every reply. */
|
|
940
|
+
const answeredIds = new Set();
|
|
941
|
+
let inFlight;
|
|
942
|
+
let timer;
|
|
943
|
+
let stopWatching;
|
|
944
|
+
|
|
945
|
+
/** The snapshot for one status, its last error and update time. */
|
|
946
|
+
const derive = (status, error, updatedAt) => ({ status, error, updatedAt, openCount: error ? 0 : countOpen(status) });
|
|
947
|
+
/** The status without the decisions answered here. */
|
|
948
|
+
const withoutAnswered = (status) => (status && Array.isArray(status.decisions) && answeredIds.size
|
|
949
|
+
? { ...status, decisions: status.decisions.filter((decision) => !answeredIds.has(decision.id)) }
|
|
950
|
+
: status);
|
|
951
|
+
let snapshot = derive(initialStatus, undefined, undefined);
|
|
952
|
+
/** Replace the snapshot and tell every listener. */
|
|
953
|
+
const publish = (next) => {
|
|
954
|
+
snapshot = next;
|
|
955
|
+
for (const listener of [...listeners]) listener();
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
/** Read the status once; a read already in flight is shared. A failure keeps the old status and sets the error. */
|
|
959
|
+
const refresh = () => {
|
|
960
|
+
if (inFlight) return inFlight.promise;
|
|
961
|
+
const controller = new AbortController();
|
|
962
|
+
let request;
|
|
963
|
+
try {
|
|
964
|
+
request = Promise.resolve(fetchStatus(controller.signal));
|
|
965
|
+
} catch (failure) {
|
|
966
|
+
request = Promise.reject(failure);
|
|
967
|
+
}
|
|
968
|
+
const promise = request.then((reply) => {
|
|
969
|
+
if (!controller.signal.aborted) publish(derive(withoutAnswered(reply), undefined, Date.now()));
|
|
970
|
+
}, (failure) => {
|
|
971
|
+
if (!controller.signal.aborted) publish(derive(snapshot.status, failure instanceof Error ? failure.message : String(failure), snapshot.updatedAt));
|
|
972
|
+
}).finally(() => {
|
|
973
|
+
if (inFlight && inFlight.controller === controller) inFlight = undefined;
|
|
974
|
+
});
|
|
975
|
+
inFlight = { controller, promise };
|
|
976
|
+
return promise;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
/** Start polling: one read now, then one per interval while the document is not hidden, and one on becoming visible. */
|
|
980
|
+
const start = () => {
|
|
981
|
+
const tick = () => { if (!doc || doc.visibilityState !== "hidden") void refresh(); };
|
|
982
|
+
tick();
|
|
983
|
+
timer = timers.setInterval(tick, pollMs);
|
|
984
|
+
if (doc) doc.addEventListener("visibilitychange", tick);
|
|
985
|
+
stopWatching = () => { if (doc) doc.removeEventListener("visibilitychange", tick); };
|
|
986
|
+
};
|
|
987
|
+
/** Stop polling and abort a read in flight. */
|
|
988
|
+
const stop = () => {
|
|
989
|
+
timers.clearInterval(timer);
|
|
990
|
+
stopWatching?.();
|
|
991
|
+
if (inFlight) {
|
|
992
|
+
inFlight.controller.abort();
|
|
993
|
+
inFlight = undefined;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
/** @returns the current snapshot; the same object until something changes. */
|
|
999
|
+
getSnapshot: () => snapshot,
|
|
1000
|
+
/** Listen for changes; the first listener starts the poller and the last one stops it. */
|
|
1001
|
+
subscribe(listener) {
|
|
1002
|
+
listeners.add(listener);
|
|
1003
|
+
if (listeners.size === 1) start();
|
|
1004
|
+
return () => {
|
|
1005
|
+
listeners.delete(listener);
|
|
1006
|
+
if (listeners.size === 0) stop();
|
|
1007
|
+
};
|
|
1008
|
+
},
|
|
1009
|
+
refresh,
|
|
1010
|
+
/** Take an answered decision out of the list at once, so its dot clears before the next read. */
|
|
1011
|
+
answered(id) {
|
|
1012
|
+
answeredIds.add(id);
|
|
1013
|
+
if (snapshot.status) publish(derive(withoutAnswered(snapshot.status), snapshot.error, snapshot.updatedAt));
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** The store the sidebar icon and the page share; created on first use, idle until something subscribes. */
|
|
1019
|
+
let sharedStore;
|
|
1020
|
+
/** The shared store. */
|
|
1021
|
+
function sharedStatusStore() {
|
|
1022
|
+
sharedStore ??= createStatusStore();
|
|
1023
|
+
return sharedStore;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Follow a store from a component.
|
|
1028
|
+
* @param {ReturnType<typeof createStatusStore>} store
|
|
1029
|
+
*/
|
|
1030
|
+
function useStatusSnapshot(store) {
|
|
1031
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Send an answer, clear the decision's dot at once, then refresh. A failed answer throws and leaves the dot.
|
|
1036
|
+
* @param {ReturnType<typeof createStatusStore>} store
|
|
1037
|
+
* @param {number} id - decision id.
|
|
1038
|
+
* @param {number} index - chosen option.
|
|
1039
|
+
* @param {string | undefined} text - optional note.
|
|
1040
|
+
*/
|
|
1041
|
+
async function submitAnswer(store, id, index, text) {
|
|
1042
|
+
await call("answer", { method: "POST", body: text === undefined ? { id, index } : { id, index, text } });
|
|
1043
|
+
store.answered(id);
|
|
1044
|
+
await store.refresh();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* The whole tab: runner header, active runs, open decisions and Setup. It reads the shared status store,
|
|
1049
|
+
* which refreshes every POLL_MS while DSH is visible. `initialStatus` seeds a private store and `initialSetup` seeds
|
|
1050
|
+
* Setup (server rendering in tests performs no request); `store` passes a store in.
|
|
1051
|
+
* @param {{ t: Function, initialStatus?: object, initialSetup?: { data: object, options: object }, store?: object }} props
|
|
1052
|
+
*/
|
|
1053
|
+
function ColcoPanel({ t, initialStatus, initialSetup, store: given }) {
|
|
1054
|
+
const [store] = useState(() => given ?? (initialStatus ? createStatusStore({ initialStatus }) : sharedStatusStore()));
|
|
1055
|
+
const { status, error: loadError, updatedAt } = useStatusSnapshot(store);
|
|
1056
|
+
const [busy, setBusy] = useState(undefined);
|
|
1057
|
+
const [actionError, setActionError] = useState(undefined);
|
|
1058
|
+
const [setupRequested, setSetupRequested] = useState(false);
|
|
1059
|
+
const [fileOpen, setFileOpen] = useState(false);
|
|
1060
|
+
const [rulesVersion, setRulesVersion] = useState(0);
|
|
1061
|
+
const refresh = store.refresh;
|
|
1062
|
+
// Subscribing (above) starts the shared poller; this read makes a page opened later show fresh data at once.
|
|
1063
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
1064
|
+
/** Run Start or Stop, then refresh; a failure shows under the buttons. */
|
|
1065
|
+
const act = async (name) => {
|
|
1066
|
+
setBusy(name);
|
|
1067
|
+
setActionError(undefined);
|
|
1068
|
+
try {
|
|
1069
|
+
await call(name, { method: "POST" });
|
|
1070
|
+
} catch (failure) {
|
|
1071
|
+
setActionError(failure instanceof Error ? failure.message : String(failure));
|
|
1072
|
+
} finally {
|
|
1073
|
+
setBusy(undefined);
|
|
1074
|
+
}
|
|
1075
|
+
await refresh();
|
|
1076
|
+
};
|
|
1077
|
+
/** Answer a decision, then refresh; the card shows a failure itself. */
|
|
1078
|
+
const answer = (id, index, text) => submitAnswer(store, id, index, text);
|
|
1079
|
+
const runner = status?.runner ?? null;
|
|
1080
|
+
const needsSetup = runner !== null && (runner.display === "not_configured" || runner.display === "rules_error");
|
|
1081
|
+
const setupOpen = needsSetup || setupRequested;
|
|
1082
|
+
const errors = status?.errors ?? {};
|
|
1083
|
+
const now = Date.now();
|
|
1084
|
+
return h("div", { className: "c4d", "data-colco4dsh-panel": runner?.display ?? "loading" },
|
|
1085
|
+
h("style", null, CSS),
|
|
1086
|
+
h(RunnerHeader, { t, runner, loading: status === undefined && !loadError, busy, actionError, setupOpen, onAction: act, onRefresh: () => void refresh(), onEditRules: () => setSetupRequested(true), onEditRulesFile: () => setFileOpen(true), updatedAt }),
|
|
1087
|
+
h(ErrorLine, { message: loadError }),
|
|
1088
|
+
["runner", "runs", "decisions"].map((section) => (errors[section] ? h(ErrorLine, { key: section, message: t("error.section", { section: t(`section.${section}`), message: errors[section] }) }) : null)),
|
|
1089
|
+
setupOpen ? h(SetupPanel, { key: rulesVersion, t, initial: initialSetup, canClose: !needsSetup, onSaved: () => void refresh(), onClose: () => setSetupRequested(false) }) : null,
|
|
1090
|
+
fileOpen ? h(RulesFileDialog, { t, onSaved: () => { setRulesVersion((value) => value + 1); void refresh(); }, onClose: () => setFileOpen(false) }) : null,
|
|
1091
|
+
h("section", { className: "c4d-section", "aria-label": t("runs.title") },
|
|
1092
|
+
h("h2", null, t("runs.title")),
|
|
1093
|
+
status && status.runs.length ? h("ul", null, status.runs.map((run) => h(RunCard, { key: run.id, t, run, now }))) : h("p", { className: "c4d-muted" }, status ? t("runs.empty") : t("state.loading"))),
|
|
1094
|
+
h("section", { className: "c4d-section", "aria-label": t("decisions.title") },
|
|
1095
|
+
h("h2", null, t("decisions.title")),
|
|
1096
|
+
status && status.decisions.length ? h("ul", null, status.decisions.map((decision) => h(DecisionCard, { key: decision.id, t, decision, onAnswer: answer }))) : h("p", { className: "c4d-muted" }, status ? t("decisions.empty") : t("state.loading"))),
|
|
1097
|
+
h(MyTasksBoard, { t }));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---- My tasks board -------------------------------------------------------------------------------------
|
|
1101
|
+
// The tasks assigned to the signed-in user, read only, grouped by status and paged by the server (GET tasks).
|
|
1102
|
+
// Task changes continue in Colco: every title links there and nothing on the board writes.
|
|
1103
|
+
|
|
1104
|
+
/** How often the board reloads while the page is open. Slower than the status poll, because tasks change in Colco. */
|
|
1105
|
+
const BOARD_POLL_MS = 60000;
|
|
1106
|
+
/** The statuses of a task in the order the server lists them: work in flight first, done last. */
|
|
1107
|
+
const BOARD_STATUSES = ["in_progress", "blocked", "review", "todo", "backlog", "done"];
|
|
1108
|
+
|
|
1109
|
+
/** Board styles: wrapping and clamping so long titles and narrow windows cannot push the page sideways. No angle brackets or quotes, because React escapes them inside style text. */
|
|
1110
|
+
const BOARD_CSS = `
|
|
1111
|
+
.c4d-board{min-width:0}
|
|
1112
|
+
.c4d-board-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;min-width:0}
|
|
1113
|
+
.c4d-board-bar select{flex:1 1 180px;max-width:100%;min-width:0;text-overflow:ellipsis}
|
|
1114
|
+
.c4d-board-group{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
1115
|
+
.c4d-board-head{flex-wrap:nowrap}
|
|
1116
|
+
.c4d-task{min-width:0;gap:4px}
|
|
1117
|
+
.c4d-task-title{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
1118
|
+
.c4d-task-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;min-width:0}
|
|
1119
|
+
.c4d-task-meta .c4d-tag{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1120
|
+
.c4d-board-pager{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
|
1121
|
+
@media (max-width:480px){.c4d-board-bar select{flex-basis:100%}.c4d-board-pager{justify-content:center}}
|
|
1122
|
+
`;
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The route path for a board query: only the filters that are set and a page after the first.
|
|
1126
|
+
* @param {{ page: number, project?: number, status?: string }} query
|
|
1127
|
+
*/
|
|
1128
|
+
function boardPath(query) {
|
|
1129
|
+
const params = [];
|
|
1130
|
+
if (query.project !== undefined) params.push(`project=${query.project}`);
|
|
1131
|
+
if (query.status !== undefined) params.push(`status=${encodeURIComponent(query.status)}`);
|
|
1132
|
+
if (query.page > 1) params.push(`page=${query.page}`);
|
|
1133
|
+
return params.length ? `tasks?${params.join("&")}` : "tasks";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The query after one change from the board. A new project or status goes back to page 1, because the old page
|
|
1138
|
+
* number means nothing for another set of tasks; an empty value clears that filter; a bad page becomes 1.
|
|
1139
|
+
* @param {{ page: number, project?: number, status?: string }} query - the current query.
|
|
1140
|
+
* @param {{ project?: string, status?: string, page?: number | string }} change - one change from a select or the pager.
|
|
1141
|
+
*/
|
|
1142
|
+
function changeBoardQuery(query, change) {
|
|
1143
|
+
const next = { ...query };
|
|
1144
|
+
if ("project" in change) {
|
|
1145
|
+
const id = Number(change.project);
|
|
1146
|
+
next.project = change.project !== "" && Number.isSafeInteger(id) && id >= 1 ? id : undefined;
|
|
1147
|
+
next.page = 1;
|
|
1148
|
+
}
|
|
1149
|
+
if ("status" in change) {
|
|
1150
|
+
next.status = BOARD_STATUSES.includes(change.status) ? change.status : undefined;
|
|
1151
|
+
next.page = 1;
|
|
1152
|
+
}
|
|
1153
|
+
if ("page" in change) {
|
|
1154
|
+
const page = Number(change.page);
|
|
1155
|
+
next.page = Number.isSafeInteger(page) && page >= 1 ? page : 1;
|
|
1156
|
+
}
|
|
1157
|
+
return next;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* One task: its title (a link into Colco when the link is http or https), then project, priority and due date.
|
|
1162
|
+
* The title is clamped to two lines by CSS and kept whole in the title attribute.
|
|
1163
|
+
* @param {{ t: Function, task: object }} props
|
|
1164
|
+
*/
|
|
1165
|
+
function TaskRow({ t, task }) {
|
|
1166
|
+
const title = task.title || t("tasks.untitled", { id: task.id });
|
|
1167
|
+
const linked = typeof task.link === "string" && /^https?:\/\//i.test(task.link);
|
|
1168
|
+
return h("li", { className: "c4d-card c4d-task", "data-colco4dsh-task": task.id, "data-status": task.status },
|
|
1169
|
+
h("p", { className: "c4d-task-title", title }, linked ? h("a", { href: task.link, target: "_blank", rel: "noopener noreferrer" }, title) : title),
|
|
1170
|
+
h("div", { className: "c4d-task-meta" },
|
|
1171
|
+
task.projectName ? h("span", { className: "c4d-tag", title: task.projectName }, task.projectName) : null,
|
|
1172
|
+
task.priority ? h("span", { className: "c4d-tag", "data-priority": task.priority, "data-tone": task.priority === "high" ? "warn" : undefined }, t("tasks.priorityTag", { priority: t(`tasks.priority.${task.priority}`) })) : null,
|
|
1173
|
+
h("span", { className: "c4d-muted" }, task.dueDate ? t("tasks.due", { date: task.dueDate }) : t("tasks.noDue"))));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The tasks of one status on this page. The heading count is the status's total across pages; a group that
|
|
1178
|
+
* continues on another page says how many of its tasks are shown here.
|
|
1179
|
+
* @param {{ t: Function, status: string, tasks: object[], total: number }} props
|
|
1180
|
+
*/
|
|
1181
|
+
function TaskGroup({ t, status, tasks, total }) {
|
|
1182
|
+
const count = Math.max(total, tasks.length);
|
|
1183
|
+
return h("section", { className: "c4d-board-group", "aria-label": t(`tasks.status.${status}`), "data-colco4dsh-group": status },
|
|
1184
|
+
h("div", { className: "c4d-row c4d-board-head" },
|
|
1185
|
+
h("h3", null, t(`tasks.status.${status}`)),
|
|
1186
|
+
h("span", { className: "c4d-tag" }, tasks.length < count ? t("tasks.groupCount", { shown: tasks.length, total: count }) : String(count))),
|
|
1187
|
+
h("ul", null, tasks.map((task) => h(TaskRow, { key: task.id, t, task }))));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The board for one reply of GET tasks: Refresh, the project and status filters, the status groups and the pager.
|
|
1192
|
+
* `reply` is undefined until the first load; a failed refresh keeps the last reply under the alert.
|
|
1193
|
+
* @param {{ t: Function, query: { page: number, project?: number, status?: string }, reply: object | undefined, loading: boolean, error: string | undefined, onQuery: (change: object) => void, onRefresh: () => void }} props
|
|
1194
|
+
*/
|
|
1195
|
+
function TaskBoard({ t, query, reply, loading, error, onQuery, onRefresh }) {
|
|
1196
|
+
const filtered = query.project !== undefined || query.status !== undefined;
|
|
1197
|
+
const byStatus = new Map();
|
|
1198
|
+
if (reply) for (const task of reply.tasks) byStatus.set(task.status, [...byStatus.get(task.status) ?? [], task]);
|
|
1199
|
+
const projectKnown = !reply || query.project === undefined || reply.projects.some((project) => project.id === query.project);
|
|
1200
|
+
const showFilters = reply && (reply.total > 0 || filtered);
|
|
1201
|
+
const first = reply ? (reply.page - 1) * reply.pageSize + 1 : 1;
|
|
1202
|
+
return h("section", { className: "c4d-section c4d-board", "aria-label": t("tasks.title"), "aria-busy": loading, "data-colco4dsh-board": reply ? "ready" : error ? "error" : "loading" },
|
|
1203
|
+
h("style", null, BOARD_CSS),
|
|
1204
|
+
h("div", { className: "c4d-row" },
|
|
1205
|
+
h("h2", null, t("tasks.title")),
|
|
1206
|
+
h("button", { type: "button", "aria-label": t("tasks.refreshLabel"), disabled: loading, onClick: onRefresh }, t("tasks.refresh"))),
|
|
1207
|
+
error ? h(ErrorLine, { message: t("tasks.error", { message: error }) }) : null,
|
|
1208
|
+
!reply && !error ? h("p", { className: "c4d-muted" }, t("tasks.loading")) : null,
|
|
1209
|
+
showFilters ? h("div", { className: "c4d-board-bar" },
|
|
1210
|
+
h("select", { "aria-label": t("tasks.filterProject"), value: query.project === undefined ? "" : String(query.project), onChange: (event) => onQuery({ project: event.target.value }) },
|
|
1211
|
+
h("option", { value: "" }, t("tasks.allProjects")),
|
|
1212
|
+
reply.projects.map((project) => h("option", { key: project.id, value: String(project.id) }, t("tasks.optionCount", { name: project.name, count: project.count }))),
|
|
1213
|
+
projectKnown ? null : h("option", { value: String(query.project) }, t("tasks.projectGone", { id: query.project }))),
|
|
1214
|
+
h("select", { "aria-label": t("tasks.filterStatus"), value: query.status ?? "", onChange: (event) => onQuery({ status: event.target.value }) },
|
|
1215
|
+
h("option", { value: "" }, t("tasks.allStatuses")),
|
|
1216
|
+
reply.counts.map((entry) => h("option", { key: entry.status, value: entry.status }, t("tasks.optionCount", { name: t(`tasks.status.${entry.status}`), count: entry.count }))))) : null,
|
|
1217
|
+
reply && reply.total === 0 ? h("p", { className: "c4d-muted" }, filtered ? t("tasks.emptyFiltered") : t("tasks.empty")) : null,
|
|
1218
|
+
reply && reply.total > 0 && reply.tasks.length === 0 ? h("p", { className: "c4d-muted" }, t("tasks.pageEmpty")) : null,
|
|
1219
|
+
reply ? reply.counts.map((entry) => (byStatus.has(entry.status) ? h(TaskGroup, { key: entry.status, t, status: entry.status, tasks: byStatus.get(entry.status), total: entry.count }) : null)) : null,
|
|
1220
|
+
reply && (reply.pages > 1 || reply.page > 1) ? h("nav", { className: "c4d-board-pager", "aria-label": t("tasks.pager") },
|
|
1221
|
+
h("button", { type: "button", disabled: reply.page <= 1, onClick: () => onQuery({ page: Math.min(reply.page - 1, reply.pages) }) }, t("tasks.previous")),
|
|
1222
|
+
h("span", null, t("tasks.pageOf", { page: reply.page, pages: reply.pages })),
|
|
1223
|
+
reply.tasks.length ? h("span", { className: "c4d-muted" }, t("tasks.range", { from: first, to: first + reply.tasks.length - 1, total: reply.total })) : null,
|
|
1224
|
+
h("button", { type: "button", disabled: reply.page >= reply.pages, onClick: () => onQuery({ page: reply.page + 1 }) }, t("tasks.next"))) : null);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* The board with its data: loads GET tasks for the current filters and page, reloads every BOARD_POLL_MS while
|
|
1229
|
+
* the page is visible, and steps back to the last page when the current one has emptied (tasks finished meanwhile).
|
|
1230
|
+
* `initial` seeds the first render (server rendering in tests performs no request).
|
|
1231
|
+
* @param {{ t: Function, initial?: { reply: object } }} props
|
|
1232
|
+
*/
|
|
1233
|
+
function MyTasksBoard({ t, initial }) {
|
|
1234
|
+
const [query, setQuery] = useState({ page: 1 });
|
|
1235
|
+
const [reply, setReply] = useState(initial ? initial.reply : undefined);
|
|
1236
|
+
const [error, setError] = useState(undefined);
|
|
1237
|
+
const [loading, setLoading] = useState(!initial);
|
|
1238
|
+
const [revision, setRevision] = useState(0);
|
|
1239
|
+
useEffect(() => {
|
|
1240
|
+
const controller = new AbortController();
|
|
1241
|
+
setLoading(true);
|
|
1242
|
+
call(boardPath(query), { signal: controller.signal }).then((next) => {
|
|
1243
|
+
if (controller.signal.aborted) return;
|
|
1244
|
+
setReply(next);
|
|
1245
|
+
setError(undefined);
|
|
1246
|
+
setLoading(false);
|
|
1247
|
+
if (next.tasks.length === 0 && next.total > 0 && query.page > next.pages) setQuery((current) => changeBoardQuery(current, { page: next.pages }));
|
|
1248
|
+
}, (failure) => {
|
|
1249
|
+
if (controller.signal.aborted) return;
|
|
1250
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
1251
|
+
setLoading(false);
|
|
1252
|
+
});
|
|
1253
|
+
return () => controller.abort();
|
|
1254
|
+
}, [query, revision]);
|
|
1255
|
+
useEffect(() => {
|
|
1256
|
+
const timer = setInterval(() => { if (document.visibilityState !== "hidden") setRevision((value) => value + 1); }, BOARD_POLL_MS);
|
|
1257
|
+
return () => clearInterval(timer);
|
|
1258
|
+
}, []);
|
|
1259
|
+
return h(TaskBoard, { t, query, reply, loading, error, onQuery: (change) => setQuery((current) => changeBoardQuery(current, change)), onRefresh: () => setRevision((value) => value + 1) });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** What the board adds to the factory's exports, for the tests. */
|
|
1263
|
+
const BOARD_EXPORTS = { TaskBoard, MyTasksBoard, changeBoardQuery, boardPath };
|
|
1264
|
+
|
|
1265
|
+
/** English copy for hosts without DSH's locale service (tests, other hosts). */
|
|
1266
|
+
const fallbackT = translator(en);
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Glyph of the left-sidebar row. The sidebar owns the button, its label and the selected state. The glyph is
|
|
1270
|
+
* decorative, except that a red dot inside its 24 by 24 box, with an accessible count, marks open decisions.
|
|
1271
|
+
* It follows the shared status store, so it updates while the Colco page is closed.
|
|
1272
|
+
* @param {{ size: number, active?: boolean, t?: Function, store?: object }} props - the edge the sidebar asks for; `t` and `store` are for tests and hosts.
|
|
1273
|
+
*/
|
|
1274
|
+
function ColcoPanelIcon({ size, t, store }) {
|
|
1275
|
+
const { openCount } = useStatusSnapshot(store ?? sharedStatusStore());
|
|
1276
|
+
const label = openCount > 0 ? (t ?? fallbackT)(openCount === 1 ? "panel.openDecision" : "panel.openDecisions", { count: openCount }) : undefined;
|
|
1277
|
+
return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", ...label ? { role: "img", "aria-label": label } : { "aria-hidden": "true" }, focusable: "false" },
|
|
1278
|
+
h("path", { d: "M16.6 7.4a6.5 6.5 0 1 0 0 9.2" }),
|
|
1279
|
+
h("circle", { cx: 18.4, cy: 12, r: 1.7, fill: "currentColor", stroke: "none" }),
|
|
1280
|
+
openCount > 0 ? h("circle", { cx: 19.5, cy: 4.5, r: 3, stroke: "none", "data-colco4dsh-dot": "true", style: { fill: "var(--dsw-alias-state-error-primary,#cf222e)" } }) : null);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* The Colco page in DSH's main column. DSH mounts a main panel only while it is the open one, so the panel
|
|
1285
|
+
* polls for as long as this page is mounted. `t` is this namespace's translate, passed by the slot.
|
|
1286
|
+
* @param {{ t?: Function, initialStatus?: object, initialSetup?: { data: object, options: object } }} props
|
|
1287
|
+
*/
|
|
1288
|
+
function ColcoPage({ t, initialStatus, initialSetup }) {
|
|
1289
|
+
const tr = t ?? fallbackT;
|
|
1290
|
+
return h("div", { className: "c4d-page" },
|
|
1291
|
+
h("div", { className: "c4d-page-scroll" },
|
|
1292
|
+
h("div", { className: "c4d-page-content" },
|
|
1293
|
+
h("header", { className: "c4d-page-heading" },
|
|
1294
|
+
h("h1", null, tr("panel.title")),
|
|
1295
|
+
h("p", { className: "c4d-muted" }, tr("panel.description"))),
|
|
1296
|
+
h(ColcoPanel, { t: tr, visible: true, initialStatus, initialSetup }))));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Register the copy, the Colco page in the main column and its row in the left sidebar. Plugins (order 0) and
|
|
1301
|
+
* Automation tasks (order 10) come first and More always follows the rows, so order 20 sits directly above More.
|
|
1302
|
+
* Every registration is removed when the plugin unloads.
|
|
1303
|
+
* @param {{ effect: Function, locale: { register: Function, bind: Function }, slots: { inject: Function, register: Function } }} ctx
|
|
1304
|
+
*/
|
|
1305
|
+
function apply(ctx) {
|
|
1306
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), "colco4dsh: dictionaries");
|
|
1307
|
+
const t = ctx.locale.bind(NS);
|
|
1308
|
+
ctx.effect(() => ctx.slots.inject("main", () => ctx.slots.register(
|
|
1309
|
+
{ name: "main", key: PANEL_ID, locale: NS },
|
|
1310
|
+
ColcoPage,
|
|
1311
|
+
)), "colco4dsh: main panel");
|
|
1312
|
+
ctx.effect(() => ctx.slots.inject("sidebar.panellist", () => ctx.slots.register(
|
|
1313
|
+
{ name: "sidebar.panellist", id: PANEL_ID, order: PANEL_ORDER, locale: NS, label: () => t("panel.title") },
|
|
1314
|
+
ColcoPanelIcon,
|
|
1315
|
+
)), "colco4dsh: sidebar row");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return { inject: ["slots", "locale"], apply, createStatusStore, submitAnswer, loadRulesFile, saveRulesFile, RulesFileDialog, ColcoPage, ColcoPanel, ColcoPanelIcon, SetupForm, translator, call, ApiError, dictionaries: { en, zh }, shellQuote, fixCommand, rulesToForm, formToRules, hasDshAgent, withDshAgent, formatDuration, API, ACTION_HEADER, PANEL_ID, NS, ...BOARD_EXPORTS };
|
|
1319
|
+
},
|
|
1320
|
+
});
|