From e5b4c5d8be8973f63c04bd083962a26f286005d5 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:32:18 +0100 Subject: [PATCH] feat(flows): fit printed flow lists to the terminal --- .changeset/flows-list-card-layout.md | 5 + src/commands/flows/index.ts | 24 ++-- src/core/ansi.ts | 9 ++ src/domains/flows/list.human.test.ts | 99 ++++++++++++++++ src/domains/flows/list.ts | 35 ++---- src/domains/flows/listDefaults.ts | 5 +- src/domains/flows/listItem.ts | 28 +++++ src/domains/flows/listRemote.test.ts | 8 +- src/domains/flows/listRemote.ts | 28 +++-- src/domains/flows/listView.ts | 5 + src/domains/flows/pulledPrefix.ts | 20 ++++ src/domains/flows/renderFlowsList.test.ts | 58 ++++++++++ src/domains/flows/renderFlowsList.ts | 24 ++++ src/domains/flows/renderListCards.test.ts | 134 ++++++++++++++++++++++ src/domains/flows/renderListCards.ts | 81 +++++++++++++ src/domains/flows/renderListTable.test.ts | 1 + src/domains/flows/renderListTable.ts | 29 ++++- 17 files changed, 538 insertions(+), 55 deletions(-) create mode 100644 .changeset/flows-list-card-layout.md create mode 100644 src/core/ansi.ts create mode 100644 src/domains/flows/list.human.test.ts create mode 100644 src/domains/flows/listItem.ts create mode 100644 src/domains/flows/listView.ts create mode 100644 src/domains/flows/pulledPrefix.ts create mode 100644 src/domains/flows/renderFlowsList.test.ts create mode 100644 src/domains/flows/renderFlowsList.ts create mode 100644 src/domains/flows/renderListCards.test.ts create mode 100644 src/domains/flows/renderListCards.ts diff --git a/.changeset/flows-list-card-layout.md b/.changeset/flows-list-card-layout.md new file mode 100644 index 000000000..65e59b2d1 --- /dev/null +++ b/.changeset/flows-list-card-layout.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +Fit printed human flow lists to the terminal width. Tables that do not fit become cards with whole values, a shared pulled-directory prefix, and flow IDs when available. JSON and agent output retain their existing layout. diff --git a/src/commands/flows/index.ts b/src/commands/flows/index.ts index b9d2ae2a3..dbee2e4a7 100644 --- a/src/commands/flows/index.ts +++ b/src/commands/flows/index.ts @@ -98,12 +98,17 @@ export function registerFlowsCommand( requiredMessage: flowsMessages.list.remoteRequiresEnv, }, (ctx, env) => - flowsListRemote(ctx, pattern, { - env, - includeDrafts: opts.includeDrafts, - aiTaskId: opts.aiTaskId, - tags, - }), + flowsListRemote( + ctx, + pattern, + { + env, + includeDrafts: opts.includeDrafts, + aiTaskId: opts.aiTaskId, + tags, + }, + { columns: process.stdout.columns }, + ), )(opts, command); } // Only an explicitly passed --ai-task-id is a usage error here: @@ -124,7 +129,12 @@ export function registerFlowsCommand( // Without --remote the tags come from the pull cache, so this works // offline; it cannot validate names against the team's tag list. return withContext(signals, (ctx) => - handleFlowsList(ctx, pattern, { tags, env: opts.env }), + handleFlowsList( + ctx, + pattern, + { tags, env: opts.env }, + { columns: process.stdout.columns }, + ), )(opts, command); }, ); diff --git a/src/core/ansi.ts b/src/core/ansi.ts new file mode 100644 index 000000000..35bafac0a --- /dev/null +++ b/src/core/ansi.ts @@ -0,0 +1,9 @@ +import { displayWidth } from "./displayWidth.js"; + +// Each style ends with its own reset rather than a full one (`\x1b[0m`), so a +// style inside another — a dimmed cell in a highlighted row — does not switch +// the outer one off. +export const bold = (text: string): string => `\x1b[1m${text}\x1b[22m`; +export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`; + +export const visibleLength = displayWidth; diff --git a/src/domains/flows/list.human.test.ts b/src/domains/flows/list.human.test.ts new file mode 100644 index 000000000..577a35828 --- /dev/null +++ b/src/domains/flows/list.human.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import type { CommandContext } from "~/shell/commandContext.js"; +import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; +import type { OutputMode } from "~/shell/ui/env.js"; +import { makeNoopLogger } from "~/shell/logger.testUtils.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { type FlowsListDeps, flowsList } from "./list.js"; +import { callsOf, makeFakeUI } from "~/shell/commandContext.testUtils.js"; + +const noopSignals = makeNoopSignals(); + +afterEach(() => { + mock.restore(); +}); + +const fakeCwd = "/proj"; + +function makeCtx( + ui = makeFakeUI(), + outputMode: OutputMode = "human", +): CommandContext { + return { + ui: { ...ui, mode: outputMode }, + configDir: "/tmp/test-config", + outputMode, + isInteractive: false, + apiBaseUrl: "https://example.invalid", + signals: noopSignals, + log: () => makeNoopLogger(), + fs: makeMemoryFs(), + }; +} + +function makeDeps(overrides?: { + files?: readonly string[]; + metaByFile?: Record; +}): FlowsListDeps { + const { files = [], metaByFile = {} } = overrides ?? {}; + return { + cwd: fakeCwd, + expandPatterns: mock(() => + Promise.resolve([...files]), + ), + peekFlowMeta: mock((file: string) => + Promise.resolve({ + name: metaByFile[file]?.name, + target: metaByFile[file]?.target, + }), + ), + readCachedFlows: mock(() => + Promise.resolve(new Map()), + ), + readEnvLabel: mock((dir: string) => + Promise.resolve(dir), + ), + findPulledEnv: mock(() => + Promise.resolve(undefined), + ), + listPulledEnvDirs: mock(() => + Promise.resolve([]), + ), + }; +} + +describe("flowsList human mode on a narrow terminal", () => { + it("prints a card per flow instead of a table that would not fit", async () => { + const ui = makeFakeUI(); + const deps = makeDeps({ + files: ["/proj/src/flows/login.flow.ts"], + metaByFile: { + "/proj/src/flows/login.flow.ts": { + name: "Login", + target: "Web - Chrome", + }, + }, + }); + + await flowsList( + { ...makeCtx(ui, "human"), isInteractive: true }, + undefined, + deps, + { tags: [] }, + { columns: 20 }, + ); + + const output = callsOf(ui.write) + .map((c) => String(c[0])) + .join(""); + // oxlint-disable-next-line no-control-regex + const plainOutput = output.replace(/\x1b\[[\d;]*m/g, ""); + expect(plainOutput).not.toMatch(/^name\s+target/m); + expect(plainOutput).toMatch(/^Login {2}· {2}Web - Chrome$/m); + expect(plainOutput).toMatch(/^ {2}file\s+src/m); + expect(ui.intro).toHaveBeenCalledWith("Flows"); + expect(ui.outro).toHaveBeenCalledWith("1 flow"); + }); +}); diff --git a/src/domains/flows/list.ts b/src/domains/flows/list.ts index be9b825b4..c100512de 100644 --- a/src/domains/flows/list.ts +++ b/src/domains/flows/list.ts @@ -2,8 +2,6 @@ import path from "node:path"; import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { flowsMessages, runnerMessages } from "~/core/messages/index.js"; -import type { CachedFlow } from "./readCachedFlows.js"; -import type { BrowserName } from "~/core/types.js"; import { batchMap, flowBatchSize } from "~/core/batchMap.js"; import { matchesSelectors, type FlowSelectors } from "~/core/flowSelectors.js"; @@ -15,6 +13,10 @@ import { import { envLabelFor, readEnvLabels } from "./envLabels.js"; import { selectPulledEnv } from "./selectPulledEnv.js"; import { emptySelectionResult, tagsNotCachedResult } from "./selectorGuards.js"; +import { renderFlowsList } from "./renderFlowsList.js"; +import { type FlowsListItem, toListRow } from "./listItem.js"; +import { type ListView, printedView } from "./listView.js"; +import type { CachedFlow } from "./readCachedFlows.js"; import { renderListTable } from "./renderListTable.js"; export type FlowsListDeps = { @@ -24,7 +26,7 @@ export type FlowsListDeps = { cwd: string, ) => Promise; readonly peekFlowMeta: PeekFlowMetaFn; - /** What each flow’s pull recorded, keyed by absolute flow path. */ + /** What each flow's pull recorded, keyed by absolute flow path. */ readonly readCachedFlows: ( files: readonly string[], ) => Promise>; @@ -38,25 +40,12 @@ export type FlowsListDeps = { readonly listPulledEnvDirs: () => Promise; }; -type FlowsListItem = { - file: string; - name: string; - flowId: string | undefined; - // The pulled environment the flow came from. Undefined for project flows, - // which belong to no environment. - env: string | undefined; - // Absent when the flow was never pulled, so its tags are unknown rather - // than known to be empty. - tags: readonly string[] | undefined; - target: string | undefined; - browser: BrowserName | undefined; -}; - export async function flowsList( ctx: CommandContext, pattern: string | undefined, deps: FlowsListDeps, selectors: FlowSelectors & { env?: string | undefined } = { tags: [] }, + view: ListView = printedView, ): Promise { const patterns = pattern ? [pattern] : []; let files = await deps.expandPatterns(patterns, deps.cwd); @@ -95,7 +84,7 @@ export async function flowsList( name: meta.name ?? flowBasename(file), flowId: cached.get(file)?.flowId, env: envLabelFor(file, envLabels), - tags: cachedTags.get(file), + tags: cached.get(file)?.tags, target: meta.target, browser: meta.target ? targetToBrowser(meta.target) : undefined, }); @@ -114,19 +103,13 @@ export async function flowsList( ctx.ui.info(runnerMessages.noFlowsMatched); return; } - const rows = items.map((it) => ({ - name: it.name, - target: it.target ?? "", - env: it.env, - tags: it.tags, - file: it.file, - })); + const rows = items.map(toListRow); if (ctx.ui.mode === "agent") { ctx.ui.write(renderListTable(rows, false)); return; } ctx.ui.gap(); ctx.ui.intro(flowsMessages.title); - ctx.ui.write(renderListTable(rows, true)); + ctx.ui.write(renderFlowsList(rows, { styled: true, columns: view.columns })); ctx.ui.outro(flowsMessages.flowCount(items.length)); } diff --git a/src/domains/flows/listDefaults.ts b/src/domains/flows/listDefaults.ts index dd9226e8b..b5d46196f 100644 --- a/src/domains/flows/listDefaults.ts +++ b/src/domains/flows/listDefaults.ts @@ -10,13 +10,15 @@ import { makePeekFlowMeta, } from "./expand.js"; import { flowsList } from "./list.js"; +import type { ListView } from "./listView.js"; import { readCachedFlows as defaultReadCachedFlows } from "./readCachedFlows.js"; import { readEnvLabel as defaultReadEnvLabel } from "./readEnvLabel.js"; export function handleFlowsList( ctx: CommandContext, pattern: string | undefined, - selectors?: FlowSelectors & { env?: string | undefined }, + selectors: FlowSelectors & { env?: string | undefined }, + view: ListView, ): Promise { const { fs } = ctx; return flowsList( @@ -33,5 +35,6 @@ export function handleFlowsList( listPulledEnvDirs: () => defaultListPulledEnvDirs(process.cwd(), fs), }, selectors, + view, ); } diff --git a/src/domains/flows/listItem.ts b/src/domains/flows/listItem.ts new file mode 100644 index 000000000..79aeec181 --- /dev/null +++ b/src/domains/flows/listItem.ts @@ -0,0 +1,28 @@ +import type { BrowserName } from "~/core/types.js"; + +import type { FlowsListRow } from "./renderListTable.js"; + +/** One flow as `flows list` reports it; `--json` emits these verbatim. */ +export type FlowsListItem = { + file: string; + name: string; + // Undefined for project flows and pulls made before IDs were recorded. + flowId: string | undefined; + // The pulled environment the flow came from. Undefined for project flows, + // which belong to no environment. + env: string | undefined; + // Absent when the flow was never pulled, so its tags are unknown rather + // than known to be empty. + tags: readonly string[] | undefined; + target: string | undefined; + browser: BrowserName | undefined; +}; + +export const toListRow = (it: FlowsListItem): FlowsListRow => ({ + name: it.name, + flowId: it.flowId, + target: it.target, + env: it.env, + tags: it.tags, + file: it.file, +}); diff --git a/src/domains/flows/listRemote.test.ts b/src/domains/flows/listRemote.test.ts index 8b4d0909c..f5cd70831 100644 --- a/src/domains/flows/listRemote.test.ts +++ b/src/domains/flows/listRemote.test.ts @@ -117,7 +117,7 @@ describe("flowsListRemote wire call", () => { }); describe("flowsListRemote success paths", () => { - it("renders a bolded header + name|target|tags|file rows in human mode", async () => { + it("renders IDs alongside the other fields in human mode", async () => { const { ui } = await run({ mode: "human" }); const output = callsOf(ui.write) @@ -129,13 +129,13 @@ describe("flowsListRemote success paths", () => { expect(lines[0]).toMatch(/\[0m/); // oxlint-disable-next-line no-control-regex, @typescript-eslint/no-non-null-assertion expect(lines[0]!.replace(/\x1b[^m]*m/g, "")).toMatch( - /^name\s+target\s+tags\s+file$/, + /^name\s+id\s+target\s+tags\s+file$/, ); expect(stripAnsi(lines[1])).toMatch( - /^Login\s+Web - Chrome\s+src\/flows\/login\.flow\.ts$/, + /^Login\s+flow-id-1\s+Web - Chrome\s+src\/flows\/login\.flow\.ts$/, ); expect(stripAnsi(lines[2])).toMatch( - /^Checkout\s+Web - Firefox\s+smoke\s+src\/flows\/sub\/checkout\.flow\.ts$/, + /^Checkout\s+flow-id-2\s+Web - Firefox\s+smoke\s+src\/flows\/sub\/checkout\.flow\.ts$/, ); expect(ui.intro).toHaveBeenCalledWith("Remote Flows"); expect(ui.outro).toHaveBeenCalledWith("2 flows"); diff --git a/src/domains/flows/listRemote.ts b/src/domains/flows/listRemote.ts index a457c29fb..6314adcfa 100644 --- a/src/domains/flows/listRemote.ts +++ b/src/domains/flows/listRemote.ts @@ -10,7 +10,9 @@ import { flowsMessages, runnerMessages } from "~/core/messages/index.js"; import { matchesSelectors } from "~/core/flowSelectors.js"; import { fetchKnownTags } from "./fetchKnownTags.js"; -import { renderListTable } from "./renderListTable.js"; +import { renderFlowsList } from "./renderFlowsList.js"; +import { renderListTable, type FlowsListRow } from "./renderListTable.js"; +import { type ListView, printedView } from "./listView.js"; import { emptySelectionResult } from "./selectorGuards.js"; type RemoteListItem = { @@ -22,6 +24,17 @@ type RemoteListItem = { url: string; }; +const toListRow = (it: RemoteListItem): FlowsListRow => ({ + name: it.name, + flowId: it.flowId, + target: it.target, + // A remote listing is scoped to one environment by definition, so the env + // column would repeat the --env value on every row. + env: undefined, + tags: it.tags, + file: it.file, +}); + export type FlowsListRemoteOptions = { readonly env: string; readonly includeDrafts: boolean; @@ -33,6 +46,7 @@ export async function flowsListRemote( ctx: AuthCommandContext, pattern: string | undefined, options: FlowsListRemoteOptions, + view: ListView = printedView, ): Promise { const result = await ctx.platformClient.callPublicApi( publicContractsV1.flow.list, @@ -77,21 +91,13 @@ export async function flowsListRemote( ctx.ui.info(runnerMessages.noFlowsMatched); return; } - const rows = items.map((it) => ({ - name: it.name, - target: it.target, - // A remote listing is scoped to one environment by definition, so the env - // column would repeat the --env value on every row. - env: undefined, - tags: it.tags, - file: it.file, - })); + const rows = items.map(toListRow); if (ctx.ui.mode === "agent") { ctx.ui.write(renderListTable(rows, false)); return; } ctx.ui.gap(); ctx.ui.intro(flowsMessages.remoteTitle); - ctx.ui.write(renderListTable(rows, true)); + ctx.ui.write(renderFlowsList(rows, { styled: true, columns: view.columns })); ctx.ui.outro(flowsMessages.flowCount(items.length)); } diff --git a/src/domains/flows/listView.ts b/src/domains/flows/listView.ts new file mode 100644 index 000000000..5c32cd18e --- /dev/null +++ b/src/domains/flows/listView.ts @@ -0,0 +1,5 @@ +export type ListView = { + readonly columns: number | undefined; +}; + +export const printedView: ListView = { columns: undefined }; diff --git a/src/domains/flows/pulledPrefix.ts b/src/domains/flows/pulledPrefix.ts new file mode 100644 index 000000000..d65b446d3 --- /dev/null +++ b/src/domains/flows/pulledPrefix.ts @@ -0,0 +1,20 @@ +import type { FlowsListRow } from "./renderListTable.js"; + +export function sharedPulledPrefix( + rows: readonly FlowsListRow[], +): string | undefined { + const prefixes = new Set( + rows.map((row) => /^\.qawolf[\\/][^\\/]+[\\/]/.exec(row.file)?.[0]), + ); + if (prefixes.size !== 1) return undefined; + const [only] = prefixes; + return only; +} + +export const withoutPulledPrefix = ( + file: string, + prefix: string | undefined, +): string => + prefix !== undefined && file.startsWith(prefix) + ? file.slice(prefix.length) + : file; diff --git a/src/domains/flows/renderFlowsList.test.ts b/src/domains/flows/renderFlowsList.test.ts new file mode 100644 index 000000000..1945b68eb --- /dev/null +++ b/src/domains/flows/renderFlowsList.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; + +import { renderFlowsList } from "./renderFlowsList.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +// Renders as "Login Web - Chrome src/flows/login.flow.ts": 44 characters. +const row: FlowsListRow = { + name: "Login", + target: "Web - Chrome", + file: "src/flows/login.flow.ts", + env: undefined, + tags: undefined, + flowId: undefined, +}; +const rows = [row]; + +const render = (columns: number | undefined): string => + renderFlowsList(rows, { styled: false, columns }); + +describe("renderFlowsList", () => { + it("retains IDs and every tag in both human layouts", () => { + const tags = ["ALPHA", "BETA", "DELTA", "GAMMA"]; + const complete = [ + { + ...row, + flowId: "00000000-0000-4000-8000-000000000001", + tags, + }, + ]; + + for (const columns of [40, 200, undefined]) { + const output = renderFlowsList(complete, { styled: false, columns }); + + expect(output).toContain("00000000-0000-4000-8000-000000000001"); + for (const name of tags) expect(output).toContain(name); + expect(output).not.toContain("more"); + } + }); + + // Piped output has no width to fit; reflowing for a guess would surprise. + it("keeps the table when the terminal width is unknown", () => { + expect(render(undefined)).toMatch(/^name\s+target\s+file/); + }); + + it("keeps the table when it fits", () => { + expect(render(200)).toMatch(/^name\s+target\s+file/); + }); + + it("keeps the table at exactly the terminal width", () => { + expect(render(44)).toMatch(/^name\s+target\s+file/); + }); + + it("switches to cards when the table would be wider than the terminal", () => { + const out = render(43); + expect(out).not.toMatch(/^name\s+target/); + expect(out.split("\n")[0]).toBe("Login · Web - Chrome"); + }); +}); diff --git a/src/domains/flows/renderFlowsList.ts b/src/domains/flows/renderFlowsList.ts new file mode 100644 index 000000000..24ad33482 --- /dev/null +++ b/src/domains/flows/renderFlowsList.ts @@ -0,0 +1,24 @@ +import { visibleLength } from "~/core/ansi.js"; + +import { renderListCards } from "./renderListCards.js"; +import { renderListTable, type FlowsListRow } from "./renderListTable.js"; + +export function renderFlowsList( + rows: readonly FlowsListRow[], + options: { styled: boolean; columns: number | undefined }, +): string { + const table = renderListTable(rows, options.styled, "full"); + // Unknown width means piped output or no terminal: reflowing for a width we + // are guessing at would only surprise whoever reads it. + if (options.columns === undefined) return table; + + const widest = Math.max(0, ...table.split("\n").map(visibleLength)); + // A table only reads well while each row is one line. Wrapping its cells + // staggers every column; cards read top to bottom and keep each value whole. + return widest <= options.columns + ? table + : renderListCards(rows, { + styled: options.styled, + width: options.columns, + }); +} diff --git a/src/domains/flows/renderListCards.test.ts b/src/domains/flows/renderListCards.test.ts new file mode 100644 index 000000000..e25942d3c --- /dev/null +++ b/src/domains/flows/renderListCards.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test"; + +import { displayWidth } from "~/core/displayWidth.js"; + +import { renderListCards } from "./renderListCards.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +const row = (over: Partial = {}): FlowsListRow => ({ + name: "Login", + target: "Web - Chrome", + file: ".qawolf/env-a/src/flows/login.flow.ts", + env: undefined, + tags: undefined, + flowId: undefined, + ...over, +}); + +const plain = (rows: FlowsListRow[], width: number): string => + renderListCards(rows, { styled: false, width }); + +describe("renderListCards", () => { + it("lists every tag rather than truncating", () => { + const out = plain( + [row({ tags: ["A_ONE", "B_TWO", "C_THREE", "D_FOUR", "E_FIVE"] })], + 200, + ); + expect(out).toContain("A_ONE, B_TWO, C_THREE, D_FOUR, E_FIVE"); + expect(out).not.toContain("more"); + }); + + it("wraps a long list between whole names, aligned under the value", () => { + const out = plain( + [ + row({ + tags: [ + "example-category-a", + "example-category-b", + "example-category-c", + "example-category-d", + ], + }), + ], + 40, + ); + expect(out).toContain( + [ + " tags example-category-a,", + " example-category-b,", + " example-category-c,", + " example-category-d", + ].join("\n"), + ); + }); + + it.each([ + { tags: ["登录".repeat(4), "付款".repeat(4)] }, + { tags: ["a".repeat(13), "b".repeat(13), "c"] }, + ])("wraps by display width including trailing separators: %j", ({ tags }) => { + const out = plain([row({ tags })], 40); + for (const line of out.split("\n")) { + expect(displayWidth(line)).toBeLessThanOrEqual(40); + } + for (const tag of tags) expect(out).toContain(tag); + }); + + it("keeps combining characters together when their display widths fit", () => { + const tags = ["e\u0301".repeat(10), "y".repeat(10)]; + expect(plain([row({ tags })], 40)).toContain(tags.join(", ")); + }); + + it("keeps a wide Unicode value whole on its own line", () => { + const tag = "登录".repeat(20); + expect(plain([row({ tags: [tag] })], 40)).toContain(tag); + }); + + it("gives an item wider than the terminal its own line, whole", () => { + const long = "a-tag-name-far-wider-than-the-column"; + const out = plain([row({ tags: ["SHORT", long] })], 20); + expect(out).toContain(long); + }); + + it("omits fields with nothing to show", () => { + const out = plain([row()], 200); + expect(out).not.toContain("tags"); + }); + + it("says a shared pulled prefix once, above the cards", () => { + const out = plain( + [ + row(), + row({ + name: "Checkout", + file: ".qawolf/env-a/src/flows/checkout.flow.ts", + }), + ], + 200, + ); + expect(out.split("\n")[0]).toBe("in .qawolf/env-a/"); + expect(out.split(".qawolf/env-a/").length - 1).toBe(1); + expect(out).toContain(" file src/flows/login.flow.ts"); + }); + + it("keeps full paths and shows the env when rows come from several places", () => { + const out = plain( + [ + row({ env: "staging", file: ".qawolf/env-a/src/flows/a.flow.ts" }), + row({ name: "Local", file: "src/flows/local.flow.ts" }), + ], + 200, + ); + expect(out).not.toContain("in .qawolf"); + expect(out).toContain(" env staging"); + expect(out).toContain(".qawolf/env-a/src/flows/a.flow.ts"); + }); + + it("bolds the name only when styled", () => { + expect(renderListCards([row()], { styled: true, width: 200 })).toContain( + "\x1b[1mLogin\x1b[22m", + ); + expect(plain([row()], 200)).not.toContain("\x1b"); + }); +}); + +describe("renderListCards flow ids", () => { + it("shows the flow id when it is known", () => { + expect(plain([row({ flowId: "flow-123" })], 200)).toContain( + " id flow-123", + ); + }); + + it("leaves the id line out when it is not", () => { + expect(plain([row()], 200)).not.toContain(" id "); + }); +}); diff --git a/src/domains/flows/renderListCards.ts b/src/domains/flows/renderListCards.ts new file mode 100644 index 000000000..44b5b62cf --- /dev/null +++ b/src/domains/flows/renderListCards.ts @@ -0,0 +1,81 @@ +import { bold as boldStyle, dim as dimStyle } from "~/core/ansi.js"; +import { displayWidth } from "~/core/displayWidth.js"; + +import { sharedPulledPrefix, withoutPulledPrefix } from "./pulledPrefix.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +const indent = " "; +// Keep every value aligned in the same column. +const labelWidth = 8; +const labelGap = " "; +// Below this, wrapping helps nobody: values run on and the terminal wraps them. +const minValueWidth = 20; + +// Breaks only between whole items — names in a list, segments of a path — so +// nothing is ever cut. An item wider than `width` gets a line to itself. +function wrapBetween( + items: readonly string[], + joiner: string, + width: number, +): string[] { + const lines: string[] = []; + let current = ""; + for (const [index, item] of items.entries()) { + const candidate = current === "" ? item : `${current}${joiner}${item}`; + const suffix = index < items.length - 1 ? joiner.trimEnd() : ""; + if (displayWidth(candidate + suffix) > width && current !== "") { + lines.push(`${current}${joiner.trimEnd()}`); + current = item; + } else { + current = candidate; + } + } + if (current !== "") lines.push(current); + return lines; +} + +export function renderListCards( + rows: readonly FlowsListRow[], + options: { styled: boolean; width: number }, +): string { + const bold = (text: string): string => + options.styled ? boldStyle(text) : text; + const dim = (text: string): string => + options.styled ? dimStyle(text) : text; + + const valueWidth = Math.max( + minValueWidth, + options.width - displayWidth(indent) - labelWidth - displayWidth(labelGap), + ); + const prefix = sharedPulledPrefix(rows); + // Same rule as the table's env column: only worth a line when rows differ. + const severalEnvs = new Set(rows.map((row) => row.env)).size > 1; + + const out: string[] = []; + if (prefix !== undefined) out.push(dim(`in ${prefix}`), ""); + + for (const row of rows) { + out.push( + row.target === undefined + ? bold(row.name) + : `${bold(row.name)} ${dim("·")} ${row.target}`, + ); + const file = withoutPulledPrefix(row.file, prefix); + const fields: [string, readonly string[], string][] = [ + ["id", row.flowId === undefined ? [] : [row.flowId], ""], + ["env", severalEnvs && row.env !== undefined ? [row.env] : [], ", "], + ["tags", row.tags ?? [], ", "], + // Split after each separator, so a Windows path keeps its own. + ["file", file.split(/(?<=[\\/])/), ""], + ]; + for (const [label, items, joiner] of fields) { + if (items.length === 0) continue; + wrapBetween(items, joiner, valueWidth).forEach((line, index) => { + const shown = index === 0 ? label : ""; + out.push(`${indent}${dim(shown.padEnd(labelWidth))}${labelGap}${line}`); + }); + } + out.push(""); + } + return out.join("\n"); +} diff --git a/src/domains/flows/renderListTable.test.ts b/src/domains/flows/renderListTable.test.ts index 799278ee6..edfd42987 100644 --- a/src/domains/flows/renderListTable.test.ts +++ b/src/domains/flows/renderListTable.test.ts @@ -8,6 +8,7 @@ const row = (over: Partial = {}): FlowsListRow => ({ file: "src/flows/login.flow.ts", env: undefined, tags: undefined, + flowId: undefined, ...over, }); diff --git a/src/domains/flows/renderListTable.ts b/src/domains/flows/renderListTable.ts index 9dc6e8b91..50619ca1b 100644 --- a/src/domains/flows/renderListTable.ts +++ b/src/domains/flows/renderListTable.ts @@ -2,13 +2,15 @@ import { type TableColumn, renderTable } from "~/core/renderTable.js"; export type FlowsListRow = { readonly name: string; - readonly target: string; + // Undefined when the flow does not declare one. + readonly target: string | undefined; readonly file: string; // The pulled environment a flow came from, undefined for project flows. readonly env: string | undefined; // Undefined when the caller cannot determine the tags, as opposed to a flow // that is genuinely untagged. readonly tags: readonly string[] | undefined; + readonly flowId: string | undefined; }; const nameColumn: TableColumn = { @@ -17,7 +19,7 @@ const nameColumn: TableColumn = { }; const targetColumn: TableColumn = { header: "target", - value: (row) => row.target, + value: (row) => row.target ?? "", }; const envColumn: TableColumn = { header: "env", @@ -33,10 +35,10 @@ const fileColumn: TableColumn = { value: (row) => row.file, }; -export function renderListTable( +function listTableColumns( rows: readonly FlowsListRow[], - boldHeader: boolean, -): string { + detail: "compact" | "full" = "compact", +): TableColumn[] { // Tagging is sparse, and local flows that were never pulled have no tags to // report at all, so the column only appears once some row can fill it. const someTagged = rows.some((row) => (row.tags ?? []).length > 0); @@ -46,10 +48,25 @@ export function renderListTable( const severalEnvs = new Set(rows.map((row) => row.env)).size > 1; const columns = [ nameColumn, + ...(detail === "full" && rows.some((row) => row.flowId !== undefined) + ? [{ header: "id", value: (row: FlowsListRow) => row.flowId ?? "" }] + : []), targetColumn, ...(severalEnvs ? [envColumn] : []), ...(someTagged ? [tagsColumn] : []), fileColumn, ]; - return renderTable({ boldHeader, columns, rows }); + return columns; +} + +export function renderListTable( + rows: readonly FlowsListRow[], + boldHeader: boolean, + detail: "compact" | "full" = "compact", +): string { + return renderTable({ + boldHeader, + columns: listTableColumns(rows, detail), + rows, + }); }