toolroll 0.9.86 → 0.9.88
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/CHANGELOG.md +86 -0
- package/dist/acp-check.d.ts +9 -0
- package/dist/acp-check.js +40 -28
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +722 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +31 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +23 -0
- package/dist/release.js +46 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +24 -12
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +70 -0
- package/dist/server/kit.d.ts +419 -0
- package/dist/server/kit.js +369 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { type Html } from "../html.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/** What a live invite grants, said on its join page ("Your access"). Project names only, never paths. */
|
|
3
|
+
export type InviteTerms = {
|
|
4
|
+
role: "approver" | "viewer";
|
|
5
|
+
mintedBy: string;
|
|
6
|
+
expiresAt: string;
|
|
7
|
+
projects: readonly string[] | null;
|
|
8
|
+
};
|
|
9
|
+
/** Which join field a refusal is about. */
|
|
10
|
+
export type JoinProblem = {
|
|
11
|
+
field: "name" | "password";
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
/** The invite's front door: cookie-free, script-free, sensitive by shape. Rendered only for a LIVE token — everything
|
|
15
|
+
* dead gets joinDeadPage. A live token's holder reads what it grants before choosing a name and a password. */
|
|
16
|
+
export declare function joinFormPage(token: string, problem: JoinProblem | null, name: string, terms?: InviteTerms | null): Html;
|
|
5
17
|
/** Unknown, expired, revoked, consumed, attempts spent: ONE page (D6). */
|
|
6
18
|
export declare function joinDeadPage(): Html;
|
|
7
19
|
/**
|
|
@@ -43,3 +55,12 @@ export declare const SIGNUP_PASSWORD_MIN = 8;
|
|
|
43
55
|
* The message sits under the field it is about, announced (role="alert"), with that field aria-invalid, described by
|
|
44
56
|
* it and focused, so it works without script (this page has none). */
|
|
45
57
|
export declare function signupPage(problem: SignupProblem | string | null, attemptsLeft: number, typedName?: string): Html;
|
|
58
|
+
/** The page a browser sees when answering broke (Wave E): what happened, whether anything changed, a reference for the
|
|
59
|
+
* log, and two ways on. `read`: the request only read, so nothing can have changed. Holds no error text. */
|
|
60
|
+
export declare function serverErrorPage(options: {
|
|
61
|
+
updating: boolean;
|
|
62
|
+
read: boolean;
|
|
63
|
+
reference: string;
|
|
64
|
+
at: Date;
|
|
65
|
+
retry: string | null;
|
|
66
|
+
}): Html;
|
|
@@ -1,34 +1,40 @@
|
|
|
1
|
-
/** Sign-in, sign-up, join and wrong-host pages.
|
|
1
|
+
/** Sign-in, sign-up, join and wrong-host pages: pages outside the shell, built from the kit (server/kit.ts outPage).
|
|
2
|
+
* They carry no script and no session; every refusal names the field it is about. */
|
|
2
3
|
import { BRAND_HTML, shell } from "./chrome.js";
|
|
3
4
|
import { html, joinHtml, postForm, replaceMarkup } from "../html.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
5
|
+
import { btn, disclose, facts, notice, outField, outPage } from "./kit.js";
|
|
6
|
+
import { projectName } from "../project.js";
|
|
7
|
+
const ACCESS_WORDS = { approver: ["Can manage and approve", "Create, manage and approve work."], viewer: ["Can view", "Read work and results."] };
|
|
8
|
+
/** The invite's front door: cookie-free, script-free, sensitive by shape. Rendered only for a LIVE token — everything
|
|
9
|
+
* dead gets joinDeadPage. A live token's holder reads what it grants before choosing a name and a password. */
|
|
10
|
+
export function joinFormPage(token, problem, name, terms = null) {
|
|
11
|
+
const [access, accessLine] = terms === null ? [null, null] : ACCESS_WORDS[terms.role];
|
|
12
|
+
const grants = terms === null ? "" : facts([
|
|
13
|
+
["Your access", html `${access} · <span class="k-muted">${accessLine}</span>`],
|
|
14
|
+
["Projects", terms.projects === null ? "Every project" : terms.projects.length === 0 ? "None yet" : terms.projects.map(one => projectName(one)).join(", ")],
|
|
15
|
+
["Expires", `${expiryWords(terms.expiresAt)} · works once`],
|
|
16
|
+
]);
|
|
17
|
+
const form = postForm(`/join/${token}`, joinHtml([
|
|
18
|
+
outField({ id: "join-name", label: "Choose a username", name: "name", value: name, error: problem?.field === "name" ? problem.text : null,
|
|
19
|
+
attrs: { autocomplete: "username", autocapitalize: "none", spellcheck: "false", required: true, ...(problem === null ? { autofocus: true } : {}) } }),
|
|
20
|
+
outField({ id: "join-password", label: "Choose a password", name: "password", type: "password", hint: "At least 8 characters.", error: problem?.field === "password" ? problem.text : null,
|
|
21
|
+
attrs: { autocomplete: "new-password" } }),
|
|
22
|
+
btn("Join", { variant: "primary", type: "submit", className: "k-wide" }),
|
|
23
|
+
]), { signedOut: true, attrs: { class: "k-form" } });
|
|
24
|
+
return shell("Join Toolroll", outPage({
|
|
25
|
+
brand: BRAND_HTML, brandHref: "/login", title: "Join Toolroll",
|
|
26
|
+
sub: terms === null ? "You were invited. Choose a username and a password to sign in." : html `<b>${terms.mintedBy}</b> invited you. This is what the invite grants:`,
|
|
27
|
+
body: joinHtml([grants, form]),
|
|
28
|
+
foot: "This link works once, for you. Joining creates your own account; whoever runs this Toolroll can change what it can do later.",
|
|
29
|
+
}), { nav: false });
|
|
22
30
|
}
|
|
23
31
|
/** Unknown, expired, revoked, consumed, attempts spent: ONE page (D6). */
|
|
24
32
|
export function joinDeadPage() {
|
|
25
|
-
return shell("Join Toolroll",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
</div>
|
|
31
|
-
</div></div>`, { nav: false });
|
|
33
|
+
return shell("Join Toolroll", outPage({
|
|
34
|
+
brand: BRAND_HTML, brandHref: "/login", title: "This invite link can't be used",
|
|
35
|
+
sub: "Links work once and expire. Ask the person who invited you for a fresh one.",
|
|
36
|
+
body: html `<div class="k-acts">${btn("Sign in instead", { href: "/login" })}</div>`,
|
|
37
|
+
}), { nav: false });
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* v100: for someone signed in with the identity provider, every step-up
|
|
@@ -48,27 +54,31 @@ export function ssoStepUps(page, sso, returnTo) {
|
|
|
48
54
|
const labelled = replaceMarkup(page, /<label>[^<]*<input\b([^>]*)>\s*<\/label>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
|
|
49
55
|
return replaceMarkup(labelled, /<input\b([^>]*)>/g, (_whole, attributes) => stepUp(attributes) ? swap(attributes) : null);
|
|
50
56
|
}
|
|
57
|
+
/** An expiry as a person reads it: "Thu 5 Nov, 10:42". */
|
|
58
|
+
function expiryWords(iso) {
|
|
59
|
+
const at = new Date(iso);
|
|
60
|
+
if (Number.isNaN(at.getTime()))
|
|
61
|
+
return iso.slice(0, 16).replace("T", " ");
|
|
62
|
+
return at.toLocaleString("en-GB", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
63
|
+
}
|
|
51
64
|
export function loginPage(problem, returnTo = "/", sso = null) {
|
|
65
|
+
const form = postForm("/login", joinHtml([
|
|
66
|
+
outField({ id: "login-name", label: "Username", name: "name", attrs: { autocomplete: "username", autocapitalize: "none", spellcheck: "false", required: true, ...(sso === null ? { autofocus: true } : {}) } }),
|
|
67
|
+
outField({ id: "login-password", label: "Password", name: "token", type: "password", attrs: { autocomplete: "current-password" } }),
|
|
68
|
+
btn("Sign in", { variant: sso === null ? "primary" : "outline", type: "submit", className: "k-wide" }),
|
|
69
|
+
]), { signedOut: true, returnTo: returnTo === "/" ? null : returnTo, attrs: { class: "k-form" } });
|
|
52
70
|
// v100: with an identity provider, its button comes first and passwords wait behind "Use a password".
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
html `<label>Password<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
65
|
-
html `<button type="submit">Sign in</button>`,
|
|
66
|
-
], "\n"), { signedOut: true, returnTo: returnTo === "/" ? null : returnTo }),
|
|
67
|
-
html `</div>`,
|
|
68
|
-
sso === null ? "" : html `</details>`,
|
|
69
|
-
html `<p class="login-foot">${sso === null ? html `Your login was shown when Toolroll first started, and saved beside its database as <code>up-login.txt</code>.<br>` : ""}No account? Ask whoever runs it for an invite link.</p>`,
|
|
70
|
-
html `</div></div>`,
|
|
71
|
-
], "\n"), { nav: false });
|
|
71
|
+
const body = sso === null
|
|
72
|
+
? joinHtml([problem === null ? "" : html `<div class="k-refusal">${notice(problem)}</div>`, form])
|
|
73
|
+
: joinHtml([
|
|
74
|
+
problem === null ? "" : html `<div class="k-refusal">${notice(problem)}</div>`,
|
|
75
|
+
btn(`Sign in with ${sso.label}`, { variant: "primary", href: `/login/sso${returnTo === "/" ? "" : `?return=${encodeURIComponent(returnTo)}`}`, className: "k-wide" }),
|
|
76
|
+
disclose(sso.operatorsOnly ? "Instance operators: use a password" : "Use a password", form),
|
|
77
|
+
]);
|
|
78
|
+
return shell("Sign in · Toolroll", outPage({
|
|
79
|
+
brand: BRAND_HTML, brandHref: "/login", title: "Sign in", body,
|
|
80
|
+
foot: html `${sso === null ? html `Forgot it? Your first login was shown when Toolroll started, and saved beside its database as <code>up-login.txt</code> on the computer running it.<br>` : ""}No account? Ask whoever runs this Toolroll for an invite link.`,
|
|
81
|
+
}), { nav: false });
|
|
72
82
|
}
|
|
73
83
|
/**
|
|
74
84
|
* The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
|
|
@@ -114,32 +124,43 @@ export function signupPage(problem, attemptsLeft, typedName = "") {
|
|
|
114
124
|
const said = typeof problem === "string" ? { field: null, text: problem }
|
|
115
125
|
: problem ?? (open ? null : { field: null, text: "Too many wrong setup codes. Restart Toolroll to get a new code." });
|
|
116
126
|
const field = open ? said?.field ?? null : null;
|
|
117
|
-
const error = (which) => field === which && said !== null ?
|
|
118
|
-
const describe = (which, hint) => field === which ? html ` aria-invalid="true" aria-describedby="${hint} signup-problem" autofocus` : html ` aria-describedby="${hint}"`;
|
|
127
|
+
const error = (which) => field === which && said !== null ? said.text : null;
|
|
119
128
|
const tries = attemptsLeft === 1 ? "1 try left" : `${attemptsLeft} tries left`;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
129
|
+
const pageProblem = !open || field === null ? (said === null ? "" : notice(said.text)) : "";
|
|
130
|
+
const form = !open ? "" : postForm("/signup", joinHtml([
|
|
131
|
+
outField({ id: "signup-code", label: "Setup code", name: "code", hint: `Printed in the terminal that started Toolroll. ${tries}.`, error: error("code"),
|
|
132
|
+
attrs: { inputmode: "numeric", autocomplete: "one-time-code", autocapitalize: "none", spellcheck: "false", required: true, ...(field === null ? { autofocus: true } : {}) } }),
|
|
133
|
+
outField({ id: "signup-name", label: "Username", name: "name", value: typedName, hint: "Letters, digits, dots, dashes or underscores, starting with a letter or digit. Up to 64.", error: error("name"),
|
|
134
|
+
attrs: { autocomplete: "username", autocapitalize: "none", spellcheck: "false", required: true, maxlength: 64, pattern: SIGNUP_NAME_PATTERN } }),
|
|
135
|
+
outField({ id: "signup-password", label: "Password", name: "password", type: "password", hint: `At least ${SIGNUP_PASSWORD_MIN} characters. You'll type it again to approve work.`, error: error("password"),
|
|
136
|
+
attrs: { autocomplete: "new-password", required: true, minlength: SIGNUP_PASSWORD_MIN } }),
|
|
137
|
+
btn("Create account", { variant: "primary", type: "submit", className: "k-wide" }),
|
|
138
|
+
]), { signedOut: true, attrs: { class: "k-form" } });
|
|
139
|
+
return shell("Create the first account · Toolroll", outPage({
|
|
140
|
+
brand: BRAND_HTML, brandHref: "/login", title: "Create the first account",
|
|
141
|
+
sub: "This Toolroll has no accounts yet. The first one can approve work and invite others.",
|
|
142
|
+
body: joinHtml([pageProblem, form]),
|
|
143
|
+
foot: "Lost the code? Restart Toolroll and it prints a new one.",
|
|
144
|
+
}), { nav: false });
|
|
145
|
+
}
|
|
146
|
+
/** The page a browser sees when answering broke (Wave E): what happened, whether anything changed, a reference for the
|
|
147
|
+
* log, and two ways on. `read`: the request only read, so nothing can have changed. Holds no error text. */
|
|
148
|
+
export function serverErrorPage(options) {
|
|
149
|
+
if (options.updating) {
|
|
150
|
+
return shell("Toolroll is updating", outPage({
|
|
151
|
+
brand: BRAND_HTML, title: "Toolroll is updating",
|
|
152
|
+
sub: "Work that is running can finish. New work starts again after the update, usually within two minutes.",
|
|
153
|
+
body: html `<div class="k-acts">${options.retry === null ? "" : btn("Try again", { variant: "primary", href: options.retry })}${btn("Go to Home", { href: "/" })}</div>`,
|
|
154
|
+
}), { nav: false });
|
|
155
|
+
}
|
|
156
|
+
return shell("Something went wrong", outPage({
|
|
157
|
+
brand: BRAND_HTML, title: "Something went wrong on our side",
|
|
158
|
+
sub: options.read ? "Toolroll couldn't load this page. Nothing you did was lost, and nothing was approved or sent."
|
|
159
|
+
: "Toolroll couldn't finish that. Open the page again to see what changed before you try again.",
|
|
160
|
+
body: joinHtml([
|
|
161
|
+
facts([["Reference", html `<span class="k-mono">${options.reference}</span>`], ["When", options.at.toISOString().slice(0, 16).replace("T", " ")]]),
|
|
162
|
+
html `<div class="k-acts">${options.retry === null ? "" : btn("Try again", { variant: "primary", href: options.retry })}${btn("Go to Home", { href: "/" })}</div>`,
|
|
163
|
+
]),
|
|
164
|
+
foot: "If it keeps happening, the reference helps whoever runs this Toolroll find it in the log.",
|
|
165
|
+
}), { nav: false });
|
|
145
166
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type Html } from "../html.js";
|
|
2
|
+
import { type ListOutcome } from "../onboard.js";
|
|
3
|
+
import { type ProjectOutcomes } from "../project-outcomes.js";
|
|
4
|
+
import { type Chrome, type Screen } from "./chrome.js";
|
|
5
|
+
import { type OnboardCardState, type ProjectPeek } from "./render-pages.js";
|
|
6
|
+
/** A path as a person reads it: the home folder as ~. */
|
|
7
|
+
export declare function shortPathOf(path: string): string;
|
|
8
|
+
/** A project's page address. Names are folder names, so two projects can share one: then the path picks. */
|
|
9
|
+
export declare function projectHref(name: string, path: string, ambiguous: boolean): string;
|
|
10
|
+
/** What a project's counts say on one line ("2 need you · 1 running"); null when it wasn't scanned. */
|
|
11
|
+
export declare function peekWords(peek: ProjectPeek | null): string | null;
|
|
12
|
+
/** Where a message belongs: next to the GitHub link, in the clone review, the page's own refusal, or a neutral line
|
|
13
|
+
* (a finished clone). */
|
|
14
|
+
export type ProblemAt = "link" | "clone" | "open" | "said" | null;
|
|
15
|
+
/** The add ways, as the drawer (and the no-script page) shows them: GitHub, then this computer. */
|
|
16
|
+
export declare function addWaysHtml(data: {
|
|
17
|
+
browsable: boolean;
|
|
18
|
+
onboard: OnboardCardState | null;
|
|
19
|
+
returnTo: string;
|
|
20
|
+
linkProblem: string | null;
|
|
21
|
+
}): Html;
|
|
22
|
+
/** The clone review a preview minted (single use, ten minutes): what lands where, then the step-up. */
|
|
23
|
+
export declare function cloneReviewHtml(onboard: OnboardCardState | null, problem: string | null): Html | null;
|
|
24
|
+
/** The projects list: the React view draws it from `workspace.view`; the body is the same page for a browser
|
|
25
|
+
* without the workspace script (and what the sensitivity check reads). */
|
|
26
|
+
export declare function projectsPage(chrome: Chrome, data: {
|
|
27
|
+
recent: readonly {
|
|
28
|
+
path: string;
|
|
29
|
+
name: string;
|
|
30
|
+
lastOpenedAt: string;
|
|
31
|
+
}[];
|
|
32
|
+
candidates: readonly string[];
|
|
33
|
+
open: string | null;
|
|
34
|
+
problem: string | null;
|
|
35
|
+
problemAt: ProblemAt;
|
|
36
|
+
browsable: boolean;
|
|
37
|
+
onboard: OnboardCardState | null;
|
|
38
|
+
peeks: Readonly<Record<string, ProjectPeek | null>>;
|
|
39
|
+
returnTo: string;
|
|
40
|
+
}): Screen;
|
|
41
|
+
export type ProjectPageTask = {
|
|
42
|
+
href: string;
|
|
43
|
+
title: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
needs: boolean;
|
|
46
|
+
since: string | null;
|
|
47
|
+
};
|
|
48
|
+
/** One project's reading page. Reads only: Open project posts to /projects/select, which switches this session. */
|
|
49
|
+
export declare function projectPage(chrome: Chrome, data: {
|
|
50
|
+
name: string;
|
|
51
|
+
path: string;
|
|
52
|
+
selfHref: string;
|
|
53
|
+
open: boolean;
|
|
54
|
+
openedAt: string | null;
|
|
55
|
+
peek: ProjectPeek | null;
|
|
56
|
+
tasks: readonly ProjectPageTask[];
|
|
57
|
+
moreTasks: number;
|
|
58
|
+
checks: {
|
|
59
|
+
href: string;
|
|
60
|
+
level: string;
|
|
61
|
+
} | null;
|
|
62
|
+
pullRequests: {
|
|
63
|
+
href: string;
|
|
64
|
+
on: boolean;
|
|
65
|
+
} | null;
|
|
66
|
+
knowledgeHref: string;
|
|
67
|
+
settingsHref: string | null;
|
|
68
|
+
activity: {
|
|
69
|
+
report: ProjectOutcomes | null;
|
|
70
|
+
} | null;
|
|
71
|
+
now: Date;
|
|
72
|
+
}): Screen;
|
|
73
|
+
export declare function browsePage(chrome: Chrome, data: {
|
|
74
|
+
at: string;
|
|
75
|
+
root: string;
|
|
76
|
+
roots: readonly string[];
|
|
77
|
+
parent: string | null;
|
|
78
|
+
entries: readonly {
|
|
79
|
+
name: string;
|
|
80
|
+
path: string;
|
|
81
|
+
git: boolean;
|
|
82
|
+
}[];
|
|
83
|
+
/** Projects already added here: their row says Open, not Add and open. */
|
|
84
|
+
registered: ReadonlySet<string>;
|
|
85
|
+
}): Screen;
|
|
86
|
+
/** The account's repositories with ONE honest action each: open (already a project here), add and open (a clone
|
|
87
|
+
* under a projects folder), or the existing preview-and-password clone ceremony. */
|
|
88
|
+
export declare function githubReposPage(chrome: Chrome, data: {
|
|
89
|
+
/** The last listing; null until someone lists (a POST: listing asks GitHub, so a page visit never does). */
|
|
90
|
+
listed: ListOutcome | null;
|
|
91
|
+
local: ReadonlyMap<string, string>;
|
|
92
|
+
registered: ReadonlySet<string>;
|
|
93
|
+
cloneReady: boolean;
|
|
94
|
+
openProject: string | null;
|
|
95
|
+
/** A projects folder didn't answer in time: local copies weren't matched this time. */
|
|
96
|
+
localUnanswered?: boolean;
|
|
97
|
+
now: Date;
|
|
98
|
+
}): Screen;
|
|
99
|
+
/** A project name nobody can see answers the same as one that doesn't exist. */
|
|
100
|
+
export declare const unknownProject: (name: string) => {
|
|
101
|
+
title: string;
|
|
102
|
+
text: string;
|
|
103
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects on the kit (src/design/KIT.md): the list's server parts (the add ways, the clone review), the project's
|
|
3
|
+
* reading page, folder browse and the GitHub listing. Names, descriptions and paths are gh or filesystem data,
|
|
4
|
+
* escaped at the sink like everything else (html.ts).
|
|
5
|
+
*/
|
|
6
|
+
import { ACTIVITY_ACTORS, activityAge, activityLimit, activityMetrics, activityScope } from "../project-activity.js";
|
|
7
|
+
import {} from "../browser-workspace.js";
|
|
8
|
+
import { html, htmlString, joinHtml, postForm } from "../html.js";
|
|
9
|
+
import {} from "../onboard.js";
|
|
10
|
+
import {} from "../project-outcomes.js";
|
|
11
|
+
import { projectName } from "../project.js";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { relativeAge, screen } from "./chrome.js";
|
|
14
|
+
import { acts, approvalFactsNow, approvalTerms, approveForm, btn, chip, count, disclose, doc, docHeader, dock, empty, facts, field, group, infoLine, input, linkTabs, note, notice, pageHeader, phoneHeader, row, slots, status, } from "./kit.js";
|
|
15
|
+
import {} from "./render-pages.js";
|
|
16
|
+
/** A path as a person reads it: the home folder as ~. */
|
|
17
|
+
export function shortPathOf(path) {
|
|
18
|
+
const home = homedir();
|
|
19
|
+
return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
20
|
+
}
|
|
21
|
+
/** A project's page address. Names are folder names, so two projects can share one: then the path picks. */
|
|
22
|
+
export function projectHref(name, path, ambiguous) {
|
|
23
|
+
return `/projects/${encodeURIComponent(name)}${ambiguous ? `?path=${encodeURIComponent(path)}` : ""}`;
|
|
24
|
+
}
|
|
25
|
+
/** What a project's counts say on one line ("2 need you · 1 running"); null when it wasn't scanned. */
|
|
26
|
+
export function peekWords(peek) {
|
|
27
|
+
if (peek === null)
|
|
28
|
+
return null;
|
|
29
|
+
const words = [
|
|
30
|
+
peek.waiting > 0 ? `${peek.waiting} need${peek.waiting === 1 ? "s" : ""} you` : "",
|
|
31
|
+
peek.running > 0 ? `${peek.running} running` : "",
|
|
32
|
+
peek.queued > 0 ? `${peek.queued} queued` : "",
|
|
33
|
+
].filter(Boolean);
|
|
34
|
+
return words.length === 0 ? "Nothing waiting" : words.join(" · ");
|
|
35
|
+
}
|
|
36
|
+
const mono = (text) => html `<span class="k-mono">${text}</span>`;
|
|
37
|
+
const sentence = (text) => text === "" ? text : `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
38
|
+
/** A refusal says what didn't change. */
|
|
39
|
+
const refused = (text) => /nothing (changed|was)/i.test(text) ? sentence(text) : `${sentence(text).replace(/[.\s]+$/, "")}. Nothing changed.`;
|
|
40
|
+
// ---- the list's server parts -------------------------------------------------------------------------------------
|
|
41
|
+
/** The add ways, as the drawer (and the no-script page) shows them: GitHub, then this computer. */
|
|
42
|
+
export function addWaysHtml(data) {
|
|
43
|
+
const onboard = data.onboard;
|
|
44
|
+
const github = onboard === null
|
|
45
|
+
? null
|
|
46
|
+
: html `<section class="k-sec" aria-labelledby="add-gh"><h3 id="add-gh">From GitHub</h3>${!onboard.enabled
|
|
47
|
+
? note(sentence(onboard.why))
|
|
48
|
+
: joinHtml([
|
|
49
|
+
acts(btn("Choose a GitHub repository", { sm: true, href: "/projects/github" })),
|
|
50
|
+
postForm("/projects/onboard-preview", joinHtml([
|
|
51
|
+
field({ id: "add-link", label: "Or paste a GitHub link", hint: "You review it before anything is downloaded.", error: data.linkProblem === null ? null : refused(data.linkProblem),
|
|
52
|
+
control: input({ id: "add-link", name: "repo", hint: true, error: data.linkProblem !== null, attrs: { placeholder: "owner/name or https://github.com/owner/name", autocomplete: "off", spellcheck: "false" } }) }),
|
|
53
|
+
onboard.roots.length > 1
|
|
54
|
+
? field({ id: "add-root", label: "Clone into", control: html `<span class="k-selw wide"><select class="k-sel" id="add-root" name="root">${onboard.roots.map((one, index) => html `<option value="${index}">${shortPathOf(one)}</option>`)}</select></span>` })
|
|
55
|
+
: html `<input type="hidden" name="root" value="0">`,
|
|
56
|
+
acts(btn("Review", { sm: true, type: "submit" })),
|
|
57
|
+
onboard.roots.length === 1 ? note(html `Clones into ${mono(shortPathOf(onboard.roots[0] ?? ""))}.`) : "",
|
|
58
|
+
]), { attrs: { class: "k-sec" } }),
|
|
59
|
+
])}</section>`;
|
|
60
|
+
const local = html `<section class="k-sec" aria-labelledby="add-local"><h3 id="add-local">From this computer</h3>${joinHtml([
|
|
61
|
+
data.browsable ? acts(btn("Choose a folder", { sm: true, href: "/projects/browse" })) : note(html `Choose a projects folder once with ${mono("toolroll up --project-root <dir>")}. Toolroll remembers it after that.`),
|
|
62
|
+
postForm("/projects/open", joinHtml([
|
|
63
|
+
field({ id: "add-path", label: "Or enter an exact path", hint: "It must be a git repository.",
|
|
64
|
+
control: input({ id: "add-path", name: "path", hint: true, attrs: { placeholder: "/Users/you/code/your-repo", autocomplete: "off", spellcheck: "false" } }) }),
|
|
65
|
+
acts(btn("Add and open", { sm: true, type: "submit" })),
|
|
66
|
+
]), { attrs: { class: "k-sec" }, returnTo: data.returnTo }),
|
|
67
|
+
])}</section>`;
|
|
68
|
+
return html `<div class="k-sec">${github ?? ""}${local}</div>`;
|
|
69
|
+
}
|
|
70
|
+
/** The clone review a preview minted (single use, ten minutes): what lands where, then the step-up. */
|
|
71
|
+
export function cloneReviewHtml(onboard, problem) {
|
|
72
|
+
if (onboard === null || !onboard.enabled || onboard.record === null)
|
|
73
|
+
return null;
|
|
74
|
+
const [nonce, record] = onboard.record;
|
|
75
|
+
const kind = "step-up";
|
|
76
|
+
const factsNow = approvalFactsNow();
|
|
77
|
+
const size = record.diskUsageKib === null ? "Unknown" : `${Math.max(1, Math.round(record.diskUsageKib / 1024))} MiB`;
|
|
78
|
+
const bigOk = record.large
|
|
79
|
+
? html `<div class="k-checks"><label><input type="checkbox" name="big-ok" value="1" required>${record.nameWithOwner} is ${record.diskUsageKib === null ? "of unknown size" : size}. Clone it anyway</label></div>`
|
|
80
|
+
: html ``;
|
|
81
|
+
return html `${slots({
|
|
82
|
+
status: html `<h2>Clone ${record.nameWithOwner}</h2>`,
|
|
83
|
+
text: html `Review before anything is downloaded. Clones into ${mono(shortPathOf(record.target))} and adds it as a project. Large-file (LFS) objects aren’t downloaded.`,
|
|
84
|
+
acts: approveForm({ action: "/projects/onboard-confirm", kind, facts: factsNow, label: "Clone and add", form: { hidden: { nonce } }, before: bigOk }),
|
|
85
|
+
terms: `${approvalTerms(kind, factsNow)} The review expires in 10 minutes.`,
|
|
86
|
+
err: problem === null ? null : refused(problem),
|
|
87
|
+
attrs: { "aria-label": `Clone ${record.nameWithOwner}` },
|
|
88
|
+
})}${facts([["Repository", mono(`github.com/${record.nameWithOwner}`)], ["Size", size], ["Into", mono(shortPathOf(record.target))]])}`;
|
|
89
|
+
}
|
|
90
|
+
/** The projects list: the React view draws it from `workspace.view`; the body is the same page for a browser
|
|
91
|
+
* without the workspace script (and what the sensitivity check reads). */
|
|
92
|
+
export function projectsPage(chrome, data) {
|
|
93
|
+
const choosing = data.returnTo.startsWith("/tasks/new");
|
|
94
|
+
const names = new Map();
|
|
95
|
+
for (const name of [...data.recent.map(one => one.name), ...data.candidates.map(projectName)])
|
|
96
|
+
names.set(name, (names.get(name) ?? 0) + 1);
|
|
97
|
+
const rowOf = (path, name, openedAt) => {
|
|
98
|
+
const peek = data.peeks[path] ?? null;
|
|
99
|
+
return {
|
|
100
|
+
name, path, shortPath: shortPathOf(path), href: projectHref(name, path, (names.get(name) ?? 0) > 1),
|
|
101
|
+
open: data.open !== null && data.open === path, openedAt,
|
|
102
|
+
status: peek === null ? null : peek.waiting > 0 ? "needs" : peek.running > 0 ? "running" : null,
|
|
103
|
+
summary: peekWords(peek),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const recent = data.recent.map(one => rowOf(one.path, one.name, one.lastOpenedAt));
|
|
107
|
+
const available = data.candidates.map(path => rowOf(path, projectName(path), null));
|
|
108
|
+
const linkProblem = data.problemAt === "link" ? data.problem : null;
|
|
109
|
+
const review = cloneReviewHtml(data.onboard, data.problemAt === "clone" ? data.problem : null);
|
|
110
|
+
const reviewHasProblem = review !== null && data.problemAt === "clone";
|
|
111
|
+
const said = data.problemAt === "said" && data.problem !== null ? sentence(data.problem) : null;
|
|
112
|
+
const topProblem = data.problem === null || said !== null || linkProblem !== null || reviewHasProblem ? null : data.problemAt === "clone" ? sentence(data.problem) : refused(data.problem);
|
|
113
|
+
const addHtml = addWaysHtml({ browsable: data.browsable, onboard: data.onboard, returnTo: data.returnTo, linkProblem });
|
|
114
|
+
// Without the workspace script: the same rows, the add ways as a section.
|
|
115
|
+
const rowHtml = (one) => row({
|
|
116
|
+
href: one.href, chip: one.status === null ? html `` : status(one.status), title: one.name, wait: one.status === "needs",
|
|
117
|
+
desc: mono(one.shortPath), meta: html `<span>${one.summary ?? "Not scanned"}</span>${one.open ? html `<span>Open now</span>` : ""}`,
|
|
118
|
+
action: one.open && !choosing ? btn("New task", { sm: true, href: "/tasks/new" })
|
|
119
|
+
: postForm("/projects/open", btn(choosing ? "Choose" : "Open", { sm: true, type: "submit" }), { returnTo: data.returnTo, hidden: { path: one.path } }),
|
|
120
|
+
});
|
|
121
|
+
const title = choosing ? "New task" : "Projects";
|
|
122
|
+
const body = joinHtml([
|
|
123
|
+
pageHeader(title, { ...(choosing ? { ctx: "Choose the project it belongs to" } : {}) }),
|
|
124
|
+
topProblem === null ? null : notice(topProblem),
|
|
125
|
+
said === null ? null : infoLine(said),
|
|
126
|
+
review,
|
|
127
|
+
recent.length > 0 ? html `${group("Projects", count(recent.length))}${recent.map(rowHtml)}` : null,
|
|
128
|
+
available.length > 0 ? html `${group("Available", count(available.length))}${available.map(rowHtml)}` : null,
|
|
129
|
+
recent.length === 0 && available.length === 0 ? empty({ title: "No projects yet.", text: "A project is a git repository your agents work in." }) : null,
|
|
130
|
+
html `<section class="k-doc" id="add-project" aria-label="Add a project">${addHtml}</section>`,
|
|
131
|
+
], "\n");
|
|
132
|
+
return screen(title, body, { chrome, workspace: { view: {
|
|
133
|
+
kind: "projects", choosing, problem: topProblem, said, returnTo: data.returnTo, recent, available,
|
|
134
|
+
add: { html: htmlString(addHtml), review: review === null ? null : htmlString(review), openOnLoad: linkProblem !== null },
|
|
135
|
+
} } });
|
|
136
|
+
}
|
|
137
|
+
function activityHtml(report) {
|
|
138
|
+
if (report === null)
|
|
139
|
+
return notice("Activity is unavailable right now. Nothing changed. Reload the page to try again.");
|
|
140
|
+
const familyLine = (one) => row({
|
|
141
|
+
href: one.href, chip: html ``, title: one.title,
|
|
142
|
+
desc: `${one.actor === null ? one.state === "ready" ? "Ready" : "History unavailable" : ACTIVITY_ACTORS[one.actor]} · ${one.kind} · Recorded checks: ${one.checks}`,
|
|
143
|
+
meta: one.state === "completed" ? html `<span class="k-num">${activityAge(one.elapsedMs)}</span>` : "",
|
|
144
|
+
});
|
|
145
|
+
return html `${note(activityScope(report))}${report.truncated ? note(activityLimit(report)) : ""}${activityMetrics(report).map(metric => disclose(`${metric.label} · ${metric.value}`, html `${note(metric.detail)}${metric.rows.length === 0 ? note("No tasks in this group.") : metric.rows.map(familyLine)}`))}`;
|
|
146
|
+
}
|
|
147
|
+
/** One project's reading page. Reads only: Open project posts to /projects/select, which switches this session. */
|
|
148
|
+
export function projectPage(chrome, data) {
|
|
149
|
+
const opened = data.open ? "Open now" : data.openedAt === null ? "Not opened here yet" : `Opened ${relativeAge(data.openedAt, data.now)}`;
|
|
150
|
+
// The one primary: in the header at a desk, in the dock on a phone (the dock's button submits the header's form).
|
|
151
|
+
const primary = data.open
|
|
152
|
+
? btn("New task", { variant: "primary", lead: "plus", href: "/tasks/new", className: "k-desk-only" })
|
|
153
|
+
: postForm("/projects/select", btn("Open project", { variant: "primary", type: "submit", className: "k-desk-only" }), { returnTo: data.selfHref, hidden: { path: data.path }, attrs: { id: "project-open" } });
|
|
154
|
+
const docked = data.open
|
|
155
|
+
? btn("New task", { variant: "primary", lead: "plus", href: "/tasks/new" })
|
|
156
|
+
: btn("Open project", { variant: "primary", type: "submit", attrs: { form: "project-open" } });
|
|
157
|
+
const peek = data.peek;
|
|
158
|
+
const countWords = (n, words, href) => data.open && n > 0 ? html `<a class="k-link" href="${href}">${String(n)} ${words}</a>` : html `<span>${String(n)} ${words}</span>`;
|
|
159
|
+
const counts = peek === null
|
|
160
|
+
? note("Not scanned yet.")
|
|
161
|
+
: html `<p>${joinHtml([
|
|
162
|
+
countWords(peek.waiting, peek.waiting === 1 ? "needs you" : "need you", "/work"),
|
|
163
|
+
countWords(peek.running, "running", "/work"),
|
|
164
|
+
countWords(peek.queued, "queued", "/board?view=order"),
|
|
165
|
+
countWords(peek.doneRecently, "built today", "/done"),
|
|
166
|
+
], " · ")}</p>`;
|
|
167
|
+
const tasks = data.tasks.length === 0
|
|
168
|
+
? note(`Nothing needs you or is running in ${data.name}.`)
|
|
169
|
+
: html `${data.tasks.map(task => row({
|
|
170
|
+
href: task.href, chip: task.needs ? status("needs") : status("running"), title: task.title, desc: task.reason, wait: task.needs,
|
|
171
|
+
meta: task.since === null ? "" : html `<span class="k-num">${relativeAge(task.since, data.now)}</span>`,
|
|
172
|
+
}))}${data.moreTasks > 0 ? note(data.open ? html `<a class="k-link" href="/work">${data.moreTasks} more in Tasks</a>` : `${data.moreTasks} more`) : ""}`;
|
|
173
|
+
const setup = facts([
|
|
174
|
+
["Folder", mono(data.path)],
|
|
175
|
+
...(data.checks === null ? [] : [["Checks", html `<a class="k-link" href="${data.checks.href}">${data.checks.level}</a>`]]),
|
|
176
|
+
...(data.pullRequests === null ? [] : [["Pull requests", html `<a class="k-link" href="${data.pullRequests.href}">${data.pullRequests.on ? "On" : "Off"}</a>`]]),
|
|
177
|
+
["Knowledge", html `<a class="k-link" href="${data.knowledgeHref}">Open knowledge</a>`],
|
|
178
|
+
]);
|
|
179
|
+
const body = joinHtml([
|
|
180
|
+
phoneHeader(data.name, { back: "/projects" }),
|
|
181
|
+
doc(docHeader({ back: ["/projects", "Projects"], title: data.name, meta: html `${mono(shortPathOf(data.path))} · ${opened}`, right: primary, same: true }), html `<section class="k-sec" aria-labelledby="p-now"><h3 id="p-now">Now</h3>${counts}</section>`, html `<section class="k-sec" aria-labelledby="p-tasks"><h3 id="p-tasks">Tasks</h3>${tasks}</section>`, html `<section class="k-sec" aria-labelledby="p-setup"><h3 id="p-setup">Setup</h3>${setup}${data.settingsHref === null ? "" : note(html `Network access, the sandbox and removing the project are in <a class="k-link" href="${data.settingsHref}">Settings → Project</a>.`)}</section>`, data.activity === null ? "" : html `<section class="k-sec" aria-labelledby="p-activity"><h3 id="p-activity">Activity</h3>${activityHtml(data.activity.report)}</section>`),
|
|
182
|
+
dock(docked),
|
|
183
|
+
], "\n");
|
|
184
|
+
return screen(data.name, body, { chrome });
|
|
185
|
+
}
|
|
186
|
+
// ---- folder browse -----------------------------------------------------------------------------------------------
|
|
187
|
+
export function browsePage(chrome, data) {
|
|
188
|
+
const browseHref = (path) => `/projects/browse?at=${encodeURIComponent(path)}`;
|
|
189
|
+
const here = data.at === data.root ? shortPathOf(data.root) : `${shortPathOf(data.root)}${data.at.slice(data.root.length)}`;
|
|
190
|
+
const openForm = (path, label) => postForm("/projects/open", btn(label, { sm: true, type: "submit" }), { hidden: { path } });
|
|
191
|
+
const rows = data.entries.length === 0
|
|
192
|
+
? note("This folder has no folders inside.")
|
|
193
|
+
: html `${data.entries.map(one => row({
|
|
194
|
+
href: browseHref(one.path), chip: html ``, title: one.name,
|
|
195
|
+
desc: data.registered.has(one.path) ? "Project" : one.git ? "Git repository" : "Folder",
|
|
196
|
+
action: one.git ? openForm(one.path, data.registered.has(one.path) ? "Open" : "Add and open") : btn("Open folder", { sm: true, variant: "ghost", href: browseHref(one.path) }),
|
|
197
|
+
}))}`;
|
|
198
|
+
const body = joinHtml([
|
|
199
|
+
phoneHeader("Choose a folder", { back: "/projects" }),
|
|
200
|
+
doc(docHeader({ back: ["/projects", "Projects"], title: "Choose a folder", meta: mono(here), same: true,
|
|
201
|
+
...(data.parent === null ? {} : { right: btn("Up one folder", { sm: true, variant: "ghost", lead: "back", href: browseHref(data.parent) }) }) }), data.roots.length > 1 ? linkTabs("Projects folders", data.root, data.roots.map(one => ({ key: one, label: projectName(one), href: browseHref(one) }))) : "", html `<div>${rows}</div>`, note(html `Git repositories are listed first and can be added. Showing folders under ${mono(shortPathOf(data.root))}.`)),
|
|
202
|
+
], "\n");
|
|
203
|
+
return screen("Choose a folder", body, { chrome });
|
|
204
|
+
}
|
|
205
|
+
// ---- the GitHub listing --------------------------------------------------------------------------------------------
|
|
206
|
+
/** The account's repositories with ONE honest action each: open (already a project here), add and open (a clone
|
|
207
|
+
* under a projects folder), or the existing preview-and-password clone ceremony. */
|
|
208
|
+
export function githubReposPage(chrome, data) {
|
|
209
|
+
const openForm = (path, label) => postForm("/projects/open", btn(label, { sm: true, type: "submit" }), { hidden: { path } });
|
|
210
|
+
const cloneForm = (nameWithOwner) => postForm("/projects/onboard-preview", btn("Clone here", { sm: true, type: "submit" }), { hidden: { repo: nameWithOwner, root: "0" } });
|
|
211
|
+
const list = (label, primary) => postForm("/projects/github", btn(label, { sm: true, variant: primary ? "primary" : "outline", type: "submit" }));
|
|
212
|
+
const listed = data.listed;
|
|
213
|
+
const instead = btn("Choose a folder instead", { sm: true, variant: "ghost", href: "/projects/browse" });
|
|
214
|
+
const rows = listed === null
|
|
215
|
+
? empty({ title: "Your repositories aren’t listed yet.", text: "Toolroll asks the GitHub CLI signed in on this computer.", action: acts(list("List repositories", true)) })
|
|
216
|
+
: !listed.ok
|
|
217
|
+
? listed.reason === "gh-missing"
|
|
218
|
+
? empty({ title: "The GitHub CLI isn’t installed on this computer.", text: "Install gh on the computer running Toolroll (brew install gh), sign in with gh auth login, then check again.", action: acts(list("Check again", false), instead) })
|
|
219
|
+
: listed.reason === "gh-auth"
|
|
220
|
+
? empty({ title: "The GitHub CLI isn’t signed in.", text: "In a terminal on the computer running Toolroll, run gh auth login, then check again.", action: acts(list("Check again", false), instead) })
|
|
221
|
+
: html `${notice(`${sentence(listed.message)}. Nothing changed.`)}${acts(list("Try again", false))}`
|
|
222
|
+
: listed.repos.length === 0
|
|
223
|
+
? empty({ title: "No repositories to list.", text: "The GitHub account signed in on this computer has none." })
|
|
224
|
+
: html `${group("Repositories", count(listed.repos.length))}${listed.repos.map(repo => {
|
|
225
|
+
const localPath = data.local.get(repo.nameWithOwner.toLowerCase()) ?? null;
|
|
226
|
+
const action = localPath !== null && data.openProject === localPath
|
|
227
|
+
? btn("Open now", { sm: true, variant: "ghost", href: projectHref(projectName(localPath), localPath, false) })
|
|
228
|
+
: localPath !== null
|
|
229
|
+
? openForm(localPath, data.registered.has(localPath) ? "Open" : "Add and open")
|
|
230
|
+
: data.cloneReady
|
|
231
|
+
? cloneForm(repo.nameWithOwner)
|
|
232
|
+
: html `<span class="k-muted">Choose a projects folder first</span>`;
|
|
233
|
+
const where = localPath === null
|
|
234
|
+
? `${repo.isPrivate ? "Private" : "Public"}${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? ` · updated ${relativeAge(repo.updatedAt, data.now)}` : ""}`
|
|
235
|
+
: data.registered.has(localPath) ? html `Project ${mono(projectName(localPath))}` : html `Already cloned at ${mono(shortPathOf(localPath))}`;
|
|
236
|
+
return row({ href: `https://github.com/${repo.nameWithOwner}`, chip: html ``, title: repo.nameWithOwner,
|
|
237
|
+
desc: repo.description === "" ? where : html `${repo.description} · ${where}`, action });
|
|
238
|
+
})}`;
|
|
239
|
+
const body = joinHtml([
|
|
240
|
+
phoneHeader("Add from GitHub", { back: "/projects" }),
|
|
241
|
+
doc(docHeader({ back: ["/projects", "Projects"], title: "Add from GitHub", meta: "Repositories the GitHub CLI on this computer can see.", same: true,
|
|
242
|
+
...(listed === null ? {} : { right: list("Refresh list", false) }) }), data.localUnanswered === true ? notice("Your projects folder didn’t answer, so copies already on this computer aren’t matched yet. Try again in a moment.") : "", html `<div>${rows}</div>`),
|
|
243
|
+
], "\n");
|
|
244
|
+
return screen("Add from GitHub", body, { chrome });
|
|
245
|
+
}
|
|
246
|
+
/** A project name nobody can see answers the same as one that doesn't exist. */
|
|
247
|
+
export const unknownProject = (name) => ({ title: `There is no project called ${name} here.`, text: "It may have been removed, or the link is for another Toolroll." });
|