@webappwiz/arbor 0.0.30 → 0.0.32
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/README.md +2 -2
- package/add.d.ts +23 -0
- package/age.d.ts +2 -0
- package/arbor.d.ts +11 -0
- package/attachments.d.ts +36 -0
- package/claim.d.ts +11 -0
- package/config.d.ts +55 -0
- package/config.js +7 -0
- package/dev/assets.d.ts +11 -0
- package/dev.d.ts +48 -0
- package/escalate.d.ts +28 -0
- package/exit.d.ts +42 -0
- package/git.d.ts +82 -0
- package/index.d.ts +2 -0
- package/index.js +4763 -0
- package/journal.d.ts +29 -0
- package/list.d.ts +18 -0
- package/load-config.d.ts +15 -0
- package/log.d.ts +14 -0
- package/merge.d.ts +24 -0
- package/package.json +15 -32
- package/path.d.ts +13 -0
- package/plan.d.ts +83 -0
- package/remove.d.ts +19 -0
- package/repository.d.ts +25 -0
- package/retry.d.ts +17 -0
- package/shell.d.ts +17 -0
- package/show.d.ts +53 -0
- package/snapshot.d.ts +36 -0
- package/table.d.ts +5 -0
- package/todo.d.ts +202 -0
- package/todos.d.ts +75 -0
- package/wait.d.ts +22 -0
- package/worktree-service.d.ts +51 -0
- package/worktree.d.ts +95 -0
- package/AGENTS.md +0 -7
- package/add.test.ts +0 -125
- package/add.ts +0 -192
- package/age.ts +0 -11
- package/arbor.test.ts +0 -145
- package/arbor.ts +0 -429
- package/assets.d.ts +0 -9
- package/attachments.ts +0 -87
- package/build.ts +0 -98
- package/claim.test.ts +0 -89
- package/claim.ts +0 -73
- package/components.json +0 -21
- package/config.ts +0 -58
- package/dev/api.ts +0 -120
- package/dev/app.test.tsx +0 -645
- package/dev/app.tsx +0 -80
- package/dev/assets.ts +0 -16
- package/dev/build/main.txt +0 -68
- package/dev/build/shell.txt +0 -21
- package/dev/build/styles.txt +0 -2263
- package/dev/components/ui/badge.tsx +0 -51
- package/dev/components/ui/button.tsx +0 -57
- package/dev/components/ui/dialog.tsx +0 -154
- package/dev/components/ui/empty.tsx +0 -100
- package/dev/components/ui/input-group.tsx +0 -155
- package/dev/components/ui/input.tsx +0 -19
- package/dev/components/ui/progress.tsx +0 -80
- package/dev/components/ui/textarea.tsx +0 -17
- package/dev/components/ui/toast.tsx +0 -227
- package/dev/feed.ts +0 -69
- package/dev/files.tsx +0 -191
- package/dev/index.html +0 -21
- package/dev/lib/utils.ts +0 -7
- package/dev/main.tsx +0 -22
- package/dev/markdown.test.tsx +0 -133
- package/dev/markdown.tsx +0 -124
- package/dev/mentions.tsx +0 -319
- package/dev/shadcn.css +0 -649
- package/dev/styles.css +0 -107
- package/dev/tags.tsx +0 -44
- package/dev/tasks.tsx +0 -151
- package/dev/todos.tsx +0 -759
- package/dev.test.ts +0 -439
- package/dev.ts +0 -386
- package/e2e.test.ts +0 -256
- package/escalate.test.ts +0 -76
- package/escalate.ts +0 -112
- package/exit.test.ts +0 -71
- package/exit.ts +0 -74
- package/git.ts +0 -294
- package/index.ts +0 -9
- package/journal.ts +0 -89
- package/list.test.ts +0 -82
- package/list.ts +0 -137
- package/load-config.test.ts +0 -121
- package/load-config.ts +0 -62
- package/log.test.ts +0 -65
- package/log.ts +0 -40
- package/merge.test.ts +0 -332
- package/merge.ts +0 -283
- package/path.test.ts +0 -35
- package/path.ts +0 -38
- package/plan.test.ts +0 -318
- package/plan.ts +0 -302
- package/progress.test.ts +0 -54
- package/progress.ts +0 -31
- package/remove.test.ts +0 -68
- package/remove.ts +0 -88
- package/repository.ts +0 -83
- package/retry.test.ts +0 -43
- package/retry.ts +0 -48
- package/shell.ts +0 -44
- package/show.test.ts +0 -91
- package/show.ts +0 -151
- package/snapshot.test.ts +0 -33
- package/snapshot.ts +0 -84
- package/table.ts +0 -20
- package/testing.ts +0 -229
- package/todo.test.ts +0 -490
- package/todo.ts +0 -834
- package/wait.test.ts +0 -61
- package/wait.ts +0 -77
- package/worktree-service.test.ts +0 -158
- package/worktree-service.ts +0 -193
- package/worktree.ts +0 -239
package/build.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { dirname, resolve } from "node:path";
|
|
2
|
-
import { Scanner } from "@tailwindcss/oxide";
|
|
3
|
-
import { compile } from "tailwindcss";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Builds the `arbor dev` page: the React app as one script, and the stylesheet
|
|
7
|
-
* with Tailwind compiled. `dev/assets.ts` imports both as text, so they end up
|
|
8
|
-
* inside the bundle a release publishes and the server hands them straight to
|
|
9
|
-
* the browser.
|
|
10
|
-
*
|
|
11
|
-
* This runs here rather than on the first page load of every `arbor dev`, which
|
|
12
|
-
* is what keeps React and Tailwind out of the published dependencies: a person
|
|
13
|
-
* installing the CLI has no use for a toolchain that already ran. It is also
|
|
14
|
-
* the only place the compile can read our own configuration, since `arbor dev`
|
|
15
|
-
* runs inside whatever repository the user is in.
|
|
16
|
-
*
|
|
17
|
-
* `bun run build` in this package, and `bun install` at the workspace root,
|
|
18
|
-
* which is what puts the output there for a fresh checkout.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const here = import.meta.dirname;
|
|
22
|
-
const out = `${here}/dev/build`;
|
|
23
|
-
|
|
24
|
-
/** The browser script, as one file with React and the page's modules in it. */
|
|
25
|
-
async function script(): Promise<string> {
|
|
26
|
-
const built = await Bun.build({
|
|
27
|
-
entrypoints: [`${here}/dev/main.tsx`],
|
|
28
|
-
target: "browser",
|
|
29
|
-
// React's production build: the development one is several times the
|
|
30
|
-
// size, slower, and asks the person on the page to install DevTools.
|
|
31
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
32
|
-
minify: true,
|
|
33
|
-
// Left to throw, a failure arrives as an AggregateError that says only
|
|
34
|
-
// "Bundle failed": which import went missing is in the logs, and whoever
|
|
35
|
-
// is reading the terminal needs that to know it is their install.
|
|
36
|
-
throw: false,
|
|
37
|
-
});
|
|
38
|
-
const [output] = built.outputs;
|
|
39
|
-
if (output === undefined) {
|
|
40
|
-
throw new Error(built.logs.map(String).join("\n"));
|
|
41
|
-
}
|
|
42
|
-
return output.text();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Where a package's stylesheet is: the `style` entry of its `exports`, which is
|
|
47
|
-
* where CSS packages like tw-animate-css put it. Bun's resolver does not
|
|
48
|
-
* follow that condition, so the package is found by walking up to it.
|
|
49
|
-
*/
|
|
50
|
-
async function stylesheet(name: string): Promise<string> {
|
|
51
|
-
for (let dir = here; ; dir = dirname(dir)) {
|
|
52
|
-
const root = `${dir}/node_modules/${name}`;
|
|
53
|
-
const manifest = Bun.file(`${root}/package.json`);
|
|
54
|
-
if (await manifest.exists()) {
|
|
55
|
-
const { exports, style } = await manifest.json();
|
|
56
|
-
const entry = exports?.["."]?.style ?? style ?? "index.css";
|
|
57
|
-
return resolve(root, entry);
|
|
58
|
-
}
|
|
59
|
-
if (dir === dirname(dir)) {
|
|
60
|
-
throw new Error(`no stylesheet package '${name}' above ${here}`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The stylesheet, with only the utilities the page actually uses in it. */
|
|
66
|
-
async function styles(): Promise<string> {
|
|
67
|
-
const base = `${here}/dev`;
|
|
68
|
-
const compiler = await compile(await Bun.file(`${base}/styles.css`).text(), {
|
|
69
|
-
base,
|
|
70
|
-
loadStylesheet: async (id: string, from: string) => {
|
|
71
|
-
// A stylesheet is asked for by bare package name (`@import "tailwindcss"`),
|
|
72
|
-
// then the parts of it by relative path. Only the bare names need module
|
|
73
|
-
// resolution; the rest are paths off a base already inside the package.
|
|
74
|
-
const path = id.startsWith(".")
|
|
75
|
-
? resolve(from, id)
|
|
76
|
-
: await stylesheet(id);
|
|
77
|
-
return {
|
|
78
|
-
path,
|
|
79
|
-
base: dirname(path),
|
|
80
|
-
content: await Bun.file(path).text(),
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
// The `@source` lines in styles.css say which files to read class names out
|
|
85
|
-
// of. Tailwind emits only the utilities it finds there.
|
|
86
|
-
return compiler.build(new Scanner({ sources: compiler.sources }).scan());
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// All three as `.txt`: what they hold is a blob the server hands to a browser,
|
|
90
|
-
// and the extensions they were authored under mean something else to a
|
|
91
|
-
// compiler. `.js` would have tsc typecheck a bundle that already compiled, and
|
|
92
|
-
// Bun's own types read `.html` and `.css` imports as something other than text.
|
|
93
|
-
await Bun.write(
|
|
94
|
-
`${out}/shell.txt`,
|
|
95
|
-
await Bun.file(`${here}/dev/index.html`).text(),
|
|
96
|
-
);
|
|
97
|
-
await Bun.write(`${out}/main.txt`, await script());
|
|
98
|
-
await Bun.write(`${out}/styles.txt`, await styles());
|
package/claim.test.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { add } from "./add";
|
|
3
|
-
import { claim } from "./claim";
|
|
4
|
-
import { LIVE_PID, Testing } from "./testing";
|
|
5
|
-
|
|
6
|
-
describe("claim", () => {
|
|
7
|
-
let deps: Testing;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
deps = await Testing.open();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => deps.disposeAsync());
|
|
14
|
-
|
|
15
|
-
it("refuses a live lease and takes a cold one", async () => {
|
|
16
|
-
await add(deps, "alpha");
|
|
17
|
-
await (await deps.service.find("alpha")).save({
|
|
18
|
-
lease: {
|
|
19
|
-
pid: LIVE_PID,
|
|
20
|
-
hostname: deps.ps.hostname,
|
|
21
|
-
heartbeatAt: new Date().toISOString(),
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
await expect(claim(deps, "alpha")).toBail("lease_held");
|
|
26
|
-
|
|
27
|
-
// The same lease, gone cold because the heartbeat aged out.
|
|
28
|
-
await (await deps.service.find("alpha")).save({
|
|
29
|
-
lease: {
|
|
30
|
-
pid: LIVE_PID,
|
|
31
|
-
hostname: deps.ps.hostname,
|
|
32
|
-
heartbeatAt: new Date(Date.now() - 120_000).toISOString(),
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
await claim(deps, "alpha");
|
|
36
|
-
|
|
37
|
-
expect((await deps.service.find("alpha")).state?.lease?.pid).toBe(
|
|
38
|
-
deps.ps.pid,
|
|
39
|
-
);
|
|
40
|
-
expect(deps.out()).toContain("claimed alpha");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("rebuilds a missing record and reports an interrupted rebase", async () => {
|
|
44
|
-
await add(deps, "alpha");
|
|
45
|
-
const worktree = (await deps.service.find("alpha")).path;
|
|
46
|
-
|
|
47
|
-
await deps.commit(deps.root, "README.md", "trunk side\n", "trunk");
|
|
48
|
-
await deps.commit(worktree, "README.md", "task side\n", "task");
|
|
49
|
-
await deps.ps.spawnCapture(["git", "-C", worktree, "rebase", "main"]);
|
|
50
|
-
await deps.fs.rm(deps.service.recordPath("alpha"));
|
|
51
|
-
|
|
52
|
-
await claim(deps, "alpha");
|
|
53
|
-
|
|
54
|
-
expect(deps.out()).toContain("state file rebuilt from disk");
|
|
55
|
-
expect(deps.out()).toContain("rebase in progress");
|
|
56
|
-
expect((await deps.service.find("alpha")).state).toMatchObject({
|
|
57
|
-
branch: "task/alpha",
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("reports a record whose worktree is gone as orphaned", async () => {
|
|
62
|
-
await add(deps, "alpha");
|
|
63
|
-
const worktree = (await deps.service.find("alpha")).path;
|
|
64
|
-
await deps.fs.rm(worktree, { recursive: true, force: true });
|
|
65
|
-
|
|
66
|
-
await expect(claim(deps, "alpha")).toBail("orphaned", {
|
|
67
|
-
message: "arbor remove alpha",
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("puts an escalated task back to working, keeping its budget", async () => {
|
|
72
|
-
await add(deps, "alpha");
|
|
73
|
-
await (await deps.service.find("alpha")).save({
|
|
74
|
-
status: "escalated",
|
|
75
|
-
lease: null,
|
|
76
|
-
mergeAttempts: 1,
|
|
77
|
-
escalations: [{ reason: "needs a look", at: new Date().toISOString() }],
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
await claim(deps, "alpha");
|
|
81
|
-
|
|
82
|
-
expect((await deps.service.find("alpha")).state).toMatchObject({
|
|
83
|
-
status: "working",
|
|
84
|
-
mergeAttempts: 1,
|
|
85
|
-
escalations: [{ reason: "needs a look" }],
|
|
86
|
-
});
|
|
87
|
-
expect(deps.out()).toContain("status: working (was escalated)");
|
|
88
|
-
});
|
|
89
|
-
});
|
package/claim.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import { fail } from "./exit";
|
|
3
|
-
import type { WorktreeService } from "./worktree-service";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The resume entry point: a fresh agent thread picking up existing work. An
|
|
7
|
-
* escalated task goes back to `working`, since someone is on it again; its
|
|
8
|
-
* merge budget stays as it was, which only `retry` refills.
|
|
9
|
-
*/
|
|
10
|
-
export async function claim(
|
|
11
|
-
{ service, log }: { service: WorktreeService; log: Logger },
|
|
12
|
-
task: string,
|
|
13
|
-
): Promise<void> {
|
|
14
|
-
const found = await service.find(task);
|
|
15
|
-
|
|
16
|
-
if (found.gone) {
|
|
17
|
-
fail("not_found", `no task '${task}': run \`arbor add ${task}\``, {
|
|
18
|
-
task,
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
if (found.status === "orphaned") {
|
|
22
|
-
fail(
|
|
23
|
-
"orphaned",
|
|
24
|
-
`state file for '${task}' has no worktree at ${found.path}: run \`arbor remove ${task}\``,
|
|
25
|
-
{ task, worktree: found.path },
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
if (found.status === "stray") {
|
|
29
|
-
fail(
|
|
30
|
-
"orphaned",
|
|
31
|
-
`branch ${found.branch} exists but has no worktree: run \`arbor remove ${task}\` and start over`,
|
|
32
|
-
{ task, branch: found.branch },
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
if (found.leaseHeldByOther) {
|
|
36
|
-
fail(
|
|
37
|
-
"lease_held",
|
|
38
|
-
`'${task}' is held by pid ${found.lease?.pid} on ${found.lease?.hostname} (heartbeat ${found.lease?.heartbeatAt}): another agent is driving this tree`,
|
|
39
|
-
{ task, lease: found.lease },
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// A worktree with no readable record is still work: `save` writes a fresh
|
|
44
|
-
// one rather than refusing the resume.
|
|
45
|
-
const rebuilt = found.state === null;
|
|
46
|
-
const escalated = found.status === "escalated";
|
|
47
|
-
const worktree = await found.take(escalated ? { status: "working" } : {});
|
|
48
|
-
|
|
49
|
-
const interrupted = await worktree.interruptedOps();
|
|
50
|
-
const changes = await worktree.uncommitted();
|
|
51
|
-
const lines = [
|
|
52
|
-
`${color.green("claimed")} ${worktree.task}`,
|
|
53
|
-
` worktree: ${worktree.path}`,
|
|
54
|
-
` branch: ${worktree.branch}`,
|
|
55
|
-
` status: ${worktree.status}${escalated ? " (was escalated)" : ""}`,
|
|
56
|
-
` attempts: ${worktree.mergeAttempts}`,
|
|
57
|
-
];
|
|
58
|
-
if (rebuilt) {
|
|
59
|
-
lines.push(` ${color.yellow("note: state file rebuilt from disk")}`);
|
|
60
|
-
}
|
|
61
|
-
if (interrupted.length > 0) {
|
|
62
|
-
lines.push(
|
|
63
|
-
` ${color.red(`interrupted git operation: ${interrupted.join(", ")}`)}`,
|
|
64
|
-
" resolve it before merging: you are standing in a half-finished operation",
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
lines.push(
|
|
68
|
-
changes.length === 0
|
|
69
|
-
? " uncommitted: none"
|
|
70
|
-
: ` uncommitted (${changes.length}):\n${changes.map((change) => ` ${change}`).join("\n")}`,
|
|
71
|
-
);
|
|
72
|
-
log.info(lines.join("\n"));
|
|
73
|
-
}
|
package/components.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
-
"style": "base-nova",
|
|
4
|
-
"rsc": false,
|
|
5
|
-
"tsx": true,
|
|
6
|
-
"tailwind": {
|
|
7
|
-
"config": "",
|
|
8
|
-
"css": "dev/styles.css",
|
|
9
|
-
"baseColor": "neutral",
|
|
10
|
-
"cssVariables": true,
|
|
11
|
-
"prefix": ""
|
|
12
|
-
},
|
|
13
|
-
"iconLibrary": "lucide",
|
|
14
|
-
"aliases": {
|
|
15
|
-
"components": "#dev/components",
|
|
16
|
-
"utils": "#dev/lib/utils.ts",
|
|
17
|
-
"ui": "#dev/components/ui",
|
|
18
|
-
"lib": "#dev/lib",
|
|
19
|
-
"hooks": "#dev/hooks"
|
|
20
|
-
}
|
|
21
|
-
}
|
package/config.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
export interface Config {
|
|
2
|
-
/**
|
|
3
|
-
* The integration branch name. `merge` rebases a task onto this branch and
|
|
4
|
-
* then fast-forwards it to the result; new worktrees start from it.
|
|
5
|
-
*/
|
|
6
|
-
trunk: string;
|
|
7
|
-
/** Directory holding one worktree per task, a sibling of the repo. */
|
|
8
|
-
worktreeRoot: string;
|
|
9
|
-
/** Command run by `add` in the new worktree, via `sh -c`. */
|
|
10
|
-
postCheckout: string | null;
|
|
11
|
-
/**
|
|
12
|
-
* Command run by `merge` after the rebase, before `preMerge`, via `sh -c`.
|
|
13
|
-
* A rebase can bring in a dependency the worktree has never installed, and
|
|
14
|
-
* whatever `preMerge` runs needs it.
|
|
15
|
-
*/
|
|
16
|
-
postRewrite: string | null;
|
|
17
|
-
/**
|
|
18
|
-
* Command run by `merge` after the rebase, via `sh -c`. The last gate before
|
|
19
|
-
* the branch lands: a nonzero exit rolls the branch back and leaves the base
|
|
20
|
-
* untouched. Tests are the obvious thing to put here, but arbor has no
|
|
21
|
-
* opinion; a repo with nothing to run leaves it null and merges on a green
|
|
22
|
-
* rebase alone.
|
|
23
|
-
*/
|
|
24
|
-
preMerge: string | null;
|
|
25
|
-
/**
|
|
26
|
-
* Command run by `merge` in the main tree after the base fast-forwards, via
|
|
27
|
-
* `sh -c`. The branch has already landed and the worktree is gone: a nonzero
|
|
28
|
-
* exit reports the failure but rolls nothing back.
|
|
29
|
-
*/
|
|
30
|
-
postMerge: string | null;
|
|
31
|
-
/** How long since its last heartbeat before a task's lease is up for grabs. */
|
|
32
|
-
leaseStalenessMs: number;
|
|
33
|
-
/** Failed `merge` attempts a task gets before it must escalate or be removed. */
|
|
34
|
-
mergeRetryCount: number;
|
|
35
|
-
/**
|
|
36
|
-
* How many removed task names to keep, so `remove` can say "already removed"
|
|
37
|
-
* rather than "never existed". A flat cap with no age policy: losing the
|
|
38
|
-
* oldest costs a nicer message and nothing else.
|
|
39
|
-
*/
|
|
40
|
-
removedCapacity: number;
|
|
41
|
-
/** How many entries `arbor log` keeps before the oldest fall off. */
|
|
42
|
-
logCapacity: number;
|
|
43
|
-
/**
|
|
44
|
-
* How long a todo can wait before `merge` stops recommending it and offers
|
|
45
|
-
* it for removal instead: work deferred that long has usually been done
|
|
46
|
-
* some other way, or stopped mattering.
|
|
47
|
-
*/
|
|
48
|
-
todoStalenessMs: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Identity, for the types. `export default defineConfig({ ... })` in
|
|
53
|
-
* `arbor.config.ts` gets the key names and their types checked, and completion
|
|
54
|
-
* while writing it; a bare object literal gets neither.
|
|
55
|
-
*/
|
|
56
|
-
export function defineConfig(config: Partial<Config>): Partial<Config> {
|
|
57
|
-
return config;
|
|
58
|
-
}
|
package/dev/api.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The page's writes: the same core functions as `arbor todo add`, `update`
|
|
3
|
-
* and `remove`.
|
|
4
|
-
* A refusal comes back as the CLI's own reason and message, thrown so the
|
|
5
|
-
* caller can show it.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export class ApiError extends Error {
|
|
9
|
-
constructor(
|
|
10
|
-
readonly reason: string,
|
|
11
|
-
message: string,
|
|
12
|
-
) {
|
|
13
|
-
super(message);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function send(path: string, init: RequestInit): Promise<unknown> {
|
|
18
|
-
const response = await fetch(path, { method: "POST", ...init });
|
|
19
|
-
const body = (await response.json().catch(() => ({}))) as {
|
|
20
|
-
reason?: string;
|
|
21
|
-
message?: string;
|
|
22
|
-
};
|
|
23
|
-
if (!response.ok) {
|
|
24
|
-
throw new ApiError(
|
|
25
|
-
body.reason ?? "error",
|
|
26
|
-
body.message ?? `${path}: ${response.status}`,
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return body;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A todo's files: new ones to add, and the stored ones to keep. */
|
|
33
|
-
export interface FilesForm {
|
|
34
|
-
files: File[];
|
|
35
|
-
/** Paths of files already stored to keep; the rest are dropped. */
|
|
36
|
-
keep: string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** What a todo says: a line, and whatever more there is to say. */
|
|
40
|
-
export interface TodoForm {
|
|
41
|
-
subject: string;
|
|
42
|
-
text: string;
|
|
43
|
-
/** Where it goes in the list, 1 at the top; absent leaves it be. */
|
|
44
|
-
position?: number;
|
|
45
|
-
/** Its tags from now on; absent leaves them be. */
|
|
46
|
-
tags?: string[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function addTodo(
|
|
50
|
-
{ subject, text, position }: TodoForm,
|
|
51
|
-
files: File[],
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const form = filesForm({ files, keep: [] });
|
|
54
|
-
todoForm(form, { subject, text, position });
|
|
55
|
-
await send("/api/todos", { body: form });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function updateTodo(
|
|
59
|
-
id: number,
|
|
60
|
-
{ subject, text, position, tags, files, keep }: FilesForm & TodoForm,
|
|
61
|
-
): Promise<void> {
|
|
62
|
-
const form = filesForm({ files, keep });
|
|
63
|
-
todoForm(form, { subject, text, position, tags });
|
|
64
|
-
await send(`/api/todos/${id}`, { method: "PATCH", body: form });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Puts a todo at `position`, 1 at the top, moving the rest around it. */
|
|
68
|
-
export async function moveTodo(id: number, position: number): Promise<void> {
|
|
69
|
-
await send(`/api/todos/${id}/position`, {
|
|
70
|
-
method: "PUT",
|
|
71
|
-
headers: { "content-type": "application/json" },
|
|
72
|
-
body: JSON.stringify({ position }),
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function removeTodo(id: number): Promise<void> {
|
|
77
|
-
await send(`/api/todos/${id}`, { method: "DELETE" });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Where the page loads a file a todo holds. */
|
|
81
|
-
export function fileUrl(path: string): string {
|
|
82
|
-
return `/api/file?${new URLSearchParams({ path })}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function todoForm(
|
|
86
|
-
form: FormData,
|
|
87
|
-
{ subject, text, position, tags }: TodoForm,
|
|
88
|
-
): void {
|
|
89
|
-
form.set("subject", subject);
|
|
90
|
-
form.set("text", text);
|
|
91
|
-
if (position !== undefined) {
|
|
92
|
-
form.set("position", String(position));
|
|
93
|
-
}
|
|
94
|
-
if (tags !== undefined) {
|
|
95
|
-
form.set("tags", tags.join(","));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function filesForm({ files, keep }: FilesForm): FormData {
|
|
100
|
-
const form = new FormData();
|
|
101
|
-
for (const file of files) {
|
|
102
|
-
form.append("file", file, file.name);
|
|
103
|
-
}
|
|
104
|
-
for (const path of keep) {
|
|
105
|
-
form.append("keep", path);
|
|
106
|
-
}
|
|
107
|
-
return form;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The files and directories (ending in `/`) a task's tree holds, or the main
|
|
112
|
-
* tree's for none: what `@` offers.
|
|
113
|
-
*/
|
|
114
|
-
export async function paths(task = ""): Promise<string[]> {
|
|
115
|
-
const response = await fetch(`/api/paths?${new URLSearchParams({ task })}`);
|
|
116
|
-
if (!response.ok) {
|
|
117
|
-
throw new ApiError("error", `/api/paths: ${response.status}`);
|
|
118
|
-
}
|
|
119
|
-
return (await response.json()) as string[];
|
|
120
|
-
}
|