feat: the approval dialog and its per-answer ledger
The wording lives beside the dialog rather than beside the paths, which is what keeps files.ts free of UI and lets every refusal path be tested without a launcher. Vicinae's rememberUserChoice is declared in the API but not implemented in the server, so the ledger is the only memory and it lasts one answer. A dialog that cannot be shown is a refusal, not consent.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApprovalLedger, describeApproval, type FileApproval } from "../lib/approval";
|
||||
import type { ApprovalRequest } from "../lib/files";
|
||||
|
||||
const request = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||
operation: "overwrite",
|
||||
path: "/Users/me/Downloads/report.md",
|
||||
display: "~/Downloads/report.md",
|
||||
subject: "report.md",
|
||||
details: ["Replaces 14 bytes, last changed 3 days ago."],
|
||||
irreversible: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("describeApproval", () => {
|
||||
it("names the operation and the file", () => {
|
||||
expect(describeApproval(request()).title).toBe("Overwrite report.md?");
|
||||
expect(describeApproval(request({ operation: "create" })).title).toBe("Create report.md?");
|
||||
expect(describeApproval(request({ operation: "edit" })).title).toBe("Edit report.md?");
|
||||
});
|
||||
|
||||
it("shows the concrete path and every detail line", () => {
|
||||
const approval = describeApproval(request());
|
||||
expect(approval.body).toContain("~/Downloads/report.md");
|
||||
expect(approval.body).toContain("Replaces 14 bytes");
|
||||
});
|
||||
|
||||
it("puts the path first, because it is the thing being approved", () => {
|
||||
expect(describeApproval(request()).body.split("\n")[0]).toBe("~/Downloads/report.md");
|
||||
});
|
||||
|
||||
it("marks a delete as destructive, and says it cannot be undone", () => {
|
||||
const approval = describeApproval(
|
||||
request({ operation: "delete", details: ["This cannot be undone."], irreversible: true }),
|
||||
);
|
||||
expect(approval.destructive).toBe(true);
|
||||
expect(approval.body).toContain("cannot be undone");
|
||||
});
|
||||
|
||||
it("names both ends of a move", () => {
|
||||
const approval = describeApproval(
|
||||
request({
|
||||
operation: "move",
|
||||
display: "~/a.md to ~/Documents/b.md",
|
||||
subject: "a.md to b.md",
|
||||
}),
|
||||
);
|
||||
expect(approval.title).toBe("Move a.md to b.md?");
|
||||
expect(approval.body).toContain("~/a.md to ~/Documents/b.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createApprovalLedger", () => {
|
||||
it("asks once for a path and remembers the answer", async () => {
|
||||
const asked: FileApproval[] = [];
|
||||
const ledger = createApprovalLedger(async (approval) => {
|
||||
asked.push(approval);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await ledger.approve(request())).toBe(true);
|
||||
expect(await ledger.approve(request())).toBe(true);
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows the dialog the same words the ledger keyed on", async () => {
|
||||
// A ledger that approved one thing while the dialog described another is the
|
||||
// failure this pair exists to make impossible.
|
||||
const asked: FileApproval[] = [];
|
||||
const ledger = createApprovalLedger(async (approval) => {
|
||||
asked.push(approval);
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request({ operation: "create", subject: "notes.md" }));
|
||||
expect(asked[0].title).toBe("Create notes.md?");
|
||||
});
|
||||
|
||||
it("remembers nothing when the user declines, so the next attempt asks", async () => {
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("treats two spellings of one path as one path", async () => {
|
||||
// The ledger keys on the resolved path, so an edit approved for
|
||||
// ~/Downloads/report.md also covers /Users/me/Downloads/report.md.
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request());
|
||||
await ledger.approve(request({ display: "/Users/me/Downloads/report.md" }));
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("asks every time for a delete or a move, even after an approval for the same path", async () => {
|
||||
let calls = 0;
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
calls++;
|
||||
return true;
|
||||
});
|
||||
|
||||
await ledger.approve(request());
|
||||
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||
await ledger.approve(request({ operation: "delete", irreversible: true }));
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses when the dialog itself fails, rather than assuming consent", async () => {
|
||||
// Fail closed: an unshowable dialog is not permission.
|
||||
const ledger = createApprovalLedger(async () => {
|
||||
throw new Error("no such widget");
|
||||
});
|
||||
expect(await ledger.approve(request())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Alert, confirmAlert } from "@vicinae/api";
|
||||
import type { ApprovalRequest, ApproveFn, FileOperation } from "./files";
|
||||
|
||||
/**
|
||||
* The approval step, and the only file in this feature that imports Vicinae.
|
||||
*
|
||||
* Kept apart from `files.ts` on purpose: the policy there is a value and this is a
|
||||
* dialog, so the boundary can be tested without a launcher and the wording can be
|
||||
* changed without touching a path rule.
|
||||
*/
|
||||
|
||||
export interface FileApproval {
|
||||
title: string;
|
||||
/** Every line the user needs: the path first, then what changes. */
|
||||
body: string;
|
||||
/** A delete or a move, which the dialog styles differently. */
|
||||
destructive: boolean;
|
||||
}
|
||||
|
||||
const VERBS: Record<FileOperation, string> = {
|
||||
create: "Create",
|
||||
overwrite: "Overwrite",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
move: "Move",
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a request into the words the user reads.
|
||||
*
|
||||
* The path is the second thing in the body and is never omitted: it is the thing
|
||||
* being approved, and a dialog that said "Lam wants to write a file" would leave the
|
||||
* user with nothing to judge.
|
||||
*/
|
||||
export function describeApproval(request: ApprovalRequest): FileApproval {
|
||||
return {
|
||||
title: `${VERBS[request.operation]} ${request.subject}?`,
|
||||
body: [request.display, ...request.details].join("\n"),
|
||||
destructive: request.irreversible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger: one approval per resolved path, per answer, except for the
|
||||
* irreversible operations, which ask every time.
|
||||
*
|
||||
* Per answer rather than per session because a session can run for hours, and because
|
||||
* "yes, edit that file while we work on it" is not consent to whatever Lam decides to
|
||||
* do with it twenty minutes later.
|
||||
*
|
||||
* Keyed on the resolved path, so `~/Downloads/x.md` and `/Users/me/Downloads/x.md`
|
||||
* are one entry: they are one file, and keying on the written form would let a second
|
||||
* spelling of the same path slip past an approval the user already gave for it.
|
||||
*/
|
||||
export function createApprovalLedger(confirm: (approval: FileApproval) => Promise<boolean>): {
|
||||
approve: ApproveFn;
|
||||
} {
|
||||
const approved = new Set<string>();
|
||||
|
||||
return {
|
||||
async approve(request: ApprovalRequest): Promise<boolean> {
|
||||
if (!request.irreversible && approved.has(request.path)) return true;
|
||||
|
||||
// A dialog that cannot be shown is not consent, and neither is an exception
|
||||
// thrown while trying to show one, so both come back as a refusal. This is the
|
||||
// fail-closed half of the approval story, and it is here rather than at a call
|
||||
// site because every call site would have to remember it.
|
||||
const granted = await confirm(describeApproval(request)).catch(() => false);
|
||||
if (granted && !request.irreversible) approved.add(request.path);
|
||||
|
||||
return granted;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog itself.
|
||||
*
|
||||
* Vicinae's `rememberUserChoice` is declared in the API but not implemented in the
|
||||
* server, so nothing here offers "do not ask again": the ledger above is the only
|
||||
* memory, and it lasts one answer.
|
||||
*/
|
||||
export function confirmWithAlert(approval: FileApproval): Promise<boolean> {
|
||||
return confirmAlert({
|
||||
title: approval.title,
|
||||
message: approval.body,
|
||||
primaryAction: {
|
||||
title: "Allow",
|
||||
style: approval.destructive ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default,
|
||||
},
|
||||
dismissAction: { title: "Decline", style: Alert.ActionStyle.Cancel },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user