diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 644872b..6c03146 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,9 +6,9 @@ This extension is a thin front end over [QuickAdd](https://github.com/chhoumann/ Obsidian ships a command-line interface (Settings → General → Command line interface). QuickAdd registers handlers on it, so anything you can trigger in the plugin is reachable from a subprocess that returns JSON: -- `quickadd:list [type=...] [commands]` - the flattened choice tree: `id`, `name`, `type`, `path` (`Multi / child`), `command`, `runnable` (a Multi is a folder, not runnable). -- `quickadd:interactive id= [vars=]` - starts a choice and returns at once with the choice (`id`, `name`, `type`) and the address of a local prompt server (`host`, `port`, `sessionId`, `token`). QuickAdd then sends each prompt to that server instead of opening a modal. -- `quickadd:run choice=|id= [vars=] [ui] [verify]` - runs a choice to completion. **Run in Obsidian** passes `ui` so QuickAdd prompts inside the app. Quick Capture, Capture Selection, and Capture Clipboard pass their text through `vars`. `verify` returns the created file path and an honest success or failure for Template and Capture choices. +- `quickadd:list [type=...] [commands]` - the flattened choice tree: `id`, `name`, `type`, `path` (`Multi / child`), `command`, `runnable` (a Multi is a folder, not runnable), and `currentNote` (`none`, `optional`, or `required`: whether the choice reads the current note). +- `quickadd:interactive id= [vars=] [current=|none]` - starts a choice and returns at once with the choice (`id`, `name`, `type`) and the address of a local prompt server (`host`, `port`, `sessionId`, `token`). QuickAdd then sends each prompt to that server instead of opening a modal. +- `quickadd:run choice=|id= [vars=] [current=|none] [ui] [verify]` - runs a choice to completion. **Run in Obsidian** passes `ui` so QuickAdd prompts inside the app. Quick Capture, Capture Selection, and Capture Clipboard pass their text through `vars`. `verify` returns the created file path and an honest success or failure for Template and Capture choices. - `quickadd:suggest kind=links|tags` - completion items for text fields. `links` returns one item per note, attachment, and alias, each with `text` (what goes inside `[[...]]`) and `path`, plus `alias` on alias items. `tags` returns each tag without its `#` and its `count`, most used first. The CLI does no filtering; Raycast's `List` filters as the user types. The extension shells out with `execFile` (each argument is a separate argv entry, so no shell quoting is needed for values with spaces or newlines) and parses the JSON envelope. @@ -43,11 +43,13 @@ Before it runs anything, `ensureVaultReady` checks that Obsidian serves the vaul ## Per-choice flow: one interactive run 1. `quickadd:list` fills the searchable list, grouped by Multi folder. -2. **Run** calls `quickadd:interactive` for the selected choice. +2. **Run** calls `quickadd:interactive` for the selected choice. When its `currentNote` is `optional` or `required`, `CurrentNotePicker` (`src/completion-pickers.tsx`) asks for the note first and the run gets `current=`, or `current=none` from the **No current note** row that an optional choice offers. 3. The extension long-polls the prompt server's `/poll`. Each event is a `prompt`, `done`, `error`, or an `idle` keepalive. The list stays on screen until the first prompt arrives, so a choice without prompts finishes with just a toast. If nothing arrives within three seconds, the list hands its poll to the run's view. 4. Each prompt renders as a native control, and the answer goes back through `/reply`. QuickAdd collects a choice's declared inputs first, as one `form` prompt. Prompts that a macro script raises later arrive one at a time. 5. The `done` event names the file and its `effect`, so the finish message reads "Created " or "Added to " and offers **Open in Obsidian**. Without a created or changed file it reads "Ran ". +Without `current=`, QuickAdd takes the current note from Obsidian's active tab, which from Raycast is whatever tab happens to be open, so every run sends `current=`: the pick, or `none` when the choice does not ask. A pinned Quicklink asks for the note the same way. `ensureVaultReady` returns the choices it listed, so the list and the Quicklink view decide from a fresh `currentNote`, never a cached one. A `current` in the launch context skips the picker, and the relaunch after opening a closed vault keeps it. **Run in Obsidian** passes no `current=`, because there the active tab is the user's. The capture commands cannot ask: a `required` choice fails with a message that points to **Run QuickAdd Choice**, and an `optional` one runs with `current=none`. + A pinned Quicklink opens the run's view at once instead of waiting on the list. A Quicklink pinned with an argument carries it as `value` in the launch context, and the run starts with `vars={"value": ...}`. Its link puts Raycast's `{argument | json-stringify}` placeholder in the URL-encoded context JSON; Raycast percent-encodes the quoted text, so the context still parses (`src/lib/quicklink.ts`). When the view has waited three seconds with no prompt, it says QuickAdd may be asking something inside Obsidian (a Templater prompt, for example) and offers **Open Obsidian** (⌘O). **Cancel Run**, or leaving the run's view while the run is live, posts `/abort`. QuickAdd rejects the open prompt and every prompt the run raises later, so a run that is mid-work stops at its next prompt. The extension never aborts a run that has ended. `driveSession` in `src/lib/interactive.ts` holds the run's state (connecting, prompt, working, done, failed, cancelled) and owns these rules, so they are unit tested without Raycast. @@ -81,4 +83,6 @@ The `verify` flag that **Run in Obsidian** and the capture commands pass needs * `[[` and `#` completion needs **QuickAdd >= 2.31**, which added `quickadd:suggest`. Before it, the picker says which version it needs. +Asking for the current note needs **QuickAdd >= 2.32**, which added `current=` and `currentNote`. Older versions send no `currentNote`, so the extension asks for nothing and the run uses the active tab. + In the one-page form, note pickers start empty only with **QuickAdd >= 2.31**, which marks them with `picker: "file"`. Older versions send them as plain suggesters, so they keep the first note picked. diff --git a/README.md b/README.md index 3759f6a..b41cd31 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ Type `[[` in any text field and a searchable list of your notes and aliases open Three commands send text to a capture choice you name in their preferences and never open a view. **Quick Capture** takes the text as an argument in Raycast's root search. **Capture Selection** takes the text selected in the frontmost app. **Capture Clipboard** takes the clipboard. Give each a hotkey and capturing is one keystroke and one line of typing. +### Choose the current note + +A choice that links to the current note or reads from it asks which note that is before it runs, from a searchable list of your notes. When the choice can do without one, **No current note** comes first. The run never falls back to whatever tab is open in Obsidian. The capture commands cannot ask, so a capture choice that needs a current note runs only from **Run QuickAdd Choice**. + ### Pin a choice, or run it in Obsidian Every choice has two pin actions. **Pin as Quicklink** makes the choice searchable from Raycast's root, where you can give it a hotkey. **Pin as Quicklink with Argument** makes a Quicklink that takes text inline: type its name, press Tab, type the text, press Enter, and the text runs as the choice's `{{VALUE}}`. **Run in Obsidian** (⌘↵) runs the choice with QuickAdd's own modals when you want them. @@ -78,6 +82,7 @@ npm run dev | Run QuickAdd Choice, with a choice's inputs on one form | QuickAdd 2.17.2 or later | | Cancel Run that stops the run, "Created" and "Added to" messages | QuickAdd 2.20 or later | | `[[` and `#` completion, note pickers that start empty | QuickAdd 2.31 or later | +| Asking for the current note | QuickAdd 2.32 or later | ## Commands diff --git a/e2e-vault/.obsidian/plugins/quickadd/data.json b/e2e-vault/.obsidian/plugins/quickadd/data.json index 542a19f..3aedd70 100644 --- a/e2e-vault/.obsidian/plugins/quickadd/data.json +++ b/e2e-vault/.obsidian/plugins/quickadd/data.json @@ -569,6 +569,120 @@ "templater": { "afterCapture": "none" } + }, + { + "id": "e2e-current", + "name": "Capture to the current note", + "type": "Capture", + "command": false, + "appendLink": false, + "copyLinkToClipboard": false, + "captureTo": "", + "captureToActiveFile": true, + "captureToCanvasNodeId": "", + "activeFileWritePosition": "bottom", + "createFileIfItDoesntExist": { + "enabled": true, + "createWithTemplate": false, + "template": "" + }, + "format": { + "enabled": true, + "format": "- current: {{VALUE}}\n" + }, + "insertAfter": { + "enabled": false, + "after": "", + "insertAtEnd": false, + "considerSubsections": false, + "createIfNotFound": false, + "createIfNotFoundLocation": "top", + "inline": false, + "replaceExisting": false, + "blankLineAfterMatchMode": "auto", + "promptHeading": false + }, + "insertBefore": { + "enabled": false, + "before": "", + "createIfNotFound": false, + "createIfNotFoundLocation": "top" + }, + "newLineCapture": { + "enabled": false, + "direction": "below" + }, + "prepend": false, + "task": false, + "openFile": false, + "fileOpening": { + "location": "tab", + "direction": "vertical", + "mode": "default", + "focus": true + }, + "templater": { + "afterCapture": "none" + } + }, + { + "id": "e2e-link", + "name": "Capture with a link", + "type": "Capture", + "command": false, + "appendLink": { + "enabled": true, + "placement": "newLine", + "requireActiveFile": false + }, + "copyLinkToClipboard": false, + "captureTo": "Output/Inbox.md", + "captureToActiveFile": false, + "captureToCanvasNodeId": "", + "activeFileWritePosition": "bottom", + "createFileIfItDoesntExist": { + "enabled": true, + "createWithTemplate": false, + "template": "" + }, + "format": { + "enabled": true, + "format": "- linked: {{VALUE}} {{LINKCURRENT}}\n" + }, + "insertAfter": { + "enabled": false, + "after": "", + "insertAtEnd": false, + "considerSubsections": false, + "createIfNotFound": false, + "createIfNotFoundLocation": "top", + "inline": false, + "replaceExisting": false, + "blankLineAfterMatchMode": "auto", + "promptHeading": false + }, + "insertBefore": { + "enabled": false, + "before": "", + "createIfNotFound": false, + "createIfNotFoundLocation": "top" + }, + "newLineCapture": { + "enabled": false, + "direction": "below" + }, + "prepend": false, + "task": false, + "openFile": false, + "fileOpening": { + "location": "tab", + "direction": "vertical", + "mode": "default", + "focus": true + }, + "templater": { + "afterCapture": "none" + } } ], "inputPrompt": "single-line", diff --git a/scripts/e2e-protocol.mts b/scripts/e2e-protocol.mts index 0c45c78..051fe90 100644 --- a/scripts/e2e-protocol.mts +++ b/scripts/e2e-protocol.mts @@ -75,12 +75,13 @@ function reply(prompt: PromptSpec): unknown { } } -async function start(choiceId: string, vars?: object) { +async function start(choiceId: string, vars?: object, current?: string) { const started = JSON.parse( obsidian( "quickadd:interactive", `id=${choiceId}`, ...(vars ? [`vars=${JSON.stringify(vars)}`] : []), + ...(current ? [`current=${current}`] : []), ), ); if (!started.ok) throw new Error(started.error); @@ -95,8 +96,8 @@ async function nextEvent(at: (path: string) => string): Promise { } } -async function run(choiceId: string, vars?: object) { - const at = await start(choiceId, vars); +async function run(choiceId: string, vars?: object, current?: string) { + const at = await start(choiceId, vars, current); for (;;) { const event = await nextEvent(at); if (event.kind === "error") throw new Error(event.error); @@ -117,6 +118,10 @@ function note(path: string): string { return readFileSync(new URL(path, VAULT_DIR), "utf8"); } +function hasLine(path: string, line: string): boolean { + return note(path).split("\n").includes(line); +} + // What a Quicklink argument carries into the run as `value`. const ARGUMENT = `idea & plan #tag [[Note]] "quoted" back\\slash æøå 💡`; @@ -168,6 +173,9 @@ obsidian( if (out) await app.vault.delete(out, true); await app.vault.createFolder("Output"); await app.vault.create("Output/Picked.md", "# Picked\\n"); + await app.vault.create("Output/Current.md", "# Current\\n"); + const decoy = await app.vault.create("Output/Decoy.md", "# Decoy\\n"); + await app.workspace.getLeaf().openFile(decoy); })()`, ); await sleep(1500); @@ -191,7 +199,82 @@ for (const [id, path, expected, vars] of cases) { console.log(` FAIL ${error instanceof Error ? error.message : error}`); } } -const aborts: Array<[name: string, check: () => Promise]> = [ +const checks: Array<[name: string, check: () => Promise]> = [ + [ + "list which choices use the current note", + async () => { + const { choices } = JSON.parse(obsidian("quickadd:list")) as { + choices: Array<{ id: string; currentNote?: string }>; + }; + const use = (id: string) => choices.find((c) => c.id === id)?.currentNote; + const got = [use("e2e-current"), use("e2e-link"), use("e2e-text")]; + if (got.join() !== "required,optional,none") { + throw new Error(`currentNote is ${JSON.stringify(got)}`); + } + }, + ], + [ + "capture into the named current note, not the open tab", + async () => { + await run("e2e-current", undefined, "Output/Current.md"); + await sleep(300); + if (!hasLine("Output/Current.md", "- current: Text to capture answer")) { + throw new Error(`Output/Current.md: ${note("Output/Current.md")}`); + } + }, + ], + [ + "refuse a capture to the current note with current=none", + async () => { + const before = note("Output/Current.md"); + const failure = await run("e2e-current", undefined, "none").then( + () => "", + (error: Error) => error.message, + ); + if (!failure.includes("no active file")) { + throw new Error(`expected the no-active-file error, got: ${failure}`); + } + if (note("Output/Current.md") !== before) { + throw new Error("Output/Current.md changed"); + } + }, + ], + [ + "link the named current note", + async () => { + await run("e2e-link", undefined, "Output/Current.md"); + await sleep(300); + if ( + !hasLine("Output/Inbox.md", "- linked: Enter value answer [[Current]]") + ) { + throw new Error(`Output/Inbox.md: ${note("Output/Inbox.md")}`); + } + if (!note("Output/Current.md").includes("[[Inbox]]")) { + throw new Error(`no link in Output/Current.md`); + } + }, + ], + [ + "leave the link empty with current=none", + async () => { + const before = note("Output/Current.md"); + await run("e2e-link", undefined, "none"); + await sleep(300); + if (!hasLine("Output/Inbox.md", "- linked: Enter value answer ")) { + throw new Error(`Output/Inbox.md: ${note("Output/Inbox.md")}`); + } + if (note("Output/Current.md") !== before) { + throw new Error("Output/Current.md changed"); + } + }, + ], + [ + "never touch the open tab", + async () => { + const decoy = note("Output/Decoy.md"); + if (decoy !== "# Decoy\n") throw new Error(`Output/Decoy.md: ${decoy}`); + }, + ], [ "abort while the run is mid-work", async () => { @@ -229,7 +312,7 @@ const aborts: Array<[name: string, check: () => Promise]> = [ }, ], ]; -for (const [name, check] of aborts) { +for (const [name, check] of checks) { console.log(name); try { await check(); @@ -240,6 +323,6 @@ for (const [name, check] of aborts) { } } -const total = cases.length + aborts.length; +const total = cases.length + checks.length; console.log(failed ? `${failed} of ${total} failed` : `all ${total} passed`); process.exit(failed ? 1 : 0); diff --git a/src/completion-pickers.tsx b/src/completion-pickers.tsx index 34f18db..5079829 100644 --- a/src/completion-pickers.tsx +++ b/src/completion-pickers.tsx @@ -1,12 +1,14 @@ import { Action, ActionPanel, Icon, List } from "@raycast/api"; -import { usePromise } from "@raycast/utils"; +import { showFailureToast, usePromise } from "@raycast/utils"; import { useEffect } from "react"; +import { type CurrentNote, noteItems } from "./lib/current-note"; import { type LinkItem, type TagItem, suggestLinks, suggestTags, } from "./lib/suggest"; +import type { ChoiceSummary } from "./lib/types"; import type { Vault } from "./lib/vaults"; interface PickerProps { @@ -95,3 +97,70 @@ export function TagPicker({ vault, onPick, onClose }: PickerProps) { ); } + +async function suggestNotes(vault: Vault): Promise { + return noteItems(await suggestLinks(vault)); +} + +export function CurrentNotePicker({ + vault, + choice, + onPick, +}: { + vault: Vault; + choice: Pick; + onPick: (current: CurrentNote) => void; +}) { + // A toast, because the "No current note" row would hide an empty view. + const { data, isLoading } = usePromise(suggestNotes, [vault], { + onError: (error) => { + void showFailureToast(error, { title: "Could not load notes" }); + }, + }); + + return ( + + {choice.currentNote === "optional" && ( + + + onPick("none")} + /> + + } + /> + + )} + + {data?.map((item) => ( + + onPick(item.path)} + /> + + } + /> + ))} + + + ); +} diff --git a/src/lib/capture.ts b/src/lib/capture.ts index 6f97fe2..4e0a842 100644 --- a/src/lib/capture.ts +++ b/src/lib/capture.ts @@ -1,8 +1,9 @@ import { Toast, getPreferenceValues, showToast } from "@raycast/api"; import { showFailureToast } from "@raycast/utils"; import { doneMessage } from "./interactive"; -import { listChoices, prepareVault, runChoice } from "./obsidianCli"; +import { prepareVault, runChoice } from "./obsidianCli"; import { resolveCaptureChoice } from "./capture-choice"; +import { headlessCurrentNote } from "./current-note"; import { chooseVault, readRegistry } from "./vaults"; export interface CaptureContext { @@ -32,10 +33,10 @@ export async function capture( } const ready = await prepareVault(chosen.vault, undefined, registry); if (!ready.ok) throw new Error(ready.message); - const listed = await listChoices(chosen.vault); - const choice = resolveCaptureChoice(listed.choices ?? [], captureChoice); + const choice = resolveCaptureChoice(ready.choices, captureChoice); const result = await runChoice(chosen.vault, choice.id, { vars: { value: text }, + current: headlessCurrentNote(choice), }); if (!result.ok) { throw new Error(result.error ?? "Capture failed"); diff --git a/src/lib/current-note.test.ts b/src/lib/current-note.test.ts new file mode 100644 index 0000000..3746ecb --- /dev/null +++ b/src/lib/current-note.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { currentFor, headlessCurrentNote, noteItems } from "./current-note"; + +describe("noteItems", () => { + it("keeps each note once and drops aliases and attachments", () => { + const links = [ + { text: "Ada Lovelace", path: "People/Ada Lovelace.md" }, + { text: "Ada", path: "People/Ada Lovelace.md", alias: "Ada" }, + { text: "diagram.png", path: "Assets/diagram.png" }, + { text: "Board.canvas", path: "Board.canvas" }, + { text: "Plan", path: "Plan.md" }, + ]; + expect(noteItems(links).map((item) => item.path)).toEqual([ + "People/Ada Lovelace.md", + "Plan.md", + ]); + }); +}); + +describe("headlessCurrentNote", () => { + it("refuses a choice that needs a current note", () => { + expect(() => + headlessCurrentNote({ name: "Log to note", currentNote: "required" }), + ).toThrow( + /^Log to note needs a current note\. Run it from Run QuickAdd Choice\.$/, + ); + }); + + it("runs a choice that can do without one with no current note", () => { + expect( + headlessCurrentNote({ name: "Inbox", currentNote: "optional" }), + ).toBe("none"); + }); + + it("sends none when the choice does not use the current note, so the active tab is never read", () => { + expect(headlessCurrentNote({ name: "Inbox", currentNote: "none" })).toBe( + "none", + ); + }); + + it("sends nothing to a QuickAdd that does not report currentNote", () => { + expect(headlessCurrentNote({ name: "Inbox" })).toBe(undefined); + }); +}); + +describe("currentFor", () => { + it("passes the pick through and defaults to none", () => { + expect( + currentFor({ name: "A", currentNote: "required" }, "Notes/X.md"), + ).toBe("Notes/X.md"); + expect(currentFor({ name: "A", currentNote: "none" })).toBe("none"); + expect(currentFor({ name: "A" })).toBe(undefined); + }); +}); diff --git a/src/lib/current-note.ts b/src/lib/current-note.ts new file mode 100644 index 0000000..9e18693 --- /dev/null +++ b/src/lib/current-note.ts @@ -0,0 +1,45 @@ +import type { LinkItem } from "./suggest"; +import type { CurrentNoteUse } from "./types"; + +/** The run's current note: a vault path, or "none" for no current note. */ +export type CurrentNote = string; + +interface UsesCurrentNote { + name: string; + currentNote?: CurrentNoteUse; +} + +/** Without an explicit note, QuickAdd would use whatever tab Obsidian has open. */ +export function asksForCurrentNote(choice: UsesCurrentNote): boolean { + return choice.currentNote === "optional" || choice.currentNote === "required"; +} + +/** The notes a run can take as its current note: no aliases, no attachments. */ +export function noteItems(links: LinkItem[]): LinkItem[] { + return links.filter((item) => !item.alias && item.path.endsWith(".md")); +} + +/** + * What to send as `current=`: the user's pick, else "none" so the run never + * reads Obsidian's active tab. QuickAdd before 2.32 sends no `currentNote` and + * would take `current` as a variable, so it gets nothing. + */ +export function currentFor( + choice: UsesCurrentNote, + pick?: CurrentNote, +): CurrentNote | undefined { + if (choice.currentNote === undefined) return undefined; + return pick ?? "none"; +} + +/** The current note for a run with no one to ask, as the capture commands are. */ +export function headlessCurrentNote( + choice: UsesCurrentNote, +): CurrentNote | undefined { + if (choice.currentNote === "required") { + throw new Error( + `${choice.name} needs a current note. Run it from Run QuickAdd Choice.`, + ); + } + return currentFor(choice); +} diff --git a/src/lib/obsidianCli.test.ts b/src/lib/obsidianCli.test.ts index 9dc2fc2..f1ff4d0 100644 --- a/src/lib/obsidianCli.test.ts +++ b/src/lib/obsidianCli.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; -import { prepareVault } from "./obsidianCli"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prepareVault, runChoice } from "./obsidianCli"; import { vaultAt } from "./vaults"; const prefs = vi.hoisted(() => ({ cliPath: "/nonexistent/obsidian" })); +const cli = vi.hoisted(() => ({ stdout: "", args: [] as string[] })); vi.mock("@raycast/api", () => ({ getPreferenceValues: () => prefs })); @@ -11,10 +12,31 @@ vi.mock("node:child_process", () => ({ const callback = rest.at(-1) as (error: Error | null, out?: object) => void; if (file === "open") callback(new Error("No application knows how to open the URL")); - else callback(null, { stdout: "" }); + else { + cli.args = rest[0] as string[]; + callback(null, { stdout: cli.stdout }); + } }, })); +beforeEach(() => { + cli.stdout = ""; + cli.args = []; +}); + +describe("runChoice", () => { + const vault = vaultAt("/Users/me/notes"); + + it("names the current note only when the caller picked one", async () => { + prefs.cliPath = process.execPath; + cli.stdout = '{"ok":true}'; + await runChoice(vault, "inbox", { current: "none" }); + expect(cli.args).toContain("current=none"); + await runChoice(vault, "inbox"); + expect(cli.args.some((arg) => arg.startsWith("current"))).toBe(false); + }); +}); + describe("prepareVault", () => { const vault = vaultAt("/Users/me/notes"); diff --git a/src/lib/obsidianCli.ts b/src/lib/obsidianCli.ts index 2e5ff40..6534924 100644 --- a/src/lib/obsidianCli.ts +++ b/src/lib/obsidianCli.ts @@ -3,13 +3,9 @@ import { execFile } from "node:child_process"; import { accessSync, constants } from "node:fs"; import { homedir } from "node:os"; import { promisify } from "node:util"; +import type { CurrentNote } from "./current-note"; import type { InteractiveSession } from "./interactive"; -import type { - ChoiceRef, - InteractiveResponse, - ListResponse, - RunResponse, -} from "./types"; +import type { ChoiceRef, InteractiveResponse, RunResponse } from "./types"; import { type Readiness, type Registry, @@ -134,19 +130,16 @@ export async function prepareVault( } } -export async function listChoices(vault: Vault): Promise { - return invoke(vault, "quickadd:list", {}); -} - export async function startInteractive( vault: Vault, choiceId: string, - vars?: Record, + options: { vars?: Record; current?: CurrentNote } = {}, ): Promise<{ session: InteractiveSession; choice: ChoiceRef }> { + const { vars, current } = options; const response = await invoke( vault, "quickadd:interactive", - { id: choiceId, vars: vars && JSON.stringify(vars) }, + { id: choiceId, vars: vars && JSON.stringify(vars), current }, ); const { port, sessionId, token, choice } = response; if (!response.ok || !port || !sessionId || !token || !choice) { @@ -166,6 +159,7 @@ export interface RunOptions { ui?: boolean; /** Values may be arrays (multi-select variables); passed verbatim via vars JSON. */ vars?: Record; + current?: CurrentNote; } function runParams(options: RunOptions): Record { @@ -175,6 +169,7 @@ function runParams(options: RunOptions): Record { ? JSON.stringify(options.vars) : undefined, ui: options.ui ? "" : undefined, + current: options.current, // Verified outcome (QuickAdd >= 2.14): honest failures + created file // path for Template/Capture. Older versions ignore the flag. verify: "", diff --git a/src/lib/types.ts b/src/lib/types.ts index 7f1647f..4f30700 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,6 +5,9 @@ export type ChoiceType = "Template" | "Capture" | "Macro" | "Multi"; /** Sent by QuickAdd >= 2.20. */ export type ChoiceEffect = "created" | "changed" | "unchanged" | "unknown"; +/** Whether a choice reads the current note. QuickAdd before `current=` omits it, which means "none". */ +export type CurrentNoteUse = "none" | "optional" | "required"; + export interface ChoiceSummary { id: string; name: string; @@ -13,12 +16,14 @@ export interface ChoiceSummary { /** Full path through Multi folders, e.g. "📥 Add... / ✍ Note (title)". */ path: string; runnable: boolean; + currentNote?: CurrentNoteUse; } export interface ChoiceRef { id: string; name: string; type: ChoiceType; + currentNote?: CurrentNoteUse; } export interface ListResponse { diff --git a/src/lib/vaults.test.ts b/src/lib/vaults.test.ts index ecb0edc..b6b858a 100644 --- a/src/lib/vaults.test.ts +++ b/src/lib/vaults.test.ts @@ -96,6 +96,7 @@ describe("sameNameConflict", () => { describe("ensureVaultReady", () => { const vault = vaultAt("/Users/me/e2e-vault"); + const listed = { id: "e2e-text", name: "Capture text", currentNote: "none" }; function obsidian({ open, @@ -124,7 +125,7 @@ describe("ensureVaultReady", () => { } lists++; if (lists <= failedLists) return ""; - return JSON.stringify({ ok: true, choices: [{ id: "e2e-text" }] }); + return JSON.stringify({ ok: true, choices: [listed] }); }, sleep: async (ms) => { clock += ms; @@ -139,6 +140,7 @@ describe("ensureVaultReady", () => { await expect(ensureVaultReady(vault, "e2e-text", deps)).resolves.toEqual({ ok: true, opened: false, + choices: [listed], }); expect(opened).toEqual([]); }); @@ -148,6 +150,7 @@ describe("ensureVaultReady", () => { await expect(ensureVaultReady(vault, "e2e-text", deps)).resolves.toEqual({ ok: true, opened: true, + choices: [listed], }); expect(opened).toEqual(["obsidian://open?vault=e2e-vault"]); }); @@ -160,6 +163,7 @@ describe("ensureVaultReady", () => { await expect(ensureVaultReady(vault, "e2e-text", deps)).resolves.toEqual({ ok: true, opened: true, + choices: [listed], }); expect(opened).toEqual(["obsidian://open?vault=e2e-vault"]); expect(elapsed()).toBe(2000); diff --git a/src/lib/vaults.ts b/src/lib/vaults.ts index a44394b..6eae281 100644 --- a/src/lib/vaults.ts +++ b/src/lib/vaults.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; +import type { ChoiceSummary, ListResponse } from "./types"; /** `name` is what the CLI's `vault=` and `obsidian://` URIs address the vault by. */ export interface Vault { @@ -80,7 +81,8 @@ export interface ReadyDeps { } export type Readiness = - { ok: true; opened: boolean } | { ok: false; message: string }; + | { ok: true; opened: boolean; choices: ChoiceSummary[] } + | { ok: false; message: string }; const POLL_MS = 500; const OPEN_TIMEOUT_MS = 20_000; @@ -115,31 +117,28 @@ export async function ensureVaultReady( vaultAt( (await deps.runCli([`vault=${vault.name}`, "vault", "info=path"])).trim(), ).path === vault.path; - const quickAddAnswers = async () => { + const listedChoices = async () => { try { - const list = JSON.parse( + const { ok, choices = [] } = JSON.parse( await deps.runCli([`vault=${vault.name}`, "quickadd:list"]), - ) as { ok?: boolean; choices?: { id: string }[] }; - return ( - list.ok === true && - (!choiceId || (list.choices ?? []).some(({ id }) => id === choiceId)) - ); + ) as ListResponse; + const listed = !choiceId || choices.some(({ id }) => id === choiceId); + return ok === true && listed ? choices : undefined; } catch { - return false; + return undefined; } }; - if (entry.open && (await servesVault()) && (await quickAddAnswers())) { - return { ok: true, opened: false }; - } + const choices = + entry.open && (await servesVault()) && (await listedChoices()); + if (choices) return { ok: true, opened: false, choices }; await deps.open(`obsidian://open?vault=${encodeURIComponent(vault.name)}`); const deadline = deps.now() + OPEN_TIMEOUT_MS; while (deps.now() < deadline) { await deps.sleep(POLL_MS); - if ((await servesVault()) && (await quickAddAnswers())) { - return { ok: true, opened: true }; - } + const choices = (await servesVault()) && (await listedChoices()); + if (choices) return { ok: true, opened: true, choices }; } return { ok: false, diff --git a/src/run-choice.tsx b/src/run-choice.tsx index d0286de..7bebbd2 100644 --- a/src/run-choice.tsx +++ b/src/run-choice.tsx @@ -17,18 +17,22 @@ import { import { createDeeplink, showFailureToast, - useCachedPromise, useCachedState, useFrecencySorting, } from "@raycast/utils"; import { useEffect, useRef, useState } from "react"; import { - listChoices, obsidianOpenUrl, prepareVault, runChoice, startInteractive, } from "./lib/obsidianCli"; +import { CurrentNotePicker } from "./completion-pickers"; +import { + type CurrentNote, + asksForCurrentNote, + currentFor, +} from "./lib/current-note"; import { choiceIcon } from "./lib/format"; import { quicklinkWithArgument } from "./lib/quicklink"; import { STALL_MS, InteractiveSessionView } from "./interactive-session"; @@ -178,30 +182,28 @@ function VaultGate({ ); } - return choiceId ? ( - + const choice = + choiceId && readiness.choices.find(({ id }) => id === choiceId); + return choice ? ( + ) : ( - + ); } -function ChoiceList({ vault }: { vault: Vault }) { - const { data, isLoading, error } = useCachedPromise( - async (vault: Vault) => { - const response = await listChoices(vault); - if (!response.ok || !response.choices) { - throw new Error(response.error ?? "QuickAdd returned no choices"); - } - return response.choices.filter((choice) => choice.runnable); - }, - [vault], - ); +function ChoiceList({ + vault, + choices, +}: { + vault: Vault; + choices: ChoiceSummary[]; +}) { + const runnable = choices.filter((choice) => choice.runnable); // The hook sorts in place and cannot tell visited items from the rest, so it // gets a copy and the visited ids are kept beside it. - const { data: byFrecency, visitItem } = useFrecencySorting( - data && [...data], - { namespace: vault.path }, - ); + const { data: byFrecency, visitItem } = useFrecencySorting([...runnable], { + namespace: vault.path, + }); const [visited, setVisited] = useCachedState( `visited-choices:${vault.path}`, [], @@ -211,28 +213,13 @@ function ChoiceList({ vault }: { vault: Vault }) { setVisited((ids) => (ids.includes(choice.id) ? ids : [...ids, choice.id])); }; - if (error) { - return ( - - - - ); - } - const recent = byFrecency .filter((choice) => visited.includes(choice.id)) .slice(0, 5); - const sections = groupByParent(data ?? []); + const sections = groupByParent(runnable); return ( - + {recent.map((choice) => ( (); + const asking = asksForCurrentNote(choice) && current === undefined; const [view, setView] = useState< | { phase: "loading" } - | { phase: "attach"; session: InteractiveSession; choiceName: string } + | { phase: "attach"; session: InteractiveSession } | { phase: "error"; message: string } >({ phase: "loading" }); // Raycast double-invokes effects (StrictMode); without the ref the choice @@ -282,16 +271,15 @@ function DirectChoice({ const startRef = useRef | null>(null); useEffect(() => { + if (asking) return; let cancelled = false; - startRef.current ??= startInteractive( - vault, - choiceId, - value === undefined ? undefined : { value }, - ); + startRef.current ??= startInteractive(vault, choice.id, { + vars: value === undefined ? undefined : { value }, + current: currentFor(choice, current), + }); startRef.current.then( - ({ session, choice }) => { - if (!cancelled) - setView({ phase: "attach", session, choiceName: choice.name }); + ({ session }) => { + if (!cancelled) setView({ phase: "attach", session }); }, (error) => { if (cancelled) return; @@ -305,19 +293,23 @@ function DirectChoice({ return () => { cancelled = true; }; - }, [choiceId]); + }, [asking]); + if (asking) { + return ( + + ); + } if (view.phase === "attach") { - const { choiceName } = view; return ( void showHUD( end.state === "done" - ? doneMessage(choiceName, end.result) + ? doneMessage(choice.name, end.result) : "Canceled", ) } @@ -367,14 +359,16 @@ function ChoiceItem({ }) { const { push, pop } = useNavigation(); - async function runInteractive() { + async function runInteractive(current?: CurrentNote) { onRun(choice); const toast = await showToast({ style: Toast.Style.Animated, title: `Running ${choice.name}...`, }); try { - const { session } = await startInteractive(vault, choice.id); + const { session } = await startInteractive(vault, choice.id, { + current: currentFor(choice, current), + }); const first = await firstEvent(session, STALL_MS); if (first.kind === "error") throw new Error(first.error); await toast.hide(); @@ -447,7 +441,28 @@ function ChoiceItem({ keywords={choice.path.split(" / ")} actions={ - + {asksForCurrentNote(choice) ? ( + { + pop(); + void runInteractive(current); + }} + /> + } + /> + ) : ( + runInteractive()} + /> + )}