@oharness/server 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routines page, the settings page, and the permission rules page.
|
|
3
|
+
*
|
|
4
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
5
|
+
*/
|
|
6
|
+
export const ROUTINES = String.raw `
|
|
7
|
+
// --- routines and settings -------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start a routine by describing it.
|
|
11
|
+
*
|
|
12
|
+
* A fresh session with the sentence begun, rather than a form: "every weekday
|
|
13
|
+
* morning, summarise yesterday's commits" is how people say it, and the agent
|
|
14
|
+
* turns that into a cron and a prompt that stands on its own. A form asked for
|
|
15
|
+
* five cron fields to express the same thing.
|
|
16
|
+
*/
|
|
17
|
+
async function newRoutine() {
|
|
18
|
+
await switchTo({ action: "new" });
|
|
19
|
+
input.value = t("routines.starter");
|
|
20
|
+
paintMirror();
|
|
21
|
+
input.focus();
|
|
22
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One run as a line: when, how it went, and what it cost. */
|
|
26
|
+
function runSummary(run) {
|
|
27
|
+
const when = new Date(run.startedAt).toLocaleString();
|
|
28
|
+
if (run.skipped) return t("routines.skipped", { when });
|
|
29
|
+
const parts = [t(run.ok ? "routines.lastOk" : "routines.lastFailed", { when })];
|
|
30
|
+
if (run.catchUp) parts.push(t("routines.catchUp"));
|
|
31
|
+
parts.push(t("routines.turns", { n: run.turns }));
|
|
32
|
+
if (run.costUsd) parts.push("$" + run.costUsd.toFixed(4));
|
|
33
|
+
return parts.join(" · ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A routine's past runs, fetched when opened.
|
|
38
|
+
*
|
|
39
|
+
* Skipped times are listed with the runs, with why: a routine that "didn't
|
|
40
|
+
* run" is the question this answers, and the answer is usually that the
|
|
41
|
+
* previous run was still going. A run opens its session — the whole of what
|
|
42
|
+
* the agent did, not the last paragraph of it.
|
|
43
|
+
*/
|
|
44
|
+
function routineHistory(routine) {
|
|
45
|
+
const box = el("details", "routine-history");
|
|
46
|
+
box.appendChild(el("summary", "muted small", t("routines.history")));
|
|
47
|
+
const list = el("div", "routine-runs");
|
|
48
|
+
box.appendChild(list);
|
|
49
|
+
box.addEventListener("toggle", () => {
|
|
50
|
+
if (!box.open || list.dataset.loaded) return;
|
|
51
|
+
list.dataset.loaded = "1";
|
|
52
|
+
list.appendChild(el("div", "muted small", t("routines.loading")));
|
|
53
|
+
void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
|
|
54
|
+
list.replaceChildren();
|
|
55
|
+
const runs = (body && body.runs) || [];
|
|
56
|
+
if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
|
|
57
|
+
for (const run of runs) {
|
|
58
|
+
const line = el(run.sessionId ? "button" : "div", "routine-run small" + (run.ok || run.skipped ? "" : " failed"));
|
|
59
|
+
line.appendChild(el("span", null, runSummary(run)));
|
|
60
|
+
if (run.skipped && run.reason === "still-running") {
|
|
61
|
+
line.appendChild(el("span", "muted", t("routines.stillRunning")));
|
|
62
|
+
}
|
|
63
|
+
if (run.sessionId) {
|
|
64
|
+
line.type = "button";
|
|
65
|
+
line.addEventListener("click", () => void openSession(run.sessionId, routine.cwd));
|
|
66
|
+
}
|
|
67
|
+
list.appendChild(line);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return box;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Scheduled runs, read from the scheduler rather than kept here.
|
|
76
|
+
*
|
|
77
|
+
* A panel with its own idea of the schedule list drifts from what actually
|
|
78
|
+
* fires, and the drift is invisible until a routine the user thought was
|
|
79
|
+
* running turns out never to have existed.
|
|
80
|
+
*/
|
|
81
|
+
async function showRoutines() {
|
|
82
|
+
const body = await getJson("/routines");
|
|
83
|
+
panelBody.replaceChildren();
|
|
84
|
+
if (body.error) return void panelBody.appendChild(panelError(body.error));
|
|
85
|
+
const routines = body.routines;
|
|
86
|
+
|
|
87
|
+
const panel = el("div", "panel");
|
|
88
|
+
const top = el("div", "row-head");
|
|
89
|
+
top.appendChild(el("h2", null, t("routines.title")));
|
|
90
|
+
const addButton = el("button", "ghost", t("routines.add"));
|
|
91
|
+
addButton.type = "button";
|
|
92
|
+
top.appendChild(addButton);
|
|
93
|
+
panel.appendChild(top);
|
|
94
|
+
|
|
95
|
+
addButton.addEventListener("click", () => void newRoutine());
|
|
96
|
+
|
|
97
|
+
if (!routines || !routines.length) {
|
|
98
|
+
panel.appendChild(el("p", "muted", t("routines.none")));
|
|
99
|
+
panel.appendChild(el("p", "muted", t("routines.empty")));
|
|
100
|
+
} else {
|
|
101
|
+
for (const routine of routines) {
|
|
102
|
+
const row = el("div", "row-card");
|
|
103
|
+
const head = el("div", "row-head");
|
|
104
|
+
head.appendChild(el("span", "row-title", routine.name));
|
|
105
|
+
head.appendChild(el("span", "mono row-cron", routine.cron));
|
|
106
|
+
if (!routine.enabled) head.appendChild(el("span", "tag", t("routines.paused")));
|
|
107
|
+
// Where it runs. A routine is made in one project and runs there, from
|
|
108
|
+
// whichever project is open when its time comes, so the card says which.
|
|
109
|
+
if (routine.cwd) {
|
|
110
|
+
const where = el("span", "muted small routine-where", projectName(routine.cwd));
|
|
111
|
+
where.title = routine.cwd;
|
|
112
|
+
head.appendChild(where);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const run = el("button", "ghost", t("routines.run"));
|
|
116
|
+
run.type = "button";
|
|
117
|
+
run.addEventListener("click", () => {
|
|
118
|
+
run.disabled = true;
|
|
119
|
+
run.textContent = t("routines.running");
|
|
120
|
+
void api("/routine", { name: routine.name }).then((result) => {
|
|
121
|
+
pushNotice(result);
|
|
122
|
+
void showRoutines();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
head.appendChild(run);
|
|
126
|
+
if (routine.removable) {
|
|
127
|
+
// Paused rather than deleted: the prompt someone got right is kept,
|
|
128
|
+
// and a routine that should not run this week is one switch away from
|
|
129
|
+
// running again.
|
|
130
|
+
const toggle = el("button", "switch");
|
|
131
|
+
toggle.type = "button";
|
|
132
|
+
toggle.setAttribute("role", "switch");
|
|
133
|
+
toggle.setAttribute("aria-checked", routine.enabled ? "true" : "false");
|
|
134
|
+
const label = routine.enabled ? t("routines.pause") : t("routines.resume");
|
|
135
|
+
toggle.setAttribute("aria-label", label);
|
|
136
|
+
toggle.title = label;
|
|
137
|
+
toggle.addEventListener("click", () => {
|
|
138
|
+
toggle.disabled = true;
|
|
139
|
+
void api("/routine/enabled", { name: routine.name, enabled: !routine.enabled }).then((result) => {
|
|
140
|
+
pushNotice(result);
|
|
141
|
+
void showRoutines();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
head.appendChild(toggle);
|
|
145
|
+
|
|
146
|
+
const remove = el("button", "ghost", t("routines.remove"));
|
|
147
|
+
remove.type = "button";
|
|
148
|
+
remove.addEventListener("click", () => {
|
|
149
|
+
if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
|
|
150
|
+
remove.disabled = true;
|
|
151
|
+
void api("/routine/remove", { name: routine.name }).then((result) => {
|
|
152
|
+
pushNotice(result);
|
|
153
|
+
void showRoutines();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
head.appendChild(remove);
|
|
157
|
+
}
|
|
158
|
+
row.appendChild(head);
|
|
159
|
+
|
|
160
|
+
const bits = [];
|
|
161
|
+
if (routine.enabled && routine.nextRunAt) {
|
|
162
|
+
bits.push(t("routines.next", { when: new Date(routine.nextRunAt).toLocaleString() }));
|
|
163
|
+
}
|
|
164
|
+
if (routine.lastRun) bits.push(runSummary(routine.lastRun));
|
|
165
|
+
if (bits.length) row.appendChild(el("div", "muted small", bits.join(" · ")));
|
|
166
|
+
if (routine.lastRun && routine.lastRun.error) {
|
|
167
|
+
row.appendChild(el("div", "err small", routine.lastRun.error));
|
|
168
|
+
}
|
|
169
|
+
row.appendChild(routineHistory(routine));
|
|
170
|
+
panel.appendChild(row);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
panelBody.appendChild(panel);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Everything the app decided for you, one line each.
|
|
178
|
+
*
|
|
179
|
+
* The page used to print every row in full: the value, the paragraph
|
|
180
|
+
* explaining it, and for the write policy a list of forty cache paths. Read
|
|
181
|
+
* top to bottom that is a wall of prose in which the three settings anybody
|
|
182
|
+
* ever changes are indistinguishable from the reference material around them.
|
|
183
|
+
*
|
|
184
|
+
* So each row is a disclosure now. The name and the current value stay on the
|
|
185
|
+
* line — that is what someone came to check — and the reasoning, the long
|
|
186
|
+
* paths and the lists wait behind a click. The controls stay on the summary,
|
|
187
|
+
* because a switch you have to open a row to reach is a switch that has been
|
|
188
|
+
* hidden rather than tidied.
|
|
189
|
+
*/
|
|
190
|
+
async function showSettings() {
|
|
191
|
+
const settings = await getJson("/settings");
|
|
192
|
+
panelBody.replaceChildren();
|
|
193
|
+
if (settings.error) return void panelBody.appendChild(panelError(settings.error));
|
|
194
|
+
|
|
195
|
+
const panel = el("div", "panel");
|
|
196
|
+
panel.appendChild(el("h2", null, t("settings.title")));
|
|
197
|
+
|
|
198
|
+
// Rows are grouped by what they are about. Each group is filled as the page
|
|
199
|
+
// is built and drawn at the end under its heading, and only when it has rows.
|
|
200
|
+
const groups = { workspace: el("div"), sync: el("div"), memory: el("div"), activity: el("div"), advanced: el("div") };
|
|
201
|
+
let target = groups.workspace;
|
|
202
|
+
|
|
203
|
+
// A click on a button inside a summary toggles the disclosure as well as
|
|
204
|
+
// running the button, so every action on a row gets this. Without it a
|
|
205
|
+
// switch would flip and open the row underneath it, which reads as the page
|
|
206
|
+
// doing something you did not ask for.
|
|
207
|
+
const keepShut = (node) => {
|
|
208
|
+
node.addEventListener("click", (e) => {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
});
|
|
212
|
+
return node;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Variadic since sync brought the first row with two things to do: a switch
|
|
216
|
+
// and a "do it now". Returns the body so a caller with more to say than a
|
|
217
|
+
// hint — the roots editor, the audit trail — can put it there.
|
|
218
|
+
const field = (label, value, hint, ...actions) => {
|
|
219
|
+
const row = el("details", "setting");
|
|
220
|
+
const head = el("summary");
|
|
221
|
+
head.appendChild(el("span", "chev"));
|
|
222
|
+
head.appendChild(el("div", "setting-name", label));
|
|
223
|
+
// The line is one line: a value that does not fit is cut with an ellipsis
|
|
224
|
+
// rather than wrapped, and the whole of it is printed again inside, so
|
|
225
|
+
// nothing is only available on hover. A value that fits gets no tooltip —
|
|
226
|
+
// one that repeats the text under the pointer is just something in the way.
|
|
227
|
+
const long = !!value && (value.indexOf("\n") >= 0 || value.length > 56);
|
|
228
|
+
const shown = el("div", "setting-value mono", value || "");
|
|
229
|
+
if (long) shown.title = value;
|
|
230
|
+
head.appendChild(shown);
|
|
231
|
+
for (const action of actions) if (action) head.appendChild(keepShut(action));
|
|
232
|
+
row.appendChild(head);
|
|
233
|
+
|
|
234
|
+
const body = el("div", "setting-body");
|
|
235
|
+
if (long) body.appendChild(el("div", "mono small", value));
|
|
236
|
+
if (hint) body.appendChild(el("div", "muted small", hint));
|
|
237
|
+
row.appendChild(body);
|
|
238
|
+
target.appendChild(row);
|
|
239
|
+
return body;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What a door row shows when it is opened, read the first time it is.
|
|
244
|
+
*
|
|
245
|
+
* Every row here that leads to a page is opened by somebody who wants a
|
|
246
|
+
* look before they decide to go there, and a row whose whole body was a
|
|
247
|
+
* sentence and a button made them go there to find out whether it was worth
|
|
248
|
+
* going. So each one carries the shape of its page: the figures, the first
|
|
249
|
+
* few rules, the last few decisions.
|
|
250
|
+
*
|
|
251
|
+
* On first open, not on paint. Two of these walk every session on disk, and
|
|
252
|
+
* the rows are on screen whether or not anybody cares — which is why the
|
|
253
|
+
* totals were left off them in the first place.
|
|
254
|
+
*/
|
|
255
|
+
const preview = (body, load) => {
|
|
256
|
+
const box = el("div");
|
|
257
|
+
body.appendChild(box);
|
|
258
|
+
const row = body.parentNode;
|
|
259
|
+
let loaded = false;
|
|
260
|
+
row.addEventListener("toggle", () => {
|
|
261
|
+
if (!row.open || loaded) return;
|
|
262
|
+
loaded = true;
|
|
263
|
+
box.appendChild(el("div", "muted small", t("preview.loading")));
|
|
264
|
+
void load(box).then(
|
|
265
|
+
() => {},
|
|
266
|
+
(err) => { box.replaceChildren(el("div", "muted small", String(err && err.message || err))); },
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
return box;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// A labelled block inside an opened row: what the label names, printed as
|
|
273
|
+
// typed. Used for the permission rules and the settings files, both of
|
|
274
|
+
// which are lists someone reads once and closes again.
|
|
275
|
+
const listing = (label, text) => {
|
|
276
|
+
const group = el("div", "setting-list");
|
|
277
|
+
group.appendChild(el("div", "muted small", label));
|
|
278
|
+
const list = el("div", "mono small");
|
|
279
|
+
list.textContent = text;
|
|
280
|
+
group.appendChild(list);
|
|
281
|
+
return group;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A switch for one boolean the user owns.
|
|
286
|
+
*
|
|
287
|
+
* role="switch" with aria-checked rather than a checkbox, because the
|
|
288
|
+
* element also has to carry a label a screen reader can read and a checkbox
|
|
289
|
+
* here would need one built around it for no gain.
|
|
290
|
+
*/
|
|
291
|
+
const toggle = (on, label, onChange, disabled) => {
|
|
292
|
+
const button = el("button", "switch");
|
|
293
|
+
button.type = "button";
|
|
294
|
+
button.setAttribute("role", "switch");
|
|
295
|
+
button.setAttribute("aria-checked", on ? "true" : "false");
|
|
296
|
+
button.setAttribute("aria-label", label);
|
|
297
|
+
button.title = label;
|
|
298
|
+
if (disabled) button.disabled = true;
|
|
299
|
+
button.addEventListener("click", () => {
|
|
300
|
+
button.disabled = true;
|
|
301
|
+
onChange(!on);
|
|
302
|
+
});
|
|
303
|
+
return button;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// The working directory is the one setting on this page that is a choice
|
|
307
|
+
// rather than a report, so it gets the same picker the sidebar uses — the
|
|
308
|
+
// list of known projects, plus a folder chooser for one that is not on it.
|
|
309
|
+
const change = el("button", "ghost", t("settings.change"));
|
|
310
|
+
change.type = "button";
|
|
311
|
+
change.addEventListener("click", openProjectPicker);
|
|
312
|
+
field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
|
|
313
|
+
permissionsField(settings);
|
|
314
|
+
|
|
315
|
+
// A door, not a report: the numbers live on a page of their own, and the row
|
|
316
|
+
// here carries no total of its own because working one out means walking
|
|
317
|
+
// every session on disk — a cost this page would then pay on every paint,
|
|
318
|
+
// for a figure most visits are not here for.
|
|
319
|
+
target = groups.activity;
|
|
320
|
+
const openUsage = el("button", "ghost", t("usage.open"));
|
|
321
|
+
openUsage.type = "button";
|
|
322
|
+
openUsage.addEventListener("click", () => {
|
|
323
|
+
setNav("usage");
|
|
324
|
+
void showUsage();
|
|
325
|
+
});
|
|
326
|
+
const usageRow = field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
|
|
327
|
+
preview(usageRow, async (box) => {
|
|
328
|
+
const body = await getJson("/usage?range=30d");
|
|
329
|
+
box.replaceChildren();
|
|
330
|
+
if (body.error) return void box.appendChild(el("div", "muted small", body.error));
|
|
331
|
+
const totals = body.totals || {};
|
|
332
|
+
const sent = (totals.input || 0) + (totals.cacheRead || 0) + (totals.cacheWrite || 0);
|
|
333
|
+
const moved = sent + (totals.output || 0);
|
|
334
|
+
const facts = [
|
|
335
|
+
"$" + (totals.costUsd || 0).toFixed(2),
|
|
336
|
+
t("usage.tokens", { n: formatTokens(moved) }),
|
|
337
|
+
];
|
|
338
|
+
if (totals.cacheRead) facts.push(t("usage.cached", { pct: Math.round((totals.cacheRead / sent) * 100) }));
|
|
339
|
+
facts.push(totals.sessions === 1 ? t("usage.overSession") : t("usage.overSessions", { n: totals.sessions || 0 }));
|
|
340
|
+
// The default window, said out loud: the figures on the page move with
|
|
341
|
+
// its picker, and a preview that named no window would be read as all time.
|
|
342
|
+
box.appendChild(el("div", "mono small", facts.join(" · ")));
|
|
343
|
+
box.appendChild(el("div", "muted small", t("usage.range.30d")));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/*
|
|
347
|
+
* Memory sits here rather than in the sidebar, and only when a plugin has
|
|
348
|
+
* actually registered it: a row for a page that does not exist would be a
|
|
349
|
+
* dead end, and the memory plugin can be switched off.
|
|
350
|
+
*/
|
|
351
|
+
target = groups.memory;
|
|
352
|
+
for (const page of pluginPages.values()) {
|
|
353
|
+
const open = el("button", "ghost", t("settings.memoryOpen"));
|
|
354
|
+
open.type = "button";
|
|
355
|
+
open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
|
|
356
|
+
const row = field(t("settings.memory"), "", t("settings.memoryHint"), open);
|
|
357
|
+
const id = page.id;
|
|
358
|
+
preview(row, async (box) => {
|
|
359
|
+
const result = await getJson("/plugin-page?id=" + encodeURIComponent(id)
|
|
360
|
+
+ "&session=" + encodeURIComponent(activeId || ""));
|
|
361
|
+
box.replaceChildren();
|
|
362
|
+
if (result.error && !result.entries) {
|
|
363
|
+
return void box.appendChild(el("div", "muted small", result.error));
|
|
364
|
+
}
|
|
365
|
+
// The same state the page opens with, worked out the same way — one
|
|
366
|
+
// sentence rather than the card, because the card is the page.
|
|
367
|
+
const state = !result.signedIn ? "local"
|
|
368
|
+
: result.accountChanged ? "account"
|
|
369
|
+
: !result.enabled ? "off"
|
|
370
|
+
: result.syncing ? "syncing"
|
|
371
|
+
: (result.errors && result.errors.length) || result.error ? "error" : "on";
|
|
372
|
+
box.appendChild(el("div", "small", t("memory.state." + state)));
|
|
373
|
+
const entries = result.entries || [];
|
|
374
|
+
const project = entries.filter((entry) => entry.scope !== "user").length;
|
|
375
|
+
box.appendChild(el("div", "muted small", result.userAvailable
|
|
376
|
+
? t("memory.countsScoped", { project, user: entries.length - project })
|
|
377
|
+
: t("memory.counts", { count: entries.length })));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
target = groups.activity;
|
|
382
|
+
auditField(settings.audit || { on: true, source: "user" });
|
|
383
|
+
target = groups.advanced;
|
|
384
|
+
filesField(settings.settingsPaths);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* What runs, what stops to ask, what is refused, and where writes may land
|
|
388
|
+
* — one row, and a page behind it.
|
|
389
|
+
*
|
|
390
|
+
* These were two rows called Rules and Writes, which is two names for one
|
|
391
|
+
* subject: both are the permission policy, both are read after something
|
|
392
|
+
* surprising happened, and neither is a thing anybody sets from here. The
|
|
393
|
+
* row answers "is anything set?" from its closed line; the lists, and the
|
|
394
|
+
* one editor among them, are on the page.
|
|
395
|
+
*/
|
|
396
|
+
function permissionsField(settings) {
|
|
397
|
+
const workspace = settings.workspace || {};
|
|
398
|
+
const rules = settings.permissions || {};
|
|
399
|
+
const roots = (workspace.writableRoots || []).length;
|
|
400
|
+
|
|
401
|
+
const summary = [workspace.mode || "—"];
|
|
402
|
+
if (workspace.mode === "workspace-write") summary.push(roots + " roots");
|
|
403
|
+
for (const kind of ["allow", "ask", "deny"]) {
|
|
404
|
+
if (rules[kind] && rules[kind].length) summary.push(kind + " " + rules[kind].length);
|
|
405
|
+
}
|
|
406
|
+
const session = (rules.sessionAllow || []).length;
|
|
407
|
+
if (session) summary.push(t("settings.sessionApproved") + " " + session);
|
|
408
|
+
|
|
409
|
+
const open = el("button", "ghost", t("perms.open"));
|
|
410
|
+
open.type = "button";
|
|
411
|
+
open.addEventListener("click", () => { setNav("permissions"); void showPermissions(); });
|
|
412
|
+
const body = field(t("perms.title"), summary.join(" · "), t("perms.hint"), open);
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* No read behind this one: everything here came with the settings the row
|
|
416
|
+
* was built from, so it is all free and is shown at once.
|
|
417
|
+
*
|
|
418
|
+
* The mode and the roots lead, because they are the part that is always
|
|
419
|
+
* there. A project with no rules of its own is the common case, and this
|
|
420
|
+
* row answered it with one sentence saying nothing was set — while the
|
|
421
|
+
* page behind it had a mode and fifty-two directories to show.
|
|
422
|
+
*/
|
|
423
|
+
const shown = 4;
|
|
424
|
+
body.appendChild(el("div", "mono small", [
|
|
425
|
+
workspace.mode || "—",
|
|
426
|
+
workspace.mode === "workspace-write" ? t("perms.rootsCount", { n: roots }) : "",
|
|
427
|
+
].filter(Boolean).join(" · ")));
|
|
428
|
+
const chosen = settings.configuredRoots || [];
|
|
429
|
+
const rootList = chosen.length ? chosen : (workspace.writableRoots || []);
|
|
430
|
+
if (workspace.mode === "workspace-write" && rootList.length) {
|
|
431
|
+
body.appendChild(listing(
|
|
432
|
+
chosen.length ? t("perms.mine") : t("perms.writes"),
|
|
433
|
+
rootList.slice(0, shown).join("\n")));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let hidden = 0;
|
|
437
|
+
let ruled = false;
|
|
438
|
+
for (const kind of ["allow", "ask", "deny"]) {
|
|
439
|
+
const list = rules[kind] || [];
|
|
440
|
+
if (!list.length) continue;
|
|
441
|
+
ruled = true;
|
|
442
|
+
body.appendChild(listing(kind + " · " + list.length, list.slice(0, shown).join("\n")));
|
|
443
|
+
hidden += Math.max(0, list.length - shown);
|
|
444
|
+
}
|
|
445
|
+
if (!ruled) body.appendChild(el("div", "muted small", t("perms.noRules")));
|
|
446
|
+
if (hidden) body.appendChild(el("div", "muted small", t("perms.more", { n: hidden })));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Whether permission decisions are being written down, and what they say.
|
|
451
|
+
*
|
|
452
|
+
* A switch, because it is a state someone owns rather than a report — but
|
|
453
|
+
* only when they own it. A repository that turned the trail off in its own
|
|
454
|
+
* config has decided this for everyone working in it, and a control offered
|
|
455
|
+
* over that would appear to work and revert on the next launch, so it is
|
|
456
|
+
* shown disabled with the reason underneath instead of hidden.
|
|
457
|
+
*
|
|
458
|
+
* The trail comes with the row rather than behind a second button inside
|
|
459
|
+
* it: opening the row is already the gesture for "tell me more about this",
|
|
460
|
+
* and a "Show trail" you had to find after that was one click too many for
|
|
461
|
+
* the only thing anybody opens this row to see.
|
|
462
|
+
*/
|
|
463
|
+
function auditField(audit) {
|
|
464
|
+
const mine = audit.source !== "project";
|
|
465
|
+
|
|
466
|
+
/*
|
|
467
|
+
* A door beside the switch.
|
|
468
|
+
*
|
|
469
|
+
* The row answers the question it is passed on the way to something else
|
|
470
|
+
* — is this on, and what did the session I am in just get allowed to do —
|
|
471
|
+
* and the page behind it answers the one the trail is actually opened
|
|
472
|
+
* for: when was this ever allowed, which is a question a single session
|
|
473
|
+
* rarely holds the answer to. Leaving that to the export meant answering
|
|
474
|
+
* it by reading JSON lines by hand.
|
|
475
|
+
*/
|
|
476
|
+
const open = el("button", "ghost", t("settings.auditOpen"));
|
|
477
|
+
open.type = "button";
|
|
478
|
+
open.addEventListener("click", () => {
|
|
479
|
+
auditScope = "session";
|
|
480
|
+
auditFilter = "all";
|
|
481
|
+
setNav("audit");
|
|
482
|
+
void showAudit();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const body = field(
|
|
486
|
+
t("settings.audit"),
|
|
487
|
+
t(audit.on ? "settings.auditOnValue" : "settings.auditOffValue"),
|
|
488
|
+
mine ? t(audit.on ? "settings.auditOn" : "settings.auditOff") : t("settings.auditProject"),
|
|
489
|
+
open,
|
|
490
|
+
toggle(audit.on, t("settings.audit"), (next) => {
|
|
491
|
+
void api("/audit", { enabled: next }).then((result) => {
|
|
492
|
+
if (result && result.error) addNotice(result.error, "err");
|
|
493
|
+
void showSettings();
|
|
494
|
+
});
|
|
495
|
+
}, !mine),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
// The extra copy, when the config named one. Not a control: nobody typed
|
|
499
|
+
// that path into this page, and a switch here that deleted it would turn
|
|
500
|
+
// "stop for now" into "forget where my collector reads from".
|
|
501
|
+
if (audit.file) {
|
|
502
|
+
body.appendChild(el("div", "muted small", t("settings.auditFile", { path: audit.file })));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
preview(body, async (box) => {
|
|
506
|
+
const result = await getJson("/audit");
|
|
507
|
+
box.replaceChildren();
|
|
508
|
+
if (result && result.error) return void box.appendChild(panelError(result.error));
|
|
509
|
+
const rows = auditRows(result.entries);
|
|
510
|
+
if (!rows.length) {
|
|
511
|
+
box.appendChild(el("div", "muted small", t("settings.auditEmpty")));
|
|
512
|
+
/*
|
|
513
|
+
* An empty session is not an empty machine, and the row said only the
|
|
514
|
+
* first of those — which reads as a trail that records nothing.
|
|
515
|
+
*
|
|
516
|
+
* The wider read happens only here, in the one case where the cheap
|
|
517
|
+
* one came back with nothing: it is a file read per session, and
|
|
518
|
+
* paying it to decorate a row that already had something to show
|
|
519
|
+
* would be the cost this preview is lazy to avoid.
|
|
520
|
+
*/
|
|
521
|
+
const everywhere = await getJson("/audit?session=all");
|
|
522
|
+
if (everywhere.error) return;
|
|
523
|
+
const all = auditRows(everywhere.entries);
|
|
524
|
+
if (all.length) {
|
|
525
|
+
box.appendChild(el("div", "muted small", t("audit.elsewhere", { n: all.length })));
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// The most recent handful, and a count of what is behind them. The row
|
|
530
|
+
// used to print two hundred lines into a box inside a disclosure inside
|
|
531
|
+
// a scrolling page, and stop dead at the two hundredth with a number
|
|
532
|
+
// and nowhere to go; the rest of them are what the page is for.
|
|
533
|
+
box.appendChild(auditList(rows.slice(0, AUDIT_PREVIEW), false));
|
|
534
|
+
if (rows.length > AUDIT_PREVIEW) {
|
|
535
|
+
box.appendChild(el("div", "muted small",
|
|
536
|
+
t("settings.auditMore", { n: rows.length - AUDIT_PREVIEW })));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/*
|
|
541
|
+
* Taking the record away with you.
|
|
542
|
+
*
|
|
543
|
+
* Plain links rather than buttons that fetch and build a blob: the server
|
|
544
|
+
* already names the file and hands it over, and a download that the page
|
|
545
|
+
* assembles in memory is one the browser cannot resume, cannot stream, and
|
|
546
|
+
* shows in no download list.
|
|
547
|
+
*
|
|
548
|
+
* Two scopes, because they answer different questions. This session is
|
|
549
|
+
* what somebody is looking at when they open the row; everything is what
|
|
550
|
+
* "send me the audit" means, and building it out of one export per session
|
|
551
|
+
* is not a thing anybody should be asked to do by hand.
|
|
552
|
+
*/
|
|
553
|
+
body.appendChild(auditExports(activeId));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Where these settings were read from — three paths, one row.
|
|
558
|
+
*
|
|
559
|
+
* Nothing here is a choice, and the three of them were three cards of grey
|
|
560
|
+
* absolute path apiece. The closed line says which layers exist, which is
|
|
561
|
+
* the whole of what most visits need; the paths are for the visit where
|
|
562
|
+
* something is set and you cannot see where from.
|
|
563
|
+
*/
|
|
564
|
+
function filesField(paths) {
|
|
565
|
+
if (!paths) return;
|
|
566
|
+
const scopes = ["user", "project", "local"].filter((scope) => paths[scope]);
|
|
567
|
+
if (!scopes.length) return;
|
|
568
|
+
const body = field(t("settings.files"), scopes.join(" · "), t("settings.filesHint"));
|
|
569
|
+
for (const scope of scopes) body.appendChild(listing(scope, paths[scope]));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The write policy, without the wall of text.
|
|
574
|
+
*
|
|
575
|
+
* This row used to print every allowed root inline, and on a machine with
|
|
576
|
+
* plugins installed that is dozens of cache directories — a page of grey
|
|
577
|
+
* paths that buried every setting under it and answered a question nobody
|
|
578
|
+
* asked in that much detail.
|
|
579
|
+
*
|
|
580
|
+
* Collapsed to the shape of the answer instead: the mode and how many
|
|
581
|
+
* roots. The list is one click away, and it comes split, because the two
|
|
582
|
+
* halves are not the same kind of thing. What the config named is a choice
|
|
583
|
+
* and can be changed here. What the plugins contributed is derived and is
|
|
584
|
+
* rebuilt at launch, so a control over it would be a control that silently
|
|
585
|
+
* reverts.
|
|
586
|
+
*/
|
|
587
|
+
// Sync, above analytics, because it is the one on this page that decides
|
|
588
|
+
// where the work goes rather than whether a counter is incremented.
|
|
589
|
+
target = groups.sync;
|
|
590
|
+
const sync = await getJson("/sync");
|
|
591
|
+
if (sync && !sync.error) {
|
|
592
|
+
// Three states, not two. Off and "on but signed out" both send nothing,
|
|
593
|
+
// and a switch is the fix for only one of them — so the hint says which,
|
|
594
|
+
// and the button that would do nothing is not offered.
|
|
595
|
+
const value = sync.on
|
|
596
|
+
? "on — mirroring to " + (sync.url || "the sync service")
|
|
597
|
+
: sync.reason === "no-credential"
|
|
598
|
+
? "on, but not signed in — nothing is being sent"
|
|
599
|
+
: sync.reason === "excluded"
|
|
600
|
+
? "off for this project"
|
|
601
|
+
: "off";
|
|
602
|
+
const hint = sync.on
|
|
603
|
+
? "Transcripts and artifacts, including revisions you make by hand in a canvas, are mirrored to your account."
|
|
604
|
+
: sync.reason === "no-credential"
|
|
605
|
+
? "Sign in to start mirroring. Until then everything stays on this machine."
|
|
606
|
+
: sync.reason === "excluded"
|
|
607
|
+
? "Listed under sync.exclude in the config. Nothing from this project leaves the machine."
|
|
608
|
+
: "Everything stays on this machine.";
|
|
609
|
+
|
|
610
|
+
const actions = [];
|
|
611
|
+
|
|
612
|
+
// Shown even when it cannot run, and disabled with the reason above it.
|
|
613
|
+
// Hiding it was worse: someone looking for "sync now" found nothing and
|
|
614
|
+
// had no way to tell whether the app has it at all.
|
|
615
|
+
const now = el("button", "ghost", "Sync now");
|
|
616
|
+
now.type = "button";
|
|
617
|
+
now.disabled = !sync.on;
|
|
618
|
+
now.addEventListener("click", () => {
|
|
619
|
+
now.disabled = true;
|
|
620
|
+
now.textContent = "syncing…";
|
|
621
|
+
void api("/sync/now").then((body) => {
|
|
622
|
+
now.disabled = false;
|
|
623
|
+
now.textContent = "Sync now";
|
|
624
|
+
// Said out loud either way: the whole point of the button is being
|
|
625
|
+
// told it happened, and a silent success is indistinguishable from a
|
|
626
|
+
// click that missed.
|
|
627
|
+
if (body && body.error) addNotice("could not sync: " + body.error, "err");
|
|
628
|
+
else addNotice("pushed everything waiting");
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
actions.push(now);
|
|
632
|
+
|
|
633
|
+
// The switch reads the setting, not the outcome: someone who has not
|
|
634
|
+
// signed in has still left sync on, and flipping it off under them
|
|
635
|
+
// because it cannot reach anything yet would be answering a question they
|
|
636
|
+
// did not ask.
|
|
637
|
+
if (sync.reason !== "excluded") {
|
|
638
|
+
actions.push(
|
|
639
|
+
toggle(sync.reason !== "disabled", "Sync to your account", (next) => {
|
|
640
|
+
void api("/sync", { enabled: next }).then(showSettings);
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
field("Sync", value, hint, ...actions);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Last, and only when this build could actually send something. A switch
|
|
649
|
+
// that does nothing is worse than no switch: it invites someone to believe
|
|
650
|
+
// they turned something off that was never on.
|
|
651
|
+
target = groups.activity;
|
|
652
|
+
const analytics = await getJson("/analytics");
|
|
653
|
+
if (analytics && !analytics.error && analytics.available) {
|
|
654
|
+
field(
|
|
655
|
+
t("settings.analytics"),
|
|
656
|
+
t(analytics.enabled ? "settings.analyticsOn" : "settings.analyticsOff"),
|
|
657
|
+
t("settings.analyticsHint"),
|
|
658
|
+
toggle(analytics.enabled, t("settings.analytics"), (next) => {
|
|
659
|
+
void api("/analytics", { enabled: next }).then(showSettings);
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
// Only in the desktop app: keeping a computer awake is an Electron call, and
|
|
664
|
+
// a switch a browser tab cannot honour would be one that lies.
|
|
665
|
+
const keepAwake = await getJson("/keep-awake");
|
|
666
|
+
if (keepAwake && !keepAwake.error && keepAwake.available) {
|
|
667
|
+
target = groups.workspace;
|
|
668
|
+
field(
|
|
669
|
+
t("settings.keepAwake"),
|
|
670
|
+
t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
|
|
671
|
+
t("settings.keepAwakeHint"),
|
|
672
|
+
toggle(keepAwake.enabled, t("settings.keepAwake"), (next) => {
|
|
673
|
+
void api("/keep-awake", { enabled: next }).then(showSettings);
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
// Which browsers the agent may use. Three choices, so a segmented control
|
|
678
|
+
// rather than a switch; absent when the browser tools are off altogether,
|
|
679
|
+
// since then there is nothing for it to move.
|
|
680
|
+
const browserModes = ["auto", "builtin", "extension"];
|
|
681
|
+
if (browserModes.includes(settings.browserMode)) {
|
|
682
|
+
target = groups.workspace;
|
|
683
|
+
const seg = el("div", "seg setting-seg");
|
|
684
|
+
seg.setAttribute("role", "radiogroup");
|
|
685
|
+
seg.setAttribute("aria-label", t("settings.browserMode"));
|
|
686
|
+
for (const mode of browserModes) {
|
|
687
|
+
const chosen = mode === settings.browserMode;
|
|
688
|
+
const button = el("button", "seg-item" + (chosen ? " on" : ""), t("settings.browserMode." + mode));
|
|
689
|
+
button.type = "button";
|
|
690
|
+
button.setAttribute("role", "radio");
|
|
691
|
+
button.setAttribute("aria-checked", chosen ? "true" : "false");
|
|
692
|
+
button.addEventListener("click", () => {
|
|
693
|
+
if (chosen) return;
|
|
694
|
+
seg.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
|
695
|
+
void api("/settings/browser-mode", { mode }).then(showSettings);
|
|
696
|
+
});
|
|
697
|
+
seg.appendChild(button);
|
|
698
|
+
}
|
|
699
|
+
const body = field(t("settings.browserMode"), "", t("settings.browserModeHint." + settings.browserMode), seg);
|
|
700
|
+
|
|
701
|
+
// Your own browser is reached through the extension, so the row says
|
|
702
|
+
// whether it is reachable now, and where to get it when it is not.
|
|
703
|
+
const ext = settings.browserExtension || {};
|
|
704
|
+
const status = el("div", "setting-status small");
|
|
705
|
+
status.appendChild(el("span", "status-dot" + (ext.connected ? " ok" : "")));
|
|
706
|
+
status.appendChild(document.createTextNode(t(ext.connected ? "settings.extensionConnected" : "settings.extensionMissing")));
|
|
707
|
+
body.appendChild(status);
|
|
708
|
+
body.appendChild(extensionHelp(ext));
|
|
709
|
+
}
|
|
710
|
+
const sections = [
|
|
711
|
+
["settings.section.workspace", [groups.workspace]],
|
|
712
|
+
["settings.section.sync", [groups.sync, groups.memory]],
|
|
713
|
+
["settings.section.activity", [groups.activity]],
|
|
714
|
+
["settings.section.advanced", [groups.advanced]],
|
|
715
|
+
];
|
|
716
|
+
for (const [title, parts] of sections) {
|
|
717
|
+
if (!parts.some((part) => part.childNodes.length)) continue;
|
|
718
|
+
panel.appendChild(el("div", "section-title", t(title)));
|
|
719
|
+
for (const part of parts) panel.appendChild(part);
|
|
720
|
+
}
|
|
721
|
+
panelBody.appendChild(panel);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Where the money went, and on which model.
|
|
726
|
+
*
|
|
727
|
+
* A page rather than a row in Settings, because the question it answers has
|
|
728
|
+
* more than one shape: what has this cost, what is it costing per model, and
|
|
729
|
+
* which sessions were the expensive ones. The line under the composer answers
|
|
730
|
+
* the first for the session in front of you; this answers the rest for the
|
|
731
|
+
* machine.
|
|
732
|
+
*
|
|
733
|
+
* Everything here is read back from the transcripts on disk — nothing is
|
|
734
|
+
* counted twice, nothing survives a session that was deleted, and a run
|
|
735
|
+
* started from the terminal is in the totals the same as one started here.
|
|
736
|
+
*/
|
|
737
|
+
/**
|
|
738
|
+
* The permission policy, on a page.
|
|
739
|
+
*
|
|
740
|
+
* Everything the Settings row summarises: the mode, the rules in each of the
|
|
741
|
+
* three kinds, what was approved for this session only, and the writable
|
|
742
|
+
* roots — the one editable thing here, which is why the page exists rather
|
|
743
|
+
* than a taller row. A textarea inside a disclosure inside a scrolling page
|
|
744
|
+
* was an editor nobody could see the whole of while typing in it.
|
|
745
|
+
*/
|
|
746
|
+
async function showPermissions() {
|
|
747
|
+
const settings = await getJson("/settings");
|
|
748
|
+
if (shownPanel !== "permissions") return;
|
|
749
|
+
panelBody.replaceChildren();
|
|
750
|
+
if (settings.error) return void panelBody.appendChild(panelError(settings.error));
|
|
751
|
+
|
|
752
|
+
const workspace = settings.workspace || {};
|
|
753
|
+
const rules = settings.permissions || {};
|
|
754
|
+
|
|
755
|
+
const panel = el("div", "panel");
|
|
756
|
+
const head = el("div", "row-head");
|
|
757
|
+
head.appendChild(el("h2", null, t("perms.title")));
|
|
758
|
+
const back = el("button", "ghost", t("usage.back"));
|
|
759
|
+
back.type = "button";
|
|
760
|
+
back.addEventListener("click", () => {
|
|
761
|
+
setNav("nav-settings");
|
|
762
|
+
void showSettings();
|
|
763
|
+
});
|
|
764
|
+
head.appendChild(back);
|
|
765
|
+
panel.appendChild(head);
|
|
766
|
+
panelBody.appendChild(panel);
|
|
767
|
+
|
|
768
|
+
// A labelled block: what the label names, printed as typed.
|
|
769
|
+
const listing = (label, text, faint) => {
|
|
770
|
+
const group = el("div", "setting-list");
|
|
771
|
+
group.appendChild(el("div", "muted small", label));
|
|
772
|
+
const list = el("div", "mono small" + (faint ? " scroll-box" : ""));
|
|
773
|
+
if (faint) list.style.opacity = ".7";
|
|
774
|
+
list.textContent = text;
|
|
775
|
+
group.appendChild(list);
|
|
776
|
+
return group;
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const card = (title) => {
|
|
780
|
+
panel.appendChild(el("h3", "section-title", title));
|
|
781
|
+
const box = el("section", "row-card perms-card");
|
|
782
|
+
panel.appendChild(box);
|
|
783
|
+
return box;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// The mode first, because it decides what the rules below are exceptions to.
|
|
787
|
+
// Drawn on the scale it sits on, so "workspace-write" reads as the middle
|
|
788
|
+
// of three rather than as a word to look up.
|
|
789
|
+
const modes = ["read-only", "workspace-write", "full-access"];
|
|
790
|
+
const modeCard = card(t("perms.mode"));
|
|
791
|
+
const modeRow = el("div", "perm-mode");
|
|
792
|
+
const current = workspace.mode || "";
|
|
793
|
+
modeRow.appendChild(el("span", "perm-mode-name " + current, current || "—"));
|
|
794
|
+
if (modes.indexOf(current) >= 0) {
|
|
795
|
+
const steps = el("div", "perm-mode-steps");
|
|
796
|
+
for (const mode of modes) steps.appendChild(el("span", "perm-mode-step" + (mode === current ? " on" : ""), mode));
|
|
797
|
+
modeRow.appendChild(steps);
|
|
798
|
+
}
|
|
799
|
+
modeCard.appendChild(modeRow);
|
|
800
|
+
if (modes.indexOf(current) >= 0) modeCard.appendChild(el("div", "muted small", t("perms.modeHint." + current)));
|
|
801
|
+
|
|
802
|
+
const rulesCard = card(t("settings.rules"));
|
|
803
|
+
rulesCard.appendChild(el("div", "muted small", t("settings.rulesHint")));
|
|
804
|
+
const group = (kind, title, items) => {
|
|
805
|
+
const box = el("div", "perm-group");
|
|
806
|
+
const head = el("div", "perm-group-head " + kind, title);
|
|
807
|
+
head.appendChild(el("span", "n", String(items.length)));
|
|
808
|
+
box.appendChild(head);
|
|
809
|
+
const chips = el("div", "perm-chips");
|
|
810
|
+
for (const item of items) {
|
|
811
|
+
const chip = el("span", "perm-chip " + kind, item);
|
|
812
|
+
chip.title = item;
|
|
813
|
+
chips.appendChild(chip);
|
|
814
|
+
}
|
|
815
|
+
box.appendChild(chips);
|
|
816
|
+
rulesCard.appendChild(box);
|
|
817
|
+
};
|
|
818
|
+
let any = false;
|
|
819
|
+
for (const kind of ["allow", "ask", "deny"]) {
|
|
820
|
+
if (!rules[kind] || !rules[kind].length) continue;
|
|
821
|
+
any = true;
|
|
822
|
+
group(kind, t("perms.kind." + kind), rules[kind]);
|
|
823
|
+
}
|
|
824
|
+
const session = rules.sessionAllow || [];
|
|
825
|
+
if (session.length) {
|
|
826
|
+
any = true;
|
|
827
|
+
group("session", t("settings.sessionApproved") + " — " + t("settings.notPersisted"), session);
|
|
828
|
+
}
|
|
829
|
+
if (!any) rulesCard.appendChild(el("div", "perm-empty", t("perms.noRules")));
|
|
830
|
+
|
|
831
|
+
// The roots, and only where they mean anything: in every other mode the
|
|
832
|
+
// list is not consulted, and an editor for it would be a control that does
|
|
833
|
+
// nothing until a setting somewhere else changes.
|
|
834
|
+
if (workspace.mode === "workspace-write") {
|
|
835
|
+
const all = workspace.writableRoots || [];
|
|
836
|
+
const chosen = settings.configuredRoots || [];
|
|
837
|
+
const derived = all.filter((root) => chosen.indexOf(root) < 0);
|
|
838
|
+
|
|
839
|
+
const writes = card(t("perms.writes"));
|
|
840
|
+
writes.previousSibling.appendChild(el("span", "perm-count", String(all.length)));
|
|
841
|
+
writes.appendChild(el("div", "muted small", t("perms.writesHint", { n: all.length })));
|
|
842
|
+
|
|
843
|
+
// Yours, and editable. A textarea rather than a list with buttons because
|
|
844
|
+
// the thing being edited is a handful of paths, and typing one is what
|
|
845
|
+
// someone came here to do.
|
|
846
|
+
const mine = el("div", "setting-list");
|
|
847
|
+
mine.appendChild(el("div", "muted small", t("perms.mine")));
|
|
848
|
+
writes.appendChild(mine);
|
|
849
|
+
|
|
850
|
+
const box = el("textarea", "boxed");
|
|
851
|
+
box.value = chosen.join("\n");
|
|
852
|
+
box.rows = Math.max(3, chosen.length + 1);
|
|
853
|
+
box.spellcheck = false;
|
|
854
|
+
box.style.width = "100%";
|
|
855
|
+
box.placeholder = t("perms.placeholder");
|
|
856
|
+
mine.appendChild(box);
|
|
857
|
+
|
|
858
|
+
const save = el("button", "ghost", t("perms.save"));
|
|
859
|
+
save.type = "button";
|
|
860
|
+
save.addEventListener("click", () => {
|
|
861
|
+
save.disabled = true;
|
|
862
|
+
const roots = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
863
|
+
void api("/settings/writable-roots", { roots }).then((result) => {
|
|
864
|
+
save.disabled = false;
|
|
865
|
+
if (result && result.error) return void addNotice(result.error, "err");
|
|
866
|
+
// Said out loud, because the list above will go on showing the old
|
|
867
|
+
// one until then and someone would reasonably read that as a failure.
|
|
868
|
+
addNotice(t("perms.saved"));
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
mine.appendChild(save);
|
|
872
|
+
|
|
873
|
+
if (derived.length) {
|
|
874
|
+
// "more" only reads right when there were some above it. With nothing
|
|
875
|
+
// configured — the common case — every one of them is derived.
|
|
876
|
+
const group = el("div", "setting-list");
|
|
877
|
+
group.appendChild(el("div", "muted small", chosen.length
|
|
878
|
+
? t("perms.derivedMore", { n: derived.length })
|
|
879
|
+
: t("perms.derivedAll", { n: derived.length })));
|
|
880
|
+
// The last segment is what tells fifty plugin paths apart; the long
|
|
881
|
+
// shared prefix is dimmed and gives way first when the line is short.
|
|
882
|
+
const list = el("div", "perm-roots");
|
|
883
|
+
for (const root of derived) {
|
|
884
|
+
const cut = root.replace(/[\\/]+$/, "").search(/[^\\/]+$/);
|
|
885
|
+
const line = el("div", "perm-root");
|
|
886
|
+
line.title = root;
|
|
887
|
+
const dir = el("span", "dir");
|
|
888
|
+
dir.appendChild(el("bdi", null, cut > 0 ? root.slice(0, cut) : ""));
|
|
889
|
+
line.appendChild(dir);
|
|
890
|
+
line.appendChild(el("span", "base", cut > 0 ? root.slice(cut) : root));
|
|
891
|
+
list.appendChild(line);
|
|
892
|
+
}
|
|
893
|
+
group.appendChild(list);
|
|
894
|
+
writes.appendChild(group);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/*
|
|
900
|
+
* ---- the permission trail -------------------------------------------------
|
|
901
|
+
*
|
|
902
|
+
* Two readers of one record. The Settings row shows the last few decisions of
|
|
903
|
+
* the session someone is in; the page shows every session on this machine,
|
|
904
|
+
* filtered by how each decision ended. They share the pairing and the line
|
|
905
|
+
* below rather than each having a copy — the pairing is the part that was
|
|
906
|
+
* wrong before it existed, and one of them would have kept being wrong.
|
|
907
|
+
*/
|
|
908
|
+
|
|
909
|
+
/** How much of the trail the Settings row shows before it points at the page. */`;
|
|
910
|
+
//# sourceMappingURL=routines.js.map
|