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.
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
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 },
|
|
});
|
|
}
|