From 7e2003c9bbda19da524f435285969d85921b31fa Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:39:57 +0100 Subject: [PATCH 1/3] feat(flows): mark flows and copy paths or IDs --- .changeset/flows-list-mark-and-copy.md | 7 + knip.config.ts | 2 + skills/qawolf-cli/SKILL.md | 8 +- src/commands/__snapshots__/help.test.ts.snap | 5 +- src/commands/flows/list.register.ts | 2 +- src/commands/qawolfCliSkill.template.md | 8 +- src/core/ansi.ts | 2 + src/core/messages/flows.ts | 16 ++ src/core/shellArguments.ts | 14 ++ src/domains/flows/copyFlowActions.test.ts | 158 ++++++++++++++++++ src/domains/flows/copyFlowActions.ts | 63 +++++++ src/domains/flows/filterFlows.test.ts | 31 +++- src/domains/flows/filterFlows.ts | 5 +- src/shell/clipboard.test.ts | 89 ++++++++++ src/shell/clipboard.ts | 56 +++++++ src/shell/clipboardPaths.test.ts | 89 ++++++++++ src/shell/clipboardPaths.ts | 21 +++ src/shell/ui/renderers/filterActions.test.ts | 108 ++++++++++++ src/shell/ui/renderers/filterActions.ts | 61 +++++++ src/shell/ui/renderers/filterFrame.test.ts | 63 +++++++ src/shell/ui/renderers/filterFrame.ts | 42 +++-- src/shell/ui/renderers/filterInput.ts | 33 ++++ .../ui/renderers/filterList.keys.test.ts | 21 +++ src/shell/ui/renderers/filterList.test.ts | 92 +++++++++- .../ui/renderers/filterList.testUtils.ts | 7 +- src/shell/ui/renderers/filterList.ts | 68 +++++++- src/shell/ui/renderers/filterView.test.ts | 2 + src/shell/ui/renderers/filterView.ts | 16 +- src/shell/ui/renderers/noticeStyle.ts | 15 ++ src/shell/ui/renderers/types.ts | 26 ++- 30 files changed, 1097 insertions(+), 33 deletions(-) create mode 100644 .changeset/flows-list-mark-and-copy.md create mode 100644 src/core/shellArguments.ts create mode 100644 src/domains/flows/copyFlowActions.test.ts create mode 100644 src/domains/flows/copyFlowActions.ts create mode 100644 src/shell/clipboard.test.ts create mode 100644 src/shell/clipboard.ts create mode 100644 src/shell/clipboardPaths.test.ts create mode 100644 src/shell/clipboardPaths.ts create mode 100644 src/shell/ui/renderers/filterActions.test.ts create mode 100644 src/shell/ui/renderers/filterActions.ts create mode 100644 src/shell/ui/renderers/filterInput.ts create mode 100644 src/shell/ui/renderers/filterList.keys.test.ts create mode 100644 src/shell/ui/renderers/noticeStyle.ts diff --git a/.changeset/flows-list-mark-and-copy.md b/.changeset/flows-list-mark-and-copy.md new file mode 100644 index 000000000..635855a4e --- /dev/null +++ b/.changeset/flows-list-mark-and-copy.md @@ -0,0 +1,7 @@ +--- +"@qawolf/cli": minor +--- + +Mark flows with Tab in the interactive list, keeping marks across searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or the highlighted flow when none are marked. Enter prints marked flows, or all matches when none are marked. Missing IDs produce a notice. + +Copied paths are separate literal shell arguments using POSIX syntax on macOS/Linux and PowerShell syntax on Windows. The CLI uses system clipboard tools and falls back to requesting the terminal clipboard when tools are unavailable. diff --git a/knip.config.ts b/knip.config.ts index 752c1049f..a1b492ab9 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -11,6 +11,8 @@ const config: KnipConfig = { ], project: ["src/**/*.ts"], ignoreBinaries: [ + // Optional system shell used by the clipboard quoting round-trip test. + "pwsh", // the built bundle, invoked as `node dist/cli.js` in the runtime-smoke CI // job; not present when knip runs (it runs before the build step) "dist/cli.js", diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 6c8ce37ee..53f49e743 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -121,10 +121,14 @@ that `url`; never guess a route and never send a repository link in its place. ## Flow lists At an interactive terminal, `qawolf flows list` opens a searchable table. -Search by flow name, path, target, environment, or tag; press Enter to print the -matches or Esc to leave. Use `--no-interactive` to print directly. Agents and +Search by flow name, path, target, environment, or tag. Tab marks flows across +searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or for the +highlighted flow when none are marked. Enter prints marked flows, or all matches +when none are marked; Esc leaves. Use `--no-interactive` to print directly. Agents and JSON output print directly by default; `-i` requires an interactive terminal. Pulled flows include cached IDs in JSON when the last pull recorded them. +Copied paths use POSIX shell quoting on macOS/Linux and PowerShell quoting on +Windows. Missing IDs produce a notice; pull again to populate older caches. ## Commands diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 0be327be1..12bd267b8 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -248,8 +248,9 @@ Options: --tag Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull (default: []) - -i, --interactive Open the flow table to filter as you type; the - default at a terminal + -i, --interactive Open the flow table to filter as you type, mark flows + and copy their paths or ids; the default at a + terminal --no-interactive Print the flow table instead of opening it --ai-task-id List the flows on this AI task's branch, including drafts, instead of the ones in the environment diff --git a/src/commands/flows/list.register.ts b/src/commands/flows/list.register.ts index 769bcdeee..685fbecf9 100644 --- a/src/commands/flows/list.register.ts +++ b/src/commands/flows/list.register.ts @@ -74,7 +74,7 @@ export function registerFlowsListCommand( // Declared before --no-interactive, so neither passed leaves it undefined. .option( "-i, --interactive", - "Open the flow table to filter as you type; the default at a terminal", + "Open the flow table to filter as you type, mark flows and copy their paths or ids; the default at a terminal", ) .option("--no-interactive", "Print the flow table instead of opening it") .addOption( diff --git a/src/commands/qawolfCliSkill.template.md b/src/commands/qawolfCliSkill.template.md index 05ec9ae4a..8225fc8b0 100644 --- a/src/commands/qawolfCliSkill.template.md +++ b/src/commands/qawolfCliSkill.template.md @@ -121,10 +121,14 @@ that `url`; never guess a route and never send a repository link in its place. ## Flow lists At an interactive terminal, `qawolf flows list` opens a searchable table. -Search by flow name, path, target, environment, or tag; press Enter to print the -matches or Esc to leave. Use `--no-interactive` to print directly. Agents and +Search by flow name, path, target, environment, or tag. Tab marks flows across +searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or for the +highlighted flow when none are marked. Enter prints marked flows, or all matches +when none are marked; Esc leaves. Use `--no-interactive` to print directly. Agents and JSON output print directly by default; `-i` requires an interactive terminal. Pulled flows include cached IDs in JSON when the last pull recorded them. +Copied paths use POSIX shell quoting on macOS/Linux and PowerShell quoting on +Windows. Missing IDs produce a notice; pull again to populate older caches. ## Commands diff --git a/src/core/ansi.ts b/src/core/ansi.ts index ec1632e3d..75e0e3470 100644 --- a/src/core/ansi.ts +++ b/src/core/ansi.ts @@ -8,5 +8,7 @@ export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`; export const strike = (text: string): string => `\x1b[9m${text}\x1b[29m`; export const inverse = (text: string): string => `\x1b[7m${text}\x1b[27m`; export const cyan = (text: string): string => `\x1b[36m${text}\x1b[39m`; +export const green = (text: string): string => `\x1b[32m${text}\x1b[39m`; +export const yellow = (text: string): string => `\x1b[33m${text}\x1b[39m`; export const visibleLength = displayWidth; diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index 020587a65..1f1ee3762 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -2,6 +2,13 @@ import { pluralize } from "~/core/pluralize.js"; import { flowsPullMessages } from "./flowsPull.js"; +function copiedValues(values: readonly string[], noun: string): string { + const [only] = values; + return values.length === 1 && only !== undefined + ? only + : pluralize(values.length, noun); +} + export const flowsMessages = { title: "Flows", remoteTitle: "Remote Flows", @@ -19,6 +26,15 @@ export const flowsMessages = { `${String(matched)} of ${pluralize(total, "flow")}`, interactiveRequiresTerminal: "--interactive needs a terminal. Run it in a terminal, without --json or --agent, and without piping its output.", + copyPath: "copy path", + copyId: "copy id", + copied: (values: readonly string[], noun: string) => + `Copied ${copiedValues(values, noun)}`, + copiedViaTerminal: (values: readonly string[], noun: string) => + `Sent ${copiedValues(values, noun)} to your terminal's clipboard`, + idsLeftOut: (missing: number) => + `${pluralize(missing, "flow")} had no id yet and ${missing === 1 ? "was" : "were"} left out`, + noFlowId: "No flow id yet. Pull this environment again to fetch it.", noFlowIdShort: "no id yet", }, selectors: { diff --git a/src/core/shellArguments.ts b/src/core/shellArguments.ts new file mode 100644 index 000000000..684756e65 --- /dev/null +++ b/src/core/shellArguments.ts @@ -0,0 +1,14 @@ +/** Quotes one literal argument; PowerShell syntax is not cmd.exe syntax. */ +export function quoteShellArgument( + value: string, + dialect: "posix" | "powershell", +): string { + const bare = + dialect === "powershell" ? /^[a-zA-Z0-9_./\\:-]+$/ : /^[a-zA-Z0-9_./-]+$/; + if (bare.test(value)) return value; + if (dialect === "powershell") { + // PowerShell treats smart apostrophes as quote delimiters too. + return `'${value.replace(/['\u2018-\u201b]/g, "$&$&")}'`; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/src/domains/flows/copyFlowActions.test.ts b/src/domains/flows/copyFlowActions.test.ts new file mode 100644 index 000000000..d9443bbf1 --- /dev/null +++ b/src/domains/flows/copyFlowActions.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, mock } from "bun:test"; +import { spawnSync } from "node:child_process"; + +import { flowsMessages } from "~/core/messages/index.js"; +import type { CopyToClipboard } from "~/shell/clipboard.js"; +import { formatClipboardPaths } from "~/shell/clipboardPaths.js"; + +import { copyFlowActions } from "./copyFlowActions.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +const row = (name: string, flowId: string | undefined): FlowsListRow => ({ + name, + target: "Web - Chrome", + file: `src/flows/${name}.flow.ts`, + env: undefined, + tags: undefined, + flowId, +}); + +/** The actions, over a clipboard that answers `outcome`. */ +function setup( + outcome: "copied" | "terminal" = "copied", + platform: NodeJS.Platform = "linux", +) { + const copy = mock(() => Promise.resolve(outcome)); + const actions = copyFlowActions(copy, (paths) => + formatClipboardPaths(paths, platform), + ); + const run = (key: string, rows: FlowsListRow[]) => + actions.find((action) => action.key === key)?.run(rows); + return { copy, run }; +} + +describe("copyFlowActions", () => { + it("copies one flow's path with Ctrl-Y, naming it", async () => { + const { copy, run } = setup(); + + const notice = await run("y", [row("a", undefined)]); + + expect(copy).toHaveBeenCalledWith("src/flows/a.flow.ts"); + expect(notice).toEqual({ + tone: "success", + text: "Copied src/flows/a.flow.ts", + }); + }); + + // So they paste straight into one command. + it("separates several paths with spaces, and counts them", async () => { + const { copy, run } = setup(); + + const notice = await run("y", [row("a", undefined), row("b", undefined)]); + + expect(copy).toHaveBeenCalledWith( + "src/flows/a.flow.ts src/flows/b.flow.ts", + ); + expect(notice).toEqual({ tone: "success", text: "Copied 2 paths" }); + }); + + it("copies the flow ids with Ctrl-O", async () => { + const { copy, run } = setup(); + + const notice = await run("o", [row("a", "id-1"), row("b", "id-2")]); + + expect(copy).toHaveBeenCalledWith("id-1 id-2"); + expect(notice).toEqual({ tone: "success", text: "Copied 2 ids" }); + }); + + for (const shell of ["sh", "bash", "zsh"]) { + it.skipIf(spawnSync(shell, ["-c", "exit 0"]).error !== undefined)( + `preserves each copied path as one literal argument in ${shell}`, + async () => { + const { copy, run } = setup(); + const files = [ + "src/flows/checkout cart.flow.ts", + "src/flows/customer's cart.flow.ts", + "src/flows/payments[1].flow.ts", + "src/flows/$USER.flow.ts", + "src/flows/$(printf injected).flow.ts", + "src/flows/`printf injected`.flow.ts", + "src/flows/semicolon; printf injected", + "src/flows/first\nsecond.flow.ts", + "src/flows/trailing.flow.ts\n", + "src/flows/carriage.flow.ts\r", + "src/flows/back\\slash.flow.ts", + 'src/flows/"quoted".flow.ts', + "#comment.flow.ts", + "~/literal.flow.ts", + "src/flows/{a,b}?.flow.ts", + "src/flows/!history.flow.ts", + ]; + + await run( + "y", + files.map((file) => ({ ...row("a", undefined), file })), + ); + const copied = copy.mock.calls[0]?.[0]; + expect(copied).toBeDefined(); + const result = spawnSync(shell, ["-c", `printf '%s\\0' ${copied}`], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.split("\0").slice(0, -1)).toEqual(files); + }, + ); + } + + for (const outcome of ["copied", "terminal"] as const) { + it(`names the Windows quoting format when ${outcome}`, async () => { + const { copy, run } = setup(outcome, "win32"); + + const notice = await run("y", [row("customer's cart", undefined)]); + + expect(copy).toHaveBeenCalledWith("'src/flows/customer''s cart.flow.ts'"); + expect(notice?.text).toEndWith(" · PowerShell syntax"); + }); + } + + it("says a flow has no id yet rather than copying nothing", async () => { + const { copy, run } = setup(); + + const notice = await run("o", [row("a", undefined)]); + + expect(notice).toEqual({ + tone: "warning", + text: flowsMessages.list.noFlowId, + }); + expect(copy).not.toHaveBeenCalled(); + }); + + it("copies the ids that are known, and warns about the rest", async () => { + const { copy, run } = setup(); + + const notice = await run("o", [ + row("a", "id-1"), + row("b", undefined), + row("c", undefined), + ]); + + expect(copy).toHaveBeenCalledWith("id-1"); + expect(notice).toEqual({ + tone: "warning", + text: "Copied id-1 · 2 flows had no id yet and were left out", + }); + }); + + it("says when the terminal was asked to copy instead", async () => { + const { run } = setup("terminal"); + + const notice = await run("y", [row("a", undefined), row("b", undefined)]); + + expect(notice).toEqual({ + tone: "success", + text: "Sent 2 paths to your terminal's clipboard", + }); + }); +}); diff --git a/src/domains/flows/copyFlowActions.ts b/src/domains/flows/copyFlowActions.ts new file mode 100644 index 000000000..e908915fd --- /dev/null +++ b/src/domains/flows/copyFlowActions.ts @@ -0,0 +1,63 @@ +import { flowsMessages } from "~/core/messages/index.js"; +import type { CopyToClipboard } from "~/shell/clipboard.js"; +import { formatClipboardPaths } from "~/shell/clipboardPaths.js"; +import type { FilterAction, FilterNotice } from "~/shell/ui/renderers/types.js"; + +import type { FlowsListRow } from "./renderListTable.js"; + +export function copyFlowActions( + copy: CopyToClipboard, + formatPaths = formatClipboardPaths, +): FilterAction[] { + // Quote individual paths before joining, so shell syntax stays literal. + const copied = async ( + values: readonly string[], + noun: "path" | "id", + ): Promise => { + const formatted = + noun === "path" + ? formatPaths(values) + : { text: values.join(" "), syntax: undefined }; + const message = + (await copy(formatted.text)) === "copied" + ? flowsMessages.list.copied(values, noun) + : flowsMessages.list.copiedViaTerminal(values, noun); + return { + tone: "success", + text: formatted.syntax ? `${message} · ${formatted.syntax}` : message, + }; + }; + + return [ + { + key: "y", + label: flowsMessages.list.copyPath, + run: (rows) => + copied( + rows.map((row) => row.file), + "path", + ), + }, + { + key: "o", + label: flowsMessages.list.copyId, + run: async (rows) => { + const ids = rows.flatMap((row) => + row.flowId === undefined ? [] : [row.flowId], + ); + if (ids.length === 0) { + return { tone: "warning", text: flowsMessages.list.noFlowId }; + } + const notice = await copied(ids, "id"); + const missing = rows.length - ids.length; + // The ids that are known still help, but the gap must not go unseen. + return missing === 0 + ? notice + : { + tone: "warning", + text: `${notice.text} · ${flowsMessages.list.idsLeftOut(missing)}`, + }; + }, + }, + ]; +} diff --git a/src/domains/flows/filterFlows.test.ts b/src/domains/flows/filterFlows.test.ts index d347c9504..94adebd7f 100644 --- a/src/domains/flows/filterFlows.test.ts +++ b/src/domains/flows/filterFlows.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import { flowsMessages } from "~/core/messages/index.js"; +import type { CopyToClipboard } from "~/shell/clipboard.js"; import { callsOf, fakeFilterList, @@ -32,6 +33,9 @@ function uiKeeping(kept: readonly FlowsListRow[] | undefined) { return { ui, offered: () => fake.calls[0] }; } +const copying = () => + mock(() => Promise.resolve("copied" as const)); + const written = (ui: UI): string => callsOf(ui.write) .map((call) => String(call[0])) @@ -41,7 +45,7 @@ describe("filterFlows", () => { it("offers every flow, searchable by its tags too", async () => { const { ui, offered } = uiKeeping(undefined); - await filterFlows(ui, [row()], { columns: 100 }); + await filterFlows(ui, [row()], { columns: 100 }, { copy: copying() }); expect(offered()?.items).toEqual([row()]); expect(ui.intro).toHaveBeenCalledWith("Flows"); @@ -52,10 +56,23 @@ describe("filterFlows", () => { expect(searchable).toContain("Smoke Tests"); }); + it("offers Ctrl-Y and Ctrl-O to copy paths and ids", async () => { + const { ui, offered } = uiKeeping(undefined); + const copy = copying(); + await filterFlows(ui, [row()], { columns: 100 }, { copy }); + + await offered() + ?.actions.find((action) => action.key === "y") + ?.run([row()]); + + expect(offered()?.actions.map((action) => action.key)).toEqual(["y", "o"]); + expect(copy).toHaveBeenCalledWith(file); + }); + it("describes the highlighted flow by id and full path", async () => { const { ui, offered } = uiKeeping(undefined); - await filterFlows(ui, [row()], { columns: 100 }); + await filterFlows(ui, [row()], { columns: 100 }, { copy: copying() }); expect(offered()?.detail(row({ flowId: "flow-123" }))).toBe( `flow-123 · ${file}`, @@ -68,7 +85,7 @@ describe("filterFlows", () => { row({ name: "Other", file: ".qawolf/env-a/src/flows/other.flow.ts" }), ]); - await filterFlows(ui, [row()], { columns: 100 }); + await filterFlows(ui, [row()], { columns: 100 }, { copy: copying() }); const out = written(ui); expect(out).toContain("View Order Items"); @@ -79,7 +96,7 @@ describe("filterFlows", () => { it("prints nothing more when the filter is cancelled", async () => { const { ui } = uiKeeping(undefined); - await filterFlows(ui, [row()], { columns: 100 }); + await filterFlows(ui, [row()], { columns: 100 }, { copy: copying() }); expect(written(ui)).toBe(""); expect(ui.outro).not.toHaveBeenCalled(); @@ -88,7 +105,7 @@ describe("filterFlows", () => { it("says no flows matched when the filter kept none", async () => { const { ui } = uiKeeping([]); - await filterFlows(ui, [row()], { columns: 100 }); + await filterFlows(ui, [row()], { columns: 100 }, { copy: copying() }); expect(ui.info).toHaveBeenCalledWith("No flows matched."); }); @@ -96,7 +113,7 @@ describe("filterFlows", () => { it("says no flows matched, without prompting, when there are none", async () => { const { ui, offered } = uiKeeping([]); - await filterFlows(ui, [], { columns: 100 }); + await filterFlows(ui, [], { columns: 100 }, { copy: copying() }); expect(ui.info).toHaveBeenCalledWith("No flows matched."); expect(offered()).toBeUndefined(); diff --git a/src/domains/flows/filterFlows.ts b/src/domains/flows/filterFlows.ts index 30271a956..558178dfc 100644 --- a/src/domains/flows/filterFlows.ts +++ b/src/domains/flows/filterFlows.ts @@ -1,7 +1,9 @@ import { flowsMessages, runnerMessages } from "~/core/messages/index.js"; +import { type CopyToClipboard, copyToClipboard } from "~/shell/clipboard.js"; import type { CommandResult } from "~/shell/commandContext.js"; import type { UI } from "~/shell/ui/index.js"; +import { copyFlowActions } from "./copyFlowActions.js"; import { fitListTable } from "./fitListTable.js"; import type { ListView } from "./listView.js"; import { renderFlowsList } from "./renderFlowsList.js"; @@ -16,7 +18,7 @@ export async function filterFlows( ui: UI, rows: readonly FlowsListRow[], view: Pick, - options: { readonly title?: string } = {}, + options: { readonly title?: string; readonly copy?: CopyToClipboard } = {}, ): Promise { if (rows.length === 0) { ui.info(runnerMessages.noFlowsMatched); @@ -38,6 +40,7 @@ export async function filterFlows( ], table: fitListTable, describeCount: flowsMessages.list.filterCount, + actions: copyFlowActions(options.copy ?? copyToClipboard), // The highlighted flow in full: the table may have cut its path. detail: (row) => `${row.flowId ?? flowsMessages.list.noFlowIdShort} · ${row.file}`, diff --git a/src/shell/clipboard.test.ts b/src/shell/clipboard.test.ts new file mode 100644 index 000000000..219eb2d23 --- /dev/null +++ b/src/shell/clipboard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { createCopyToClipboard } from "./clipboard.js"; +import type { SpawnFn } from "./spawn.js"; + +/** A spawn where only the named tools succeed. */ +function spawnAnswering(working: readonly string[]) { + return mock((cmd) => + Promise.resolve({ + exitCode: working.includes(cmd) ? 0 : 1, + stdout: "", + stderr: "", + }), + ); +} + +const tried = (spawn: ReturnType): string[] => + spawn.mock.calls.map((call) => call[0]); + +describe("createCopyToClipboard", () => { + it("copies with pbcopy on macOS, passing the text on stdin", async () => { + const spawn = spawnAnswering(["pbcopy"]); + const copy = createCopyToClipboard({ + platform: "darwin", + env: {}, + spawn, + writeTerminal: mock(), + }); + + expect(await copy("src/flows/a.flow.ts")).toBe("copied"); + expect(spawn).toHaveBeenCalledWith("pbcopy", [], { + platform: "darwin", + stdin: "src/flows/a.flow.ts", + }); + }); + + it("tries wl-copy first under Wayland, then xclip", async () => { + const spawn = spawnAnswering(["xclip"]); + const copy = createCopyToClipboard({ + platform: "linux", + env: { WAYLAND_DISPLAY: "wayland-0" }, + spawn, + writeTerminal: mock(), + }); + + expect(await copy("x")).toBe("copied"); + expect(tried(spawn)).toEqual(["wl-copy", "xclip"]); + }); + + it("skips wl-copy without Wayland", async () => { + const spawn = spawnAnswering(["xclip"]); + const copy = createCopyToClipboard({ + platform: "linux", + env: {}, + spawn, + writeTerminal: mock(), + }); + + await copy("x"); + expect(tried(spawn)).toEqual(["xclip"]); + }); + + // Over SSH there is no local clipboard tool, but the terminal has one. + it("asks the terminal to copy when no tool works", async () => { + const writeTerminal = mock(); + const copy = createCopyToClipboard({ + platform: "linux", + env: {}, + spawn: spawnAnswering([]), + writeTerminal, + }); + + expect(await copy("hello")).toBe("terminal"); + expect(writeTerminal).toHaveBeenCalledWith("\x1b]52;c;aGVsbG8=\x07"); + }); + + it("treats a tool that cannot start as missing", async () => { + const writeTerminal = mock(); + const copy = createCopyToClipboard({ + platform: "darwin", + env: {}, + spawn: mock(() => Promise.reject(new Error("ENOENT"))), + writeTerminal, + }); + + expect(await copy("x")).toBe("terminal"); + expect(writeTerminal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shell/clipboard.ts b/src/shell/clipboard.ts new file mode 100644 index 000000000..c19a56aad --- /dev/null +++ b/src/shell/clipboard.ts @@ -0,0 +1,56 @@ +import { defaultSpawn, type SpawnFn } from "./spawn.js"; + +/** "terminal" when no clipboard tool answered and the terminal was asked instead. */ +type ClipboardOutcome = "copied" | "terminal"; + +export type CopyToClipboard = (text: string) => Promise; + +type Deps = { + readonly platform: NodeJS.Platform; + readonly env: Readonly>; + readonly spawn: SpawnFn; + /** Where the terminal escape goes when no clipboard tool is installed. */ + readonly writeTerminal: (text: string) => void; +}; + +type Tool = { readonly cmd: string; readonly args: string[] }; + +// The system clipboard tools for each platform, most likely first. +function toolsFor(platform: NodeJS.Platform, env: Deps["env"]): Tool[] { + if (platform === "darwin") return [{ cmd: "pbcopy", args: [] }]; + if (platform === "win32") return [{ cmd: "clip", args: [] }]; + const tools: Tool[] = []; + if (env["WAYLAND_DISPLAY"]) tools.push({ cmd: "wl-copy", args: [] }); + tools.push( + { cmd: "xclip", args: ["-selection", "clipboard"] }, + { cmd: "xsel", args: ["--clipboard", "--input"] }, + ); + return tools; +} + +export function createCopyToClipboard(deps: Deps): CopyToClipboard { + return async (text) => { + for (const tool of toolsFor(deps.platform, deps.env)) { + // A tool that is not installed is just the next one to try. + const result = await deps + .spawn(tool.cmd, tool.args, { platform: deps.platform, stdin: text }) + .catch(() => undefined); + if (result?.exitCode === 0) return "copied"; + } + // No tool answered — over SSH, or on a machine without one. OSC 52 asks + // the terminal itself to set the clipboard, which most modern ones do; it + // cannot report back, so the caller is told the terminal was asked. + const encoded = Buffer.from(text, "utf8").toString("base64"); + deps.writeTerminal(`\x1b]52;c;${encoded}\x07`); + return "terminal"; + }; +} + +export const copyToClipboard: CopyToClipboard = createCopyToClipboard({ + platform: process.platform, + env: process.env, + spawn: defaultSpawn, + writeTerminal: (text) => { + process.stdout.write(text); + }, +}); diff --git a/src/shell/clipboardPaths.test.ts b/src/shell/clipboardPaths.test.ts new file mode 100644 index 000000000..69fbc4691 --- /dev/null +++ b/src/shell/clipboardPaths.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; + +import { formatClipboardPaths } from "./clipboardPaths.js"; + +describe("formatClipboardPaths", () => { + it("keeps simple POSIX paths bare and space separated", () => { + expect( + formatClipboardPaths(["src/a.flow.ts", "src/b.flow.ts"], "linux"), + ).toEqual({ text: "src/a.flow.ts src/b.flow.ts", syntax: undefined }); + }); + + it("keeps simple Windows paths bare", () => { + expect(formatClipboardPaths(["C:\\flows\\a.flow.ts"], "win32")).toEqual({ + text: "C:\\flows\\a.flow.ts", + syntax: undefined, + }); + }); + + it("labels PowerShell syntax when Windows paths need quoting", () => { + expect( + formatClipboardPaths( + ["C:\\flows\\customer's cart.flow.ts", "src/flows/payments[1].flow.ts"], + "win32", + ), + ).toEqual({ + text: "'C:\\flows\\customer''s cart.flow.ts' 'src/flows/payments[1].flow.ts'", + syntax: "PowerShell syntax", + }); + }); + + it("quotes PowerShell expressions, cmd expansions, and smart apostrophes literally", () => { + expect( + formatClipboardPaths( + [ + "src/$env:USERNAME.flow.ts", + "src/`Write-Output injected`.flow.ts", + "src/$(Write-Output injected).flow.ts", + "src/%USERNAME%!flow!.ts", + "src/it’s ‘quoted‚‛.flow.ts", + "src/first\nsecond.flow.ts", + ], + "win32", + ), + ).toEqual({ + text: "'src/$env:USERNAME.flow.ts' 'src/`Write-Output injected`.flow.ts' 'src/$(Write-Output injected).flow.ts' 'src/%USERNAME%!flow!.ts' 'src/it’’s ‘‘quoted‚‚‛‛.flow.ts' 'src/first\nsecond.flow.ts'", + syntax: "PowerShell syntax", + }); + }); + + it("defaults to the current platform's clipboard format", () => { + const paths = ["src/it's a flow.ts"]; + expect(formatClipboardPaths(paths)).toEqual( + formatClipboardPaths(paths, process.platform), + ); + }); + + it.skipIf( + spawnSync("pwsh", ["-NoProfile", "-Command", "exit 0"]).error !== undefined, + )("round trips quoted paths through PowerShell when installed", () => { + const paths = [ + "C:\\flows\\a.flow.ts", + "C:\\flows\\customer's cart.flow.ts", + "src/$(Write-Output injected).flow.ts", + "src/`Write-Output injected`.flow.ts", + "src/%USERNAME%!flow!.ts", + "src/it’s ‘quoted‚‛.flow.ts", + "src/first\nsecond.flow.ts", + "#comment.flow.ts", + "~/literal.flow.ts", + ]; + const { text } = formatClipboardPaths(paths, "win32"); + const result = spawnSync( + "pwsh", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `function Read-Arguments { ConvertTo-Json -Compress -InputObject $args }; Read-Arguments ${text}`, + ], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const actual: unknown = JSON.parse(result.stdout); + expect(actual).toEqual(paths); + }); +}); diff --git a/src/shell/clipboardPaths.ts b/src/shell/clipboardPaths.ts new file mode 100644 index 000000000..bf73c9ab1 --- /dev/null +++ b/src/shell/clipboardPaths.ts @@ -0,0 +1,21 @@ +import { quoteShellArgument } from "~/core/shellArguments.js"; + +/** A fixed paste format, since the launching shell cannot be inferred from OS/env. */ +export function formatClipboardPaths( + paths: readonly string[], + platform: NodeJS.Platform = process.platform, +): { + text: string; + syntax: string | undefined; +} { + const dialect = platform === "win32" ? "powershell" : "posix"; + const quoted = paths.map((path) => quoteShellArgument(path, dialect)); + return { + text: quoted.join(" "), + syntax: + dialect === "powershell" && + quoted.some((path, index) => path !== paths[index]) + ? "PowerShell syntax" + : undefined, + }; +} diff --git a/src/shell/ui/renderers/filterActions.test.ts b/src/shell/ui/renderers/filterActions.test.ts new file mode 100644 index 000000000..0188062d9 --- /dev/null +++ b/src/shell/ui/renderers/filterActions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { sleep } from "~/core/sleep.js"; + +import { actionHints, createActionRunner } from "./filterActions.js"; +import type { FilterAction, FilterNotice } from "./types.js"; + +const done = (text: string): Promise => + Promise.resolve({ tone: "success", text }); + +const copyPath: FilterAction = { + key: "y", + label: "copy path", + run: (items) => done(`Copied ${items.join(" ")}`), +}; + +const runner = (actions: FilterAction[] = [copyPath], holdMs = 20) => { + const changed = mock(); + return { changed, ...createActionRunner({ actions, holdMs, changed }) }; +}; + +describe("createActionRunner", () => { + it("ignores an action that completes after disposal", async () => { + const pending = Promise.withResolvers(); + const r = runner([{ key: "y", label: "copy", run: () => pending.promise }]); + r.onKey({ name: "y", ctrl: true }, ["a"]); + r.dispose(); + pending.resolve({ tone: "success", text: "late" }); + await sleep(30); + expect(r.changed).not.toHaveBeenCalled(); + expect(r.notice()).toBeUndefined(); + }); + it("runs the action for Ctrl and its key, on the items it is given", async () => { + const r = runner(); + + r.onKey({ name: "y", ctrl: true }, ["src/a.flow.ts", "src/b.flow.ts"]); + await sleep(1); + + expect(r.notice()).toEqual({ + tone: "success", + text: "Copied src/a.flow.ts src/b.flow.ts", + }); + expect(r.changed).toHaveBeenCalled(); + r.dispose(); + }); + + it("ignores the letter without Ctrl, since that is typing", async () => { + const run = mock(() => done("x")); + const r = runner([{ key: "y", label: "copy", run }]); + + r.onKey({ name: "y", ctrl: false }, ["a"]); + await sleep(1); + + expect(run).not.toHaveBeenCalled(); + r.dispose(); + }); + + it("does nothing when there is nothing to act on", async () => { + const run = mock(() => done("x")); + const r = runner([{ key: "y", label: "copy", run }]); + + r.onKey({ name: "y", ctrl: true }, []); + await sleep(1); + + expect(run).not.toHaveBeenCalled(); + r.dispose(); + }); + + it("clears the confirmation after a moment", async () => { + const r = runner(); + + r.onKey({ name: "y", ctrl: true }, ["a"]); + await sleep(60); + + expect(r.notice()).toBeUndefined(); + r.dispose(); + }); + + it("reports a failed action instead of throwing", async () => { + const r = runner([ + { + key: "y", + label: "copy path", + run: () => Promise.reject(new Error("no")), + }, + ]); + + r.onKey({ name: "y", ctrl: true }, ["a"]); + await sleep(1); + + expect(r.notice()).toEqual({ + tone: "warning", + text: "Could not copy path.", + }); + r.dispose(); + }); +}); + +describe("actionHints", () => { + it("names each key with its label", () => { + expect( + actionHints([ + copyPath, + { key: "o", label: "copy id", run: copyPath.run }, + ]), + ).toBe("^Y copy path · ^O copy id"); + }); +}); diff --git a/src/shell/ui/renderers/filterActions.ts b/src/shell/ui/renderers/filterActions.ts new file mode 100644 index 000000000..6dc49e67d --- /dev/null +++ b/src/shell/ui/renderers/filterActions.ts @@ -0,0 +1,61 @@ +import type { FilterAction, FilterNotice } from "./types.js"; + +type Key = { + readonly name?: string | undefined; + readonly ctrl?: boolean | undefined; +}; + +/** "^Y copy path · ^O copy id": the footer's reminder of what the keys do. */ +export function actionHints( + actions: readonly FilterAction[], +): string { + return actions + .map((action) => `^${action.key.toUpperCase()} ${action.label}`) + .join(" · "); +} + +export function createActionRunner(args: { + readonly actions: readonly FilterAction[]; + readonly holdMs: number; + readonly changed: () => void; +}): { + onKey: (key: Key, targets: readonly Item[]) => void; + notice: () => FilterNotice | undefined; + dispose: () => void; +} { + let notice: FilterNotice | undefined; + let timer: ReturnType | undefined; + let disposed = false; + + const show = (next: FilterNotice): void => { + if (disposed) return; + notice = next; + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + notice = undefined; + timer = undefined; + args.changed(); + }, args.holdMs); + args.changed(); + }; + + return { + onKey(key, targets) { + if (key.ctrl !== true || targets.length === 0) return; + const action = args.actions.find( + (candidate) => candidate.key === key.name, + ); + if (action === undefined) return; + void action + .run(targets) + .then(show, () => + show({ tone: "warning", text: `Could not ${action.label}.` }), + ); + }, + notice: () => notice, + dispose() { + disposed = true; + if (timer !== undefined) clearTimeout(timer); + }, + }; +} diff --git a/src/shell/ui/renderers/filterFrame.test.ts b/src/shell/ui/renderers/filterFrame.test.ts index 644fef61b..9a669a2fd 100644 --- a/src/shell/ui/renderers/filterFrame.test.ts +++ b/src/shell/ui/renderers/filterFrame.test.ts @@ -14,7 +14,11 @@ const frame = (over: Partial = {}): FilterFrame => ({ rowCount: 20, line: (index) => `flow ${String(index + 1)}`, focus: 0, + marked: new Set(), + markedCount: 0, detail: "id flow-1 · src/flows/1.flow.ts", + notice: undefined, + hints: "^Y copy path", columns: 100, // Leaves room for 4 rows once the frame's other lines are counted. terminalRows: 12, @@ -59,6 +63,28 @@ describe("renderFilterFrame", () => { expect(lines()).toContain("│ id flow-1 · src/flows/1.flow.ts"); }); + it("shows a confirmation in place of the count while it lasts", () => { + const footer = lines({ + notice: { tone: "success", text: "Copied src/flows/1.flow.ts" }, + }).at(-2); + expect(footer).toContain("✓ Copied src/flows/1.flow.ts"); + expect(footer).not.toContain("20 of 100"); + }); + + // "No flow id yet" is not a success; a green tick would say it was. + it("marks a notice that is not a success with a warning sign", () => { + const footer = lines({ + notice: { tone: "warning", text: "No flow id yet." }, + }).at(-2); + expect(footer).toContain("▲ No flow id yet."); + expect(footer).not.toContain("✓"); + }); + + it("lists the action keys beside the navigation keys", () => { + expect(lines().join("\n")).toContain("^Y copy path"); + expect(lines().at(-1)).toContain("Tab mark · ↑/↓ move"); + }); + // A frame taller than the screen scrolls the terminal. it("never draws more lines than the terminal has", () => { expect(lines().length).toBeLessThanOrEqual(12); @@ -87,6 +113,14 @@ describe("renderFilterFrame", () => { expect(Bun.stringWidth(line)).toBeLessThanOrEqual(40); }); + it("keeps Enter and Esc instructions visible at 80 columns", () => { + const out = lines({ columns: 80, hints: "^Y copy path · ^O copy id" }).join( + "\n", + ); + expect(out).toContain("Enter prints matches"); + expect(out).toContain("Esc cancels"); + }); + it("keeps multiline metadata and controls on single display lines", () => { const over = { message: "Filter\nflows", @@ -96,6 +130,7 @@ describe("renderFilterFrame", () => { line: () => "Log in\nwith password\t!", detail: "src/flow\r\nname.flow.ts", count: "20\nflows", + hints: "copy\bpath", terminalRows: 24, }; const out = lines(over); @@ -108,6 +143,28 @@ describe("renderFilterFrame", () => { expect(over.search).toBe("with\npassword"); }); + it("marks marked rows in the gutter, the highlighted one included", () => { + const out = lines({ focus: 1, marked: new Set([1, 2]), markedCount: 2 }); + expect(out.find((line) => line.includes("flow 2"))).toStartWith("│◼ "); + expect(out.find((line) => line.includes("flow 3"))).toStartWith("│◼ "); + expect(out.find((line) => line.includes("flow 1"))).toStartWith("│ "); + }); + + // Marks can come from other searches, so the count includes hidden ones. + it("counts the marked items, and says Enter prints them", () => { + const footer = lines({ + marked: new Set([0]), + markedCount: 3, + columns: 140, + }).at(-2); + expect(footer).toContain("20 of 100 flows · 3 marked"); + expect(lines({ markedCount: 3 }).at(-1)).toContain("Enter prints marked"); + }); + + it("says Enter prints the matches when nothing is marked", () => { + expect(lines().at(-1)).toContain("Enter prints matches"); + }); + it("says when nothing matches", () => { expect(lines({ rowCount: 0, count: "0 of 100 flows" })).toContain( "│ Nothing matches.", @@ -122,6 +179,12 @@ describe("renderFilterFrame", () => { expect(out).toHaveLength(3); }); + it("collapses to the marked count when marked items were kept", () => { + expect(lines({ state: "submit", markedCount: 3 }).at(-1)).toContain( + "3 marked", + ); + }); + it("collapses to the struck-out search when cancelled", () => { const out = lines({ state: "cancel" }); expect(out.join("\n")).not.toContain("flow 1"); diff --git a/src/shell/ui/renderers/filterFrame.ts b/src/shell/ui/renderers/filterFrame.ts index 6f757d8db..11610ef1c 100644 --- a/src/shell/ui/renderers/filterFrame.ts +++ b/src/shell/ui/renderers/filterFrame.ts @@ -1,9 +1,11 @@ -import { S_BAR, S_BAR_END, symbol } from "@clack/prompts"; +import { S_BAR, S_BAR_END, S_CHECKBOX_SELECTED, symbol } from "@clack/prompts"; -import { cyan, dim, inverse, strike } from "~/core/ansi.js"; +import { cyan, dim, green, inverse, strike } from "~/core/ansi.js"; import { clipColumns, displayWidth, padColumns } from "~/core/displayWidth.js"; +import { noticeMark, noticePaint } from "./noticeStyle.js"; import { singleLine } from "./singleLine.js"; +import type { FilterNotice } from "./types.js"; export const frameGutter = `${S_BAR} `; @@ -16,27 +18,37 @@ export type FilterFrame = { readonly rowCount: number; readonly line: (index: number) => string; readonly focus: number; + readonly marked: ReadonlySet; + readonly markedCount: number; readonly detail: string | undefined; + readonly notice: FilterNotice | undefined; + readonly hints: string; readonly columns: number; readonly terminalRows: number; readonly count: string; }; +const markedSummary = (count: number): string => `${String(count)} marked`; + function collapsed(frame: FilterFrame, title: string): string[] { - const kept = `${frame.search === "" ? "" : `${frame.search} `}${frame.count}`; + const kept = + frame.markedCount > 0 + ? markedSummary(frame.markedCount) + : `${frame.search === "" ? "" : `${frame.search} `}${frame.count}`; const typed = frame.state === "submit" ? dim(kept) : strike(frame.search); return [S_BAR, title, `${frameGutter}${typed}`]; } -function rowGutter(focused: boolean): string { - const sign = focused ? cyan("›") : " "; +function rowGutter(focused: boolean, marked: boolean): string { + const sign = marked ? green(S_CHECKBOX_SELECTED) : focused ? cyan("›") : " "; return `${S_BAR}${sign} `; } -function keyHints(room: number): string { - const enter = "Enter prints matches"; +function keyHints(frame: FilterFrame, room: number): string { + const enter = + frame.markedCount > 0 ? "Enter prints marked" : "Enter prints matches"; const essential = `${enter} · Esc cancels`; - const full = `↑/↓ move · ${essential}`; + const full = `Tab mark · ↑/↓ move · ${essential}`; if (displayWidth(full) <= room) return full; return displayWidth(essential) <= room ? essential @@ -90,7 +102,7 @@ export function renderFilterFrame(frame: FilterFrame): string { const at = start + index; const line = clipColumns(singleLine(frame.line(at)), room); const focused = at === frame.focus; - return `${rowGutter(focused)}${focused ? inverse(padColumns(line, room)) : line}`; + return `${rowGutter(focused, frame.marked.has(at))}${focused ? inverse(padColumns(line, room)) : line}`; }); if (frame.rowCount === 0 && visible > 0) shown.push(`${frameGutter}${dim("Nothing matches.")}`); @@ -99,12 +111,20 @@ export function renderFilterFrame(frame: FilterFrame): string { frame.rowCount > visible && count > 0 ? ` · rows ${String(start + 1)}–${String(start + count)}` : ""; - const statusLine = `${frameGutter}${dim(`${frame.count}${position}`)}`; + const marks = + frame.markedCount > 0 ? ` · ${markedSummary(frame.markedCount)}` : ""; + const status = + frame.notice === undefined + ? `${frame.count}${marks}${position}` + : `${noticeMark[frame.notice.tone]} ${frame.notice.text}`; + const paint = + frame.notice === undefined ? dim : noticePaint[frame.notice.tone]; + const statusLine = `${frameGutter}${paint(status)}${frame.hints === "" ? "" : dim(` · ${frame.hints}`)}`; return fit([ ...before, ...shown, ...detail, ...(headings ? [statusLine] : []), - `${S_BAR_END} ${dim(keyHints(room))}`, + `${S_BAR_END} ${dim(keyHints(frame, room))}`, ]); } diff --git a/src/shell/ui/renderers/filterInput.ts b/src/shell/ui/renderers/filterInput.ts new file mode 100644 index 000000000..c376e404e --- /dev/null +++ b/src/shell/ui/renderers/filterInput.ts @@ -0,0 +1,33 @@ +import { emitKeypressEvents, type Key } from "node:readline"; +import { PassThrough, type Readable } from "node:stream"; + +export function filterInput( + source: Readable, + consume: (key: Key) => boolean, +): { input: PassThrough; dispose: () => void } { + const input = new PassThrough(); + const flowing = source.readableFlowing; + Object.defineProperty(input, "isTTY", { + get: () => "isTTY" in source && source.isTTY, + }); + Object.defineProperty(input, "setRawMode", { + value: (raw: boolean) => { + if ("setRawMode" in source && typeof source.setRawMode === "function") + source.setRawMode(raw); + }, + }); + const onKey = (text: string | undefined, key: Key): void => { + // Readline listens on the proxy, so action keys never reach its editor. + if (!consume(key)) input.emit("keypress", text, key); + }; + emitKeypressEvents(source); + source.on("keypress", onKey); + return { + input, + dispose() { + source.off("keypress", onKey); + if (!flowing) source.pause(); + input.destroy(); + }, + }; +} diff --git a/src/shell/ui/renderers/filterList.keys.test.ts b/src/shell/ui/renderers/filterList.keys.test.ts new file mode 100644 index 000000000..df6dc3a1f --- /dev/null +++ b/src/shell/ui/renderers/filterList.keys.test.ts @@ -0,0 +1,21 @@ +import { expect, it, mock } from "bun:test"; + +import { fakeTerminal, open, typed } from "./filterList.testUtils.js"; +import type { FilterNotice } from "./types.js"; + +it("consumes Ctrl-Y before readline can yank killed search text", async () => { + const run = mock(() => + Promise.resolve({ tone: "success", text: "copied" }), + ); + const { input, result } = open(fakeTerminal(), [ + { key: "y", label: "copy", run }, + ]); + await typed(input, "alpha"); + await typed(input, "\x15"); + await typed(input, "\x1b[B"); + await typed(input, "\x19"); + input.write("\r"); + const kept = await result; + expect(run).toHaveBeenCalledWith(["beta"]); + expect(kept).toEqual({ ok: true, value: ["alpha", "beta", "gamma"] }); +}); diff --git a/src/shell/ui/renderers/filterList.test.ts b/src/shell/ui/renderers/filterList.test.ts index beeae7422..e06efa32a 100644 --- a/src/shell/ui/renderers/filterList.test.ts +++ b/src/shell/ui/renderers/filterList.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import { PassThrough } from "node:stream"; import { sleep } from "~/core/sleep.js"; import { createFilterList } from "./filterList.js"; import { fakeTerminal, open, typed, paints } from "./filterList.testUtils.js"; +import type { FilterNotice } from "./types.js"; const enterAltScreen = "\x1b[?1049h"; const leaveAltScreen = "\x1b[?1049l"; @@ -64,6 +65,7 @@ describe("createFilterList", () => { table: () => ({ header: "name", line: (item) => item }), describeCount: () => "1 of 1", detail: (item) => item, + actions: [], }); } catch (error) { caught = error; @@ -89,6 +91,94 @@ describe("createFilterList", () => { expect(after).not.toContain("gamma"); }); + it("runs an action on the highlighted row and shows its confirmation", async () => { + const terminal = fakeTerminal(); + const run = mock((items: readonly string[]) => + Promise.resolve({ + tone: "success", + text: `Copied ${items.join(" ")}`, + }), + ); + const { input, result } = open(terminal, [ + { key: "y", label: "copy", run }, + ]); + + await typed(input, "\x1b[B"); // ↓ moves the highlight to "beta" + await typed(input, "\x19"); // Ctrl-Y + + expect(run).toHaveBeenCalledWith(["beta"]); + expect(paints(terminal).at(-1)).toContain("Copied beta"); + input.write("\x03"); + await result; + }); + + it("marks rows with Tab, moving down, and keeps the marked on Enter", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "\t"); // marks alpha, moves to beta + await typed(input, "\x1b[B"); // on to gamma + await typed(input, "\t"); + input.write("\r"); + + expect(await result).toEqual({ ok: true, value: ["alpha", "gamma"] }); + }); + + // Tab is the mark key, so it must not end up in the search. + it("does not type Tab into the search", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "a\t"); + + expect(paints(terminal).at(-1)).toContain("Search: a█"); + input.write("\x03"); + await result; + }); + + it("keeps marks across searches, in list order", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "gam"); + await typed(input, "\t"); + await typed(input, "\x7f\x7f\x7fal"); + await typed(input, "\t"); + input.write("\r"); + + expect(await result).toEqual({ ok: true, value: ["alpha", "gamma"] }); + }); + + it("runs an action on the marked rows when there are any", async () => { + const terminal = fakeTerminal(); + const run = mock(() => + Promise.resolve({ tone: "success", text: "done" }), + ); + const { input, result } = open(terminal, [ + { key: "y", label: "copy", run }, + ]); + + await typed(input, "\t\t"); // alpha, then beta + await typed(input, "\x19"); // Ctrl-Y + + expect(run).toHaveBeenCalledWith(["alpha", "beta"]); + input.write("\x03"); + await result; + }); + + // clack's cursor wraps, so moving on from the last row would jump to the top. + it("stays on the last row when Tab marks it", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "\x1b[B\x1b[B"); // gamma, the last + await typed(input, "\t"); + + expect(paints(terminal).at(-1)).toContain("about gamma"); + input.write("\x03"); + await result; + }); + it("describes the highlighted row", async () => { const terminal = fakeTerminal(); const { input, result } = open(terminal); diff --git a/src/shell/ui/renderers/filterList.testUtils.ts b/src/shell/ui/renderers/filterList.testUtils.ts index 849b519d1..5b95a6870 100644 --- a/src/shell/ui/renderers/filterList.testUtils.ts +++ b/src/shell/ui/renderers/filterList.testUtils.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { sleep } from "~/core/sleep.js"; import { createFilterList } from "./filterList.js"; +import type { FilterAction } from "./types.js"; const frameStart = "\x1b[?2026h"; export function fakeTerminal() { const writes: string[] = []; @@ -24,7 +25,10 @@ export function fakeTerminal() { } /** Opens the real prompt against fake streams; type into `input` to drive it. */ -export function open(terminal: ReturnType) { +export function open( + terminal: ReturnType, + actions: FilterAction[] = [], +) { const input = new PassThrough(); const previousTerm = process.env["TERM"]; process.env["TERM"] = "xterm-256color"; @@ -34,6 +38,7 @@ export function open(terminal: ReturnType) { searchText: (item) => [item], table: () => ({ header: "name", line: (item) => item }), describeCount: (matched, total) => `${String(matched)} of ${String(total)}`, + actions, detail: (item) => `about ${item}`, }); if (previousTerm === undefined) delete process.env["TERM"]; diff --git a/src/shell/ui/renderers/filterList.ts b/src/shell/ui/renderers/filterList.ts index 0e9861de1..8c8d5e572 100644 --- a/src/shell/ui/renderers/filterList.ts +++ b/src/shell/ui/renderers/filterList.ts @@ -7,7 +7,9 @@ import type { OutputMode } from "~/shell/ui/env.js"; import { openAltScreen } from "./altScreen.js"; import { assertHumanMode } from "./assertHumanMode.js"; import { type TerminalStream, withDebouncedResize } from "./debouncedResize.js"; +import { actionHints, createActionRunner } from "./filterActions.js"; import { createFrameDrawer } from "./filterView.js"; +import { filterInput } from "./filterInput.js"; import type { FilterListArgs, FilterListFn, PromptResult } from "./types.js"; type Deps = { @@ -17,6 +19,7 @@ type Deps = { }; const resizeSettleMs = 100; +const noticeHoldMs = 2500; const isDone = (state: string): boolean => state === "submit" || state === "cancel"; @@ -38,15 +41,50 @@ export function createFilterList(deps: Deps): FilterListFn { args.searchText(option.item), ); - const draw = createFrameDrawer(args, output); + // Marked items, by their place in the full list rather than in the + // matches, so a mark outlives the search that found it. + const marked = new Set(); + const markedItems = (): Item[] => + options + .filter((option) => marked.has(option.value)) + .map((option) => option.item); + + let repaint = (): void => {}; + const actions = createActionRunner({ + actions: args.actions, + holdMs: noticeHoldMs, + changed: () => repaint(), + }); + const draw = createFrameDrawer(args, output, { + hints: actionHints(args.actions), + notice: actions.notice, + marked, + }); const screen = openAltScreen(output); + let keyboard: ReturnType | undefined; try { + keyboard = filterInput(deps.input ?? process.stdin, (key) => { + if ( + !key.ctrl || + !args.actions.some((action) => action.key === key.name) + ) + return false; + const focused = prompt.filteredOptions[prompt.cursor]; + const targets = + marked.size > 0 + ? markedItems() + : focused === undefined + ? [] + : [focused.item]; + actions.onKey(key, targets); + return true; + }); const prompt = new AutocompletePrompt({ options, filter: matches, output, - input: deps.input ?? process.stdin, + input: keyboard.input, // Paint whole frames after resizes; returning an empty frame prevents // clack from diffing against its stale pre-resize screen. render() { @@ -54,7 +92,26 @@ export function createFilterList(deps: Deps): FilterListFn { return ""; }, }); + repaint = () => { + if (!isDone(prompt.state)) screen.paint(draw(prompt)); + }; + // By now clack has handled the key; it draws once this returns. + prompt.on("key", (_char, key) => { + const focused = prompt.filteredOptions[prompt.cursor]; + if (key.name !== "tab") return; + // clack takes Tab back out of the search, so it is free to mark with. + if (focused === undefined) return; + if (!marked.delete(focused.value)) marked.add(focused.value); + // Avoid clack wrapping from the last row back to the first. + if (prompt.cursor < prompt.filteredOptions.length - 1) { + // clack keeps its cursor private, so the move is a synthetic ↓ key. + prompt.emit("key", undefined, { name: "down" }); + } + }); + const result = await prompt.prompt().finally(() => { + actions.dispose(); + keyboard?.dispose(); screen.close(); }); @@ -65,9 +122,14 @@ export function createFilterList(deps: Deps): FilterListFn { if (isCancel(result)) return { ok: false }; return { ok: true, - value: prompt.filteredOptions.map((option) => option.item), + value: + marked.size > 0 + ? markedItems() + : prompt.filteredOptions.map((option) => option.item), }; } finally { + actions.dispose(); + keyboard?.dispose(); screen.close(); } } finally { diff --git a/src/shell/ui/renderers/filterView.test.ts b/src/shell/ui/renderers/filterView.test.ts index ca184bfe2..da3f5c025 100644 --- a/src/shell/ui/renderers/filterView.test.ts +++ b/src/shell/ui/renderers/filterView.test.ts @@ -15,9 +15,11 @@ it("formats only visible rows and reuses them until width changes", () => { table, searchText: (item) => [item], describeCount: (matched) => String(matched), + actions: [], detail: (item) => item, }, output, + { hints: "", notice: () => undefined, marked: new Set() }, ); const view = { state: "active" as const, diff --git a/src/shell/ui/renderers/filterView.ts b/src/shell/ui/renderers/filterView.ts index 5727c666d..aebb2961f 100644 --- a/src/shell/ui/renderers/filterView.ts +++ b/src/shell/ui/renderers/filterView.ts @@ -8,7 +8,7 @@ import { frameGutter, renderFilterFrame, } from "./filterFrame.js"; -import type { FilterListArgs, FilterTable } from "./types.js"; +import type { FilterListArgs, FilterNotice, FilterTable } from "./types.js"; export type PromptView = { readonly state: FilterFrame["state"]; @@ -24,6 +24,12 @@ export type PromptView = { export function createFrameDrawer( args: FilterListArgs, output: Writable, + status: { + readonly hints: string; + readonly notice: () => FilterNotice | undefined; + /** Marked items, by their place in `args.items`. */ + readonly marked: ReadonlySet; + }, ): (view: PromptView, state?: FilterFrame["state"]) => string { // Laid out from every item rather than the current matches, so columns // hold still while the list narrows. Redone only on a resize. @@ -59,8 +65,16 @@ export function createFrameDrawer( return line; }, focus: view.cursor, + marked: new Set( + view.filteredOptions.flatMap((option, line) => + status.marked.has(option.value) ? [line] : [], + ), + ), + markedCount: status.marked.size, detail: highlighted === undefined ? undefined : args.detail(highlighted.item), + notice: status.notice(), + hints: status.hints, columns, terminalRows: getRows(output), count: args.describeCount(view.filteredOptions.length, args.items.length), diff --git a/src/shell/ui/renderers/noticeStyle.ts b/src/shell/ui/renderers/noticeStyle.ts new file mode 100644 index 000000000..b807208c9 --- /dev/null +++ b/src/shell/ui/renderers/noticeStyle.ts @@ -0,0 +1,15 @@ +import { green, yellow } from "~/core/ansi.js"; + +import type { FilterNotice } from "./types.js"; + +export const noticeMark: Record = { + success: "✓", + warning: "▲", +}; +export const noticePaint: Record< + FilterNotice["tone"], + (text: string) => string +> = { + success: green, + warning: yellow, +}; diff --git a/src/shell/ui/renderers/types.ts b/src/shell/ui/renderers/types.ts index 31f7b85cb..8159d41f1 100644 --- a/src/shell/ui/renderers/types.ts +++ b/src/shell/ui/renderers/types.ts @@ -6,6 +6,25 @@ export type FilterTable = { readonly line: (item: Item) => string; }; +/** What the footer says for a moment after an action. */ +export type FilterNotice = { + readonly tone: "success" | "warning"; + readonly text: string; +}; + +/** Something Ctrl plus a letter does to the marked items. */ +export type FilterAction = { + /** The letter pressed with Ctrl: "y" for Ctrl-Y. */ + readonly key: string; + /** Shown in the footer, e.g. "copy path". */ + readonly label: string; + /** + * Gets the marked items in list order, or the highlighted one when none are + * marked. Resolves with what the footer tells the user. + */ + readonly run: (items: readonly Item[]) => Promise; +}; + export type FilterListArgs = { readonly message: string; readonly items: readonly Item[]; @@ -14,11 +33,16 @@ export type FilterListArgs = { /** Fixes column widths for the full list while the visible rows change. */ readonly table: (items: readonly Item[], width: number) => FilterTable; readonly describeCount: (matched: number, total: number) => string; + readonly actions: readonly FilterAction[]; /** One line about the highlighted item, shown under the table. */ readonly detail: (item: Item) => string; }; -/** A searchable table that returns every match when Enter is pressed. */ +/** + * A table that narrows as the user types, and whose rows Tab marks. Resolves, + * when the user presses Enter, with the marked items, or with every item + * still showing when none are marked. + */ export type FilterListFn = ( args: FilterListArgs, ) => Promise>; From 29d8259a996c96edad7fff5b2d777746f97428e5 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:09:33 +0100 Subject: [PATCH 2/3] test(flows): scope POSIX path round trips to POSIX hosts Run the POSIX filename corpus on its supported hosts. Windows copies PowerShell syntax, whose native round trip already runs there. Keep all POSIX cases and add an exact CR/LF assertion that runs on every platform. --- src/domains/flows/copyFlowActions.test.ts | 7 ++++++- src/shell/clipboardPaths.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/domains/flows/copyFlowActions.test.ts b/src/domains/flows/copyFlowActions.test.ts index d9443bbf1..a6f40439d 100644 --- a/src/domains/flows/copyFlowActions.test.ts +++ b/src/domains/flows/copyFlowActions.test.ts @@ -66,7 +66,12 @@ describe("copyFlowActions", () => { }); for (const shell of ["sh", "bash", "zsh"]) { - it.skipIf(spawnSync(shell, ["-c", "exit 0"]).error !== undefined)( + // This corpus includes POSIX filenames with control characters. Windows + // copies PowerShell syntax, covered by clipboardPaths.test.ts. + it.skipIf( + process.platform === "win32" || + spawnSync(shell, ["-c", "exit 0"]).error !== undefined, + )( `preserves each copied path as one literal argument in ${shell}`, async () => { const { copy, run } = setup(); diff --git a/src/shell/clipboardPaths.test.ts b/src/shell/clipboardPaths.test.ts index 69fbc4691..c41b7ccff 100644 --- a/src/shell/clipboardPaths.test.ts +++ b/src/shell/clipboardPaths.test.ts @@ -10,6 +10,18 @@ describe("formatClipboardPaths", () => { ).toEqual({ text: "src/a.flow.ts src/b.flow.ts", syntax: undefined }); }); + it("preserves POSIX carriage returns and newlines before shell invocation", () => { + expect( + formatClipboardPaths( + ["src/carriage.flow.ts\r", "src/first\nsecond.flow.ts"], + "linux", + ), + ).toEqual({ + text: "'src/carriage.flow.ts\r' 'src/first\nsecond.flow.ts'", + syntax: undefined, + }); + }); + it("keeps simple Windows paths bare", () => { expect(formatClipboardPaths(["C:\\flows\\a.flow.ts"], "win32")).toEqual({ text: "C:\\flows\\a.flow.ts", From 8df86b1486610c065dd1314e98ae1e0600093acf Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:56:41 +0100 Subject: [PATCH 3/3] fix(flows): quote IDs and preserve copy order --- src/domains/flows/copyFlowActions.test.ts | 117 ++++++++++--------- src/domains/flows/copyFlowActions.ts | 7 +- src/shell/ui/renderers/filterActions.test.ts | 87 +++++++++++++- src/shell/ui/renderers/filterActions.ts | 16 ++- 4 files changed, 160 insertions(+), 67 deletions(-) diff --git a/src/domains/flows/copyFlowActions.test.ts b/src/domains/flows/copyFlowActions.test.ts index a6f40439d..d76fe5e1d 100644 --- a/src/domains/flows/copyFlowActions.test.ts +++ b/src/domains/flows/copyFlowActions.test.ts @@ -65,61 +65,70 @@ describe("copyFlowActions", () => { expect(notice).toEqual({ tone: "success", text: "Copied 2 ids" }); }); - for (const shell of ["sh", "bash", "zsh"]) { - // This corpus includes POSIX filenames with control characters. Windows - // copies PowerShell syntax, covered by clipboardPaths.test.ts. - it.skipIf( - process.platform === "win32" || - spawnSync(shell, ["-c", "exit 0"]).error !== undefined, - )( - `preserves each copied path as one literal argument in ${shell}`, - async () => { - const { copy, run } = setup(); - const files = [ - "src/flows/checkout cart.flow.ts", - "src/flows/customer's cart.flow.ts", - "src/flows/payments[1].flow.ts", - "src/flows/$USER.flow.ts", - "src/flows/$(printf injected).flow.ts", - "src/flows/`printf injected`.flow.ts", - "src/flows/semicolon; printf injected", - "src/flows/first\nsecond.flow.ts", - "src/flows/trailing.flow.ts\n", - "src/flows/carriage.flow.ts\r", - "src/flows/back\\slash.flow.ts", - 'src/flows/"quoted".flow.ts', - "#comment.flow.ts", - "~/literal.flow.ts", - "src/flows/{a,b}?.flow.ts", - "src/flows/!history.flow.ts", - ]; - - await run( - "y", - files.map((file) => ({ ...row("a", undefined), file })), + for (const [kind, key] of [ + ["path", "y"], + ["id", "o"], + ] as const) { + for (const shell of ["sh", "bash", "zsh"]) { + // This corpus includes POSIX filenames with control characters. Windows + // copies PowerShell syntax, covered by clipboardPaths.test.ts. + it.skipIf( + process.platform === "win32" || + spawnSync(shell, ["-c", "exit 0"]).error !== undefined, + )( + `preserves each copied ${kind} as one literal argument in ${shell}`, + async () => { + const { copy, run } = setup(); + const files = [ + "src/flows/checkout cart.flow.ts", + "src/flows/reader's note.flow.ts", + "src/flows/payments[1].flow.ts", + "src/flows/$USER.flow.ts", + "src/flows/$(printf injected).flow.ts", + "src/flows/`printf injected`.flow.ts", + "src/flows/semicolon; printf injected", + "src/flows/first\nsecond.flow.ts", + "src/flows/trailing.flow.ts\n", + "src/flows/carriage.flow.ts\r", + "src/flows/back\\slash.flow.ts", + 'src/flows/"quoted".flow.ts', + "#comment.flow.ts", + "~/literal.flow.ts", + "src/flows/{a,b}?.flow.ts", + "src/flows/!history.flow.ts", + ]; + + await run( + key, + files.map((file) => ({ ...row("a", file), file })), + ); + const copied = copy.mock.calls[0]?.[0]; + expect(copied).toBeDefined(); + const result = spawnSync(shell, ["-c", `printf '%s\\0' ${copied}`], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.split("\0").slice(0, -1)).toEqual(files); + }, + ); + } + + for (const outcome of ["copied", "terminal"] as const) { + it(`names the Windows ${kind} quoting format when ${outcome}`, async () => { + const { copy, run } = setup(outcome, "win32"); + + const notice = await run(key, [row("reader's note", "reader's note")]); + + expect(copy).toHaveBeenCalledWith( + key === "y" + ? "'src/flows/reader''s note.flow.ts'" + : "'reader''s note'", ); - const copied = copy.mock.calls[0]?.[0]; - expect(copied).toBeDefined(); - const result = spawnSync(shell, ["-c", `printf '%s\\0' ${copied}`], { - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.split("\0").slice(0, -1)).toEqual(files); - }, - ); - } - - for (const outcome of ["copied", "terminal"] as const) { - it(`names the Windows quoting format when ${outcome}`, async () => { - const { copy, run } = setup(outcome, "win32"); - - const notice = await run("y", [row("customer's cart", undefined)]); - - expect(copy).toHaveBeenCalledWith("'src/flows/customer''s cart.flow.ts'"); - expect(notice?.text).toEndWith(" · PowerShell syntax"); - }); + expect(notice?.text).toEndWith(" · PowerShell syntax"); + }); + } } it("says a flow has no id yet rather than copying nothing", async () => { diff --git a/src/domains/flows/copyFlowActions.ts b/src/domains/flows/copyFlowActions.ts index e908915fd..464f9b20d 100644 --- a/src/domains/flows/copyFlowActions.ts +++ b/src/domains/flows/copyFlowActions.ts @@ -9,15 +9,12 @@ export function copyFlowActions( copy: CopyToClipboard, formatPaths = formatClipboardPaths, ): FilterAction[] { - // Quote individual paths before joining, so shell syntax stays literal. + // Quote individual values before joining, so shell syntax stays literal. const copied = async ( values: readonly string[], noun: "path" | "id", ): Promise => { - const formatted = - noun === "path" - ? formatPaths(values) - : { text: values.join(" "), syntax: undefined }; + const formatted = formatPaths(values); const message = (await copy(formatted.text)) === "copied" ? flowsMessages.list.copied(values, noun) diff --git a/src/shell/ui/renderers/filterActions.test.ts b/src/shell/ui/renderers/filterActions.test.ts index 0188062d9..3f973cd73 100644 --- a/src/shell/ui/renderers/filterActions.test.ts +++ b/src/shell/ui/renderers/filterActions.test.ts @@ -22,11 +22,22 @@ const runner = (actions: FilterAction[] = [copyPath], holdMs = 20) => { describe("createActionRunner", () => { it("ignores an action that completes after disposal", async () => { const pending = Promise.withResolvers(); - const r = runner([{ key: "y", label: "copy", run: () => pending.promise }]); + const started = Promise.withResolvers(); + const r = runner([ + { + key: "y", + label: "copy", + run: () => { + started.resolve(); + return pending.promise; + }, + }, + ]); r.onKey({ name: "y", ctrl: true }, ["a"]); + await started.promise; r.dispose(); pending.resolve({ tone: "success", text: "late" }); - await sleep(30); + await new Promise((resolve) => setImmediate(resolve)); expect(r.changed).not.toHaveBeenCalled(); expect(r.notice()).toBeUndefined(); }); @@ -94,6 +105,78 @@ describe("createActionRunner", () => { }); r.dispose(); }); + it("keeps overlapping actions in key-press order", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const started: string[] = []; + const writes: string[] = []; + const run = async (items: readonly string[]): Promise => { + const value = items[0] ?? ""; + started.push(value); + const notice = await (value === "first" ? first : second).promise; + writes.push(value); + return notice; + }; + const r = runner( + [ + { key: "y", label: "copy path", run }, + { key: "o", label: "copy id", run }, + ], + 1000, + ); + try { + r.onKey({ name: "y", ctrl: true }, ["first"]); + r.onKey({ name: "o", ctrl: true }, ["second"]); + await new Promise((resolve) => setImmediate(resolve)); + expect(started).toEqual(["first"]); + second.resolve({ tone: "success", text: "second" }); + first.resolve({ tone: "success", text: "first" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(writes).toEqual(["first", "second"]); + expect(r.notice()?.text).toBe("second"); + } finally { + r.dispose(); + first.resolve({ tone: "success", text: "first" }); + second.resolve({ tone: "success", text: "second" }); + } + }); + + it("does not start queued or new actions after disposal", async () => { + const pending = Promise.withResolvers(); + const run = mock(() => pending.promise); + const r = runner([{ key: "y", label: "copy", run }]); + r.onKey({ name: "y", ctrl: true }, ["first"]); + await new Promise((resolve) => setImmediate(resolve)); + r.onKey({ name: "y", ctrl: true }, ["second"]); + r.dispose(); + r.onKey({ name: "y", ctrl: true }, ["third"]); + pending.resolve({ tone: "success", text: "first" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(["first"]); + expect(r.changed).not.toHaveBeenCalled(); + expect(r.notice()).toBeUndefined(); + }); + + for (const failure of ["throw", "reject"]) { + it(`continues queued actions after ${failure}`, async () => { + const run = (items: readonly string[]): Promise => { + if (items[0] !== "first") return done("second"); + if (failure === "throw") throw new Error("copy failed"); + return Promise.reject(new Error("copy failed")); + }; + const r = runner([{ key: "y", label: "copy", run }], 1000); + try { + r.onKey({ name: "y", ctrl: true }, ["first"]); + r.onKey({ name: "y", ctrl: true }, ["second"]); + await new Promise((resolve) => setImmediate(resolve)); + expect(r.notice()).toEqual({ tone: "success", text: "second" }); + expect(r.changed).toHaveBeenCalledTimes(2); + } finally { + r.dispose(); + } + }); + } }); describe("actionHints", () => { diff --git a/src/shell/ui/renderers/filterActions.ts b/src/shell/ui/renderers/filterActions.ts index 6dc49e67d..07e2bd99e 100644 --- a/src/shell/ui/renderers/filterActions.ts +++ b/src/shell/ui/renderers/filterActions.ts @@ -26,6 +26,7 @@ export function createActionRunner(args: { let notice: FilterNotice | undefined; let timer: ReturnType | undefined; let disposed = false; + let pending = Promise.resolve(); const show = (next: FilterNotice): void => { if (disposed) return; @@ -41,16 +42,19 @@ export function createActionRunner(args: { return { onKey(key, targets) { - if (key.ctrl !== true || targets.length === 0) return; + if (disposed || key.ctrl !== true || targets.length === 0) return; const action = args.actions.find( (candidate) => candidate.key === key.name, ); if (action === undefined) return; - void action - .run(targets) - .then(show, () => - show({ tone: "warning", text: `Could not ${action.label}.` }), - ); + pending = pending.then(async () => { + if (disposed) return; + try { + show(await action.run(targets)); + } catch { + show({ tone: "warning", text: `Could not ${action.label}.` }); + } + }); }, notice: () => notice, dispose() {