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..d76fe5e1d --- /dev/null +++ b/src/domains/flows/copyFlowActions.test.ts @@ -0,0 +1,172 @@ +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 [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'", + ); + 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..464f9b20d --- /dev/null +++ b/src/domains/flows/copyFlowActions.ts @@ -0,0 +1,60 @@ +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 values before joining, so shell syntax stays literal. + const copied = async ( + values: readonly string[], + noun: "path" | "id", + ): Promise => { + const formatted = formatPaths(values); + 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..c41b7ccff --- /dev/null +++ b/src/shell/clipboardPaths.test.ts @@ -0,0 +1,101 @@ +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("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", + 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..3f973cd73 --- /dev/null +++ b/src/shell/ui/renderers/filterActions.test.ts @@ -0,0 +1,191 @@ +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 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 new Promise((resolve) => setImmediate(resolve)); + 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(); + }); + 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", () => { + 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..07e2bd99e --- /dev/null +++ b/src/shell/ui/renderers/filterActions.ts @@ -0,0 +1,65 @@ +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; + let pending = Promise.resolve(); + + 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 (disposed || key.ctrl !== true || targets.length === 0) return; + const action = args.actions.find( + (candidate) => candidate.key === key.name, + ); + if (action === undefined) return; + 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() { + 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>;