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 = { 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): { approve: ApproveFn; } { const approved = new Set(); return { async approve(request: ApprovalRequest): Promise { 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 { 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 }, }); }