Skip to content

Commit 48ea32d

Browse files
authored
fix: stop macros and APIs immediately when cancellations occur (#976)
1 parent bc61760 commit 48ea32d

18 files changed

Lines changed: 680 additions & 81 deletions

docs/docs/Advanced/onePageInputs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,4 @@ Behavior:
184184
- Preflight may import user script modules to statically read `quickadd.inputs`. This can execute module top-level code.
185185
- Inline scripts aren’t scanned for input declarations yet.
186186
- If needed, you can still prompt ad-hoc (e.g., using inputPrompt or suggester) and those values will skip future one-page prompts due to being prefilled.
187+
- Closing the modal without submitting triggers `MacroAbortError("Input cancelled by user")`, which stops the macro unless you catch it.

docs/docs/QuickAddAPI.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Opens a one-page modal to collect multiple inputs in one go. Values already pres
3838
**Behavior:**
3939
- Uses existing values for any ids that already exist in `variables` (including empty strings).
4040
- Prompts only for missing (`undefined`/`null`) inputs.
41+
- If the user closes the modal without submitting, the promise rejects with `MacroAbortError("Input cancelled by user")`.
4142

4243
**Field Types:**
4344
- `text`: Single-line text input
@@ -115,18 +116,25 @@ Opens a prompt that asks for text input.
115116
- `placeholder`: (Optional) Placeholder text in the input field
116117
- `value`: (Optional) Default value
117118
118-
**Returns:** Promise resolving to the entered string, or `null` if cancelled
119+
**Returns:** Promise resolving to the entered string.
120+
121+
**Cancellation:** If the user cancels or presses Escape, the promise rejects with `MacroAbortError("Input cancelled by user")`. Letting it bubble will stop the macro automatically. Catch it only if your script wants to handle the cancellation itself.
119122
120123
**Example:**
121124
```javascript
122-
const name = await quickAddApi.inputPrompt(
123-
"What's your name?",
124-
"Enter your full name",
125-
"John Doe"
126-
);
127-
128-
if (name) {
129-
console.log(`Hello, ${name}!`);
125+
try {
126+
const name = await quickAddApi.inputPrompt(
127+
"What's your name?",
128+
"Enter your full name",
129+
"John Doe"
130+
);
131+
console.log(`Hello, ${name}!`);
132+
} catch (error) {
133+
if (error?.name === "MacroAbortError") {
134+
// Optional: perform cleanup before QuickAdd aborts the macro
135+
return;
136+
}
137+
throw error;
130138
}
131139
```
132140
@@ -135,7 +143,7 @@ Opens a wider prompt for longer text input (multi-line).
135143
136144
**Parameters:** Same as `inputPrompt`
137145
138-
**Returns:** Promise resolving to the entered string, or `null` if cancelled
146+
**Returns:** Promise resolving to the entered string. Cancelling rejects with `MacroAbortError` (same as `inputPrompt`).
139147
140148
**Example:**
141149
```javascript
@@ -153,7 +161,7 @@ Opens a confirmation dialog with Yes/No buttons.
153161
- `header`: The dialog title
154162
- `text`: (Optional) Additional explanation text
155163
156-
**Returns:** Promise resolving to `true` (Yes) or `false` (No)
164+
**Returns:** Promise resolving to `true` (Yes) or `false` (No). If the user closes the dialog without answering, the promise rejects with `MacroAbortError`.
157165
158166
**Example:**
159167
```javascript
@@ -196,7 +204,7 @@ Opens a selection prompt with searchable options. Can optionally allow custom in
196204
- `allowCustomInput`: (Optional) When `true`, allows users to enter custom text not in `actualItems`. Defaults to `false`
197205
- `options.renderItem`: (Optional) Custom renderer `(value, el) => void` to control how each suggestion row is drawn
198206
199-
**Returns:** Promise resolving to the selected value or custom input, or `null` if cancelled
207+
**Returns:** Promise resolving to the selected value or custom input. Cancelling rejects with `MacroAbortError`.
200208
201209
**Examples:**
202210

docs/docs/UserScripts.md

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,36 +287,55 @@ module.exports = async (params) => {
287287
**When to use `params.abort()`:**
288288
- Input validation failures
289289
- Missing required configuration
290-
- User cancels a confirmation prompt
290+
- You want to provide a custom message after catching a `MacroAbortError`
291291
- Prerequisites not met (e.g., required plugin not installed)
292292

293+
Prompt cancellations already throw `MacroAbortError` and halt macros automatically, so only call `abort()` in those scenarios if you need to surface a custom message or you're stopping for a non-prompt reason.
294+
293295
**What happens when you call `abort()`:**
294296
- Macro execution stops immediately
295297
- A message is logged: "Macro execution aborted: [your message]"
296298
- Remaining commands in the macro are skipped
297299
- No error is thrown to the user
298300

299301
**QuickAdd API methods that can be cancelled:**
300-
- `inputPrompt()` - Returns `undefined` if cancelled
301-
- `wideInputPrompt()` - Returns `undefined` if cancelled
302-
- `yesNoPrompt()` - Returns `undefined` if cancelled
303-
- `suggester()` - Aborts macro if cancelled
304-
- `checkboxPrompt()` - Returns `undefined` if cancelled
302+
- `inputPrompt()`
303+
- `wideInputPrompt()`
304+
- `yesNoPrompt()`
305+
- `suggester()`
306+
- `checkboxPrompt()`
307+
308+
Each of these now rejects with `MacroAbortError("Input cancelled by user")` when the user presses Escape or closes the dialog. If you do nothing, the macro will automatically stop (matching user expectations). If you want to handle cancellation in your script, wrap the call in `try/catch` and intercept the error before it reaches the macro engine.
309+
310+
```javascript
311+
try {
312+
const name = await quickAddApi.inputPrompt("Your name:");
313+
} catch (error) {
314+
if (error?.name === "MacroAbortError") {
315+
// Optional custom handling (e.g., cleanup) before the macro aborts
316+
return;
317+
}
318+
throw error; // real errors should still bubble up
319+
}
320+
```
305321
306-
**Important:** When using the QuickAdd API, check for `undefined` to handle cancellations gracefully:
322+
**Important:** Because cancellations now throw, you should only call `abort()` yourself when you want to provide a custom message or stop execution for a non-prompt reason.
307323
308324
```javascript
309325
module.exports = async (params) => {
310326
const { quickAddApi, abort } = params;
311327

312-
const name = await quickAddApi.inputPrompt("Your name:");
313-
314-
// Handle cancellation
315-
if (!name) {
316-
abort("Name is required");
328+
let name;
329+
try {
330+
name = await quickAddApi.inputPrompt("Your name:");
331+
} catch (error) {
332+
if (error?.name === "MacroAbortError") {
333+
abort("Name is required");
334+
return;
335+
}
336+
throw error;
317337
}
318338

319-
// Safe to use name here
320339
console.log(`Processing: ${name}`);
321340
};
322341
```
@@ -906,4 +925,4 @@ For complete working examples, see:
906925
**API methods returning undefined:**
907926
- Ensure you're using `await` with async methods
908927
- Check that QuickAdd plugin is enabled
909-
- Verify you're accessing the API correctly through `params.quickAddApi`
928+
- Verify you're accessing the API correctly through `params.quickAddApi`

src/IChoiceExecutor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import type IChoice from "./types/choices/IChoice";
2+
import type { MacroAbortError } from "./errors/MacroAbortError";
23

34
export interface IChoiceExecutor {
45
execute(choice: IChoice): Promise<void>;
56
variables: Map<string, unknown>;
7+
/**
8+
* Records that the most recent choice execution aborted so orchestrators can react.
9+
* Engines that handle cancellations without throwing should call this immediately after
10+
* {@link handleMacroAbort} returns true.
11+
*/
12+
signalAbort?(error: MacroAbortError): void;
13+
/**
14+
* Returns and clears any pending abort signal. Callers should invoke this right after
15+
* awaiting {@link execute} to determine whether the child choice stopped early.
16+
*/
17+
consumeAbortSignal?(): MacroAbortError | null;
618
}

src/choiceExecutor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,22 @@ import { isCancellationError } from "./utils/errorUtils";
1717

1818
export class ChoiceExecutor implements IChoiceExecutor {
1919
public variables: Map<string, unknown> = new Map<string, unknown>();
20+
private pendingAbort: MacroAbortError | null = null;
2021

2122
constructor(private app: App, private plugin: QuickAdd) {}
2223

24+
signalAbort(error: MacroAbortError) {
25+
this.pendingAbort = error;
26+
}
27+
28+
consumeAbortSignal(): MacroAbortError | null {
29+
const abort = this.pendingAbort;
30+
this.pendingAbort = null;
31+
return abort ?? null;
32+
}
33+
2334
async execute(choice: IChoice): Promise<void> {
35+
this.pendingAbort = null;
2436
// One-page preflight honoring per-choice override
2537
const globalEnabled = settingsStore.getState().onePageInputEnabled;
2638
const override = choice.onePageInput;

src/engine/CaptureChoiceEngine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
184184
defaultReason: "Capture aborted",
185185
})
186186
) {
187+
this.choiceExecutor.signalAbort?.(err as MacroAbortError);
187188
return;
188189
}
189190
reportError(err, `Error running capture choice "${this.choice.name}"`);

0 commit comments

Comments
 (0)