diff --git a/bin/recs-tui.ts b/bin/recs-tui.ts new file mode 100644 index 0000000..0ce72ec --- /dev/null +++ b/bin/recs-tui.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +/** + * CLI entry point for the RecordStream TUI pipeline builder. + * + * Usage: + * recs tui [inputfile] Open TUI with optional input file + * recs tui --session Resume a saved session + * recs tui --list List saved sessions + * recs tui --clean Remove sessions older than 7 days + * recs tui --pipeline "..." Start with an initial pipeline command + */ + +import { launchTui, type TuiOptions } from "../src/tui/index.tsx"; + +const args = process.argv.slice(2); + +// Handle --list +if (args.includes("--list")) { + // TODO: implement session listing (Phase 3) + console.log("No saved sessions. (Session persistence coming in Phase 3)"); + process.exit(0); +} + +// Handle --clean +if (args.includes("--clean")) { + // TODO: implement session cleanup (Phase 3) + console.log("Session cleanup not yet implemented. (Phase 3)"); + process.exit(0); +} + +// Parse options +const options: TuiOptions = {}; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + + if (arg === "--session" || arg === "-s") { + options.sessionId = args[i + 1]; + i++; // skip value + } else if (arg === "--pipeline" || arg === "-p") { + options.pipeline = args[i + 1]; + i++; // skip value + } else if (arg === "--help" || arg === "-h") { + console.log(`Usage: recs tui [options] [inputfile] + +Options: + --session, -s Resume a saved session + --pipeline, -p Start with an initial pipeline command + --list List saved sessions + --clean Remove sessions older than 7 days + --help, -h Show this help message + +Examples: + recs tui data.jsonl + recs tui access.log --pipeline "fromre '^(\\S+)' | grep status=200" + recs tui --session abc123 + recs tui`); + process.exit(0); + } else if (!arg.startsWith("-")) { + // Positional arg = input file + options.inputFile = arg; + } +} + +await launchTui(options); diff --git a/bin/recs.ts b/bin/recs.ts index 526b4a9..d523fb9 100644 --- a/bin/recs.ts +++ b/bin/recs.ts @@ -99,6 +99,21 @@ if (command === "story") { process.exit(0); } +// Handle TUI subcommand +if (command === "tui") { + const tuiArgs = args.slice(1); + // Re-exec via recs-tui.ts with remaining args + const { join } = await import("node:path"); + const tuiEntry = join(import.meta.dir, "recs-tui.ts"); + const proc = Bun.spawn(["bun", "run", tuiEntry, ...tuiArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const code = await proc.exited; + process.exit(code); +} + // Handle alias management subcommand if (command === "alias") { const aliasArgs = args.slice(1); diff --git a/bun.lock b/bun.lock index 3f10d15..cf4d96f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,16 @@ "": { "name": "ts", "dependencies": { + "@opentui/core": "^0.1.81", + "@opentui/react": "^0.1.81", + "@types/react": "^19.2.14", "better-sqlite3": "^12.6.2", "fast-xml-parser": "^5.3.7", "mongodb": "^7.1.0", + "nanoid": "^5.1.6", "openai": "^6.22.0", "papaparse": "^5.5.3", + "react": "^19.2.4", "string-width": "^8.2.0", "xlsx": "^0.18.5", }, @@ -69,6 +74,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="], "@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="], @@ -125,10 +132,82 @@ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="], + "@opentui/core": ["@opentui/core@0.1.81", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.81", "@opentui/core-darwin-x64": "0.1.81", "@opentui/core-linux-arm64": "0.1.81", "@opentui/core-linux-x64": "0.1.81", "@opentui/core-win32-arm64": "0.1.81", "@opentui/core-win32-x64": "0.1.81", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ooFjkkQ80DDC4X5eLvH8dBcLAtWwGp9RTaWsaeWet3GOv4N0SDcN8mi1XGhYnUlTuxmofby5eQrPegjtWHODlA=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.81", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I3Ry5JbkSQXs2g1me8yYr0v3CUcIIfLHzbWz9WMFla8kQDSa+HOr8IpZbqZDeIFgOVzolAXBmZhg0VJI3bZ7MA=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.81", "", { "os": "darwin", "cpu": "x64" }, "sha512-CrtNKu41D6+bOQdUOmDX4Q3hTL6p+sT55wugPzbDq7cdqFZabCeguBAyOlvRl2g2aJ93kmOWW6MXG0bPPklEFg=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.81", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJw9zmJop9WiMvtT07nSrfBLPLqskxL6xfV3GNft0mSYV+C3hdJ0qkiczGSHUX/6V7fmouM84RWwmY53Rb6hYQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.81", "", { "os": "linux", "cpu": "x64" }, "sha512-Rj2AFIiuWI0BEMIvh/Jeuxty9Gp5ZhLuQU7ZHJJhojKo/mpBpMs9X+5kwZPZya/tyR8uVDAVyB6AOLkhdRW5lw=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.81", "", { "os": "win32", "cpu": "arm64" }, "sha512-AiZB+mZ1cVr8plAPrPT98e3kw6D0OdOSe2CQYLgJRbfRlPqq3jl26lHPzDb3ZO2OR0oVGRPJvXraus939mvoiQ=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.81", "", { "os": "win32", "cpu": "x64" }, "sha512-l8R2Ni1CR4eHi3DTmSkEL/EjHAtOZ/sndYs3VVw+Ej2esL3Mf0W7qSO5S0YNBanz2VXZhbkmM6ERm9keH8RD3w=="], + + "@opentui/react": ["@opentui/react@0.1.81", "", { "dependencies": { "@opentui/core": "0.1.81", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-Qlgt2j081EdYxBgNpiHQEw3I/Kgw+UZLCx6IliSetF7wGw4nXWPLSgCsa5aZpVj25/vzZnHyEkTZcx8Pq8xn8Q=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w=="], @@ -233,6 +312,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -253,6 +334,8 @@ "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -297,12 +380,20 @@ "@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], "algoliasearch": ["algoliasearch@5.49.0", "", { "dependencies": { "@algolia/abtesting": "1.15.0", "@algolia/client-abtesting": "5.49.0", "@algolia/client-analytics": "5.49.0", "@algolia/client-common": "5.49.0", "@algolia/client-insights": "5.49.0", "@algolia/client-personalization": "5.49.0", "@algolia/client-query-suggestions": "5.49.0", "@algolia/client-search": "5.49.0", "@algolia/ingestion": "1.49.0", "@algolia/monitoring": "1.49.0", "@algolia/recommend": "5.49.0", "@algolia/requester-browser-xhr": "5.49.0", "@algolia/requester-fetch": "5.49.0", "@algolia/requester-node-http": "5.49.0" } }, "sha512-Tse7vx7WOvbU+kpq/L3BrBhSWTPbtMa59zIEhMn+Z2NoxZlpcCRUDCRxQ7kDFs1T3CHxDgvb+mDuILiBBpBaAA=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], @@ -313,12 +404,26 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], @@ -349,6 +454,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -359,10 +466,18 @@ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], "fast-xml-parser": ["fast-xml-parser@5.3.7", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], @@ -375,6 +490,8 @@ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -387,12 +504,18 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "lefthook": ["lefthook@2.1.1", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.1", "lefthook-darwin-x64": "2.1.1", "lefthook-freebsd-arm64": "2.1.1", "lefthook-freebsd-x64": "2.1.1", "lefthook-linux-arm64": "2.1.1", "lefthook-linux-x64": "2.1.1", "lefthook-openbsd-arm64": "2.1.1", "lefthook-openbsd-x64": "2.1.1", "lefthook-windows-arm64": "2.1.1", "lefthook-windows-x64": "2.1.1" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-Tl9h9c+sG3ShzTHKuR3LAIblnnh+Mgxnm2Ul7yu9cu260Z27LEbO3V6Zw4YZFP59/2rlD42pt/llYsQCkkCFzw=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-O/RS1j03/Fnq5zCzEb2r7UOBsqPeBuf1C5pMkIJcO4TSE6hf3rhLUkcorKc2M5ni/n5zLGtzQUXHV08/fSAT3Q=="], @@ -419,6 +542,8 @@ "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], @@ -433,6 +558,8 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -447,12 +574,14 @@ "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], @@ -461,18 +590,36 @@ "oxlint": ["oxlint@1.49.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.49.0", "@oxlint/binding-android-arm64": "1.49.0", "@oxlint/binding-darwin-arm64": "1.49.0", "@oxlint/binding-darwin-x64": "1.49.0", "@oxlint/binding-freebsd-x64": "1.49.0", "@oxlint/binding-linux-arm-gnueabihf": "1.49.0", "@oxlint/binding-linux-arm-musleabihf": "1.49.0", "@oxlint/binding-linux-arm64-gnu": "1.49.0", "@oxlint/binding-linux-arm64-musl": "1.49.0", "@oxlint/binding-linux-ppc64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-musl": "1.49.0", "@oxlint/binding-linux-s390x-gnu": "1.49.0", "@oxlint/binding-linux-x64-gnu": "1.49.0", "@oxlint/binding-linux-x64-musl": "1.49.0", "@oxlint/binding-openharmony-arm64": "1.49.0", "@oxlint/binding-win32-arm64-msvc": "1.49.0", "@oxlint/binding-win32-ia32-msvc": "1.49.0", "@oxlint/binding-win32-x64-msvc": "1.49.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -481,8 +628,16 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -495,16 +650,24 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -515,6 +678,8 @@ "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -527,6 +692,8 @@ "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], @@ -535,6 +702,12 @@ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -555,6 +728,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -567,6 +742,8 @@ "vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -577,8 +754,32 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], } } diff --git a/package.json b/package.json index c64c78b..88377d1 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,16 @@ "vitepress": "^1.6.4" }, "dependencies": { + "@opentui/core": "^0.1.81", + "@opentui/react": "^0.1.81", + "@types/react": "^19.2.14", "better-sqlite3": "^12.6.2", "fast-xml-parser": "^5.3.7", "mongodb": "^7.1.0", + "nanoid": "^5.1.6", "openai": "^6.22.0", "papaparse": "^5.5.3", + "react": "^19.2.4", "string-width": "^8.2.0", "xlsx": "^0.18.5" } diff --git a/src/tui/components/App.tsx b/src/tui/components/App.tsx new file mode 100644 index 0000000..d7cb040 --- /dev/null +++ b/src/tui/components/App.tsx @@ -0,0 +1,329 @@ +/** + * Root TUI application component. + * + * Renders the WelcomeScreen when no input is provided, + * or the MainLayout when a pipeline is active. + * + * Integrates useReducer for state management, useKeyboard for global + * keyboard handling, and wires together all sub-components. + */ + +import { useReducer, useState, useCallback } from "react"; +import { useKeyboard } from "@opentui/react"; +import type { CliRenderer } from "@opentui/core"; +import type { TuiOptions } from "../index.tsx"; +import type { PipelineAction, StageConfig } from "../model/types.ts"; +import { pipelineReducer, createInitialState } from "../model/reducer.ts"; +import { getCursorStage, getCursorOutput, getDownstreamStages } from "../model/selectors.ts"; +import { exportAsPipeScript, exportAsChainCommand, copyToClipboard } from "../model/serialization.ts"; +import { ExportPicker, type ExportFormat } from "./modals/ExportPicker.tsx"; +import { TitleBar } from "./TitleBar.tsx"; +import { StageList } from "./StageList.tsx"; +import { InspectorPanel } from "./InspectorPanel.tsx"; +import { StatusBar } from "./StatusBar.tsx"; +import { AddStageModal } from "./modals/AddStageModal.tsx"; +import { EditStageModal } from "./modals/EditStageModal.tsx"; +import { ConfirmDialog } from "./modals/ConfirmDialog.tsx"; +import { HelpPanel } from "./modals/HelpPanel.tsx"; + +export interface AppProps { + options: TuiOptions; + renderer: CliRenderer; +} + +type ModalState = + | { kind: "none" } + | { kind: "addStage" } + | { kind: "editStage" } + | { kind: "confirmDelete"; stageId: string } + | { kind: "help" } + | { kind: "exportPicker" }; + +export function App({ options, renderer }: AppProps) { + const hasInput = Boolean(options.inputFile || options.sessionId); + + const [state, dispatch] = useReducer(pipelineReducer, undefined, () => { + const initial = createInitialState(); + // If input file provided, add it as an input source + if (options.inputFile) { + return pipelineReducer(initial, { + type: "ADD_INPUT", + source: { kind: "file", path: options.inputFile }, + label: options.inputFile.split("/").pop() ?? options.inputFile, + }); + } + return initial; + }); + + const [modal, setModal] = useState({ kind: "none" }); + const [statusMessage, setStatusMessage] = useState(null); + + const showStatus = useCallback((msg: string) => { + setStatusMessage(msg); + setTimeout(() => setStatusMessage(null), 3000); + }, []); + + // Global keyboard handler + useKeyboard((key) => { + // Modal is open — don't handle global keys + if (modal.kind !== "none") return; + + // Global keys (always active) + if (key.name === "q" && !key.ctrl) { + renderer.destroy(); + return; + } + if (key.raw === "?") { + setModal({ kind: "help" }); + return; + } + if (key.raw === "u") { + dispatch({ type: "UNDO" }); + return; + } + if (key.name === "r" && key.ctrl) { + dispatch({ type: "REDO" }); + return; + } + if (key.name === "tab") { + dispatch({ type: "TOGGLE_FOCUS" }); + return; + } + if (key.name === "c" && key.ctrl) { + renderer.destroy(); + return; + } + if (key.raw === "x") { + const script = exportAsPipeScript(state); + void copyToClipboard(script).then((ok) => { + showStatus(ok ? "Copied pipe script!" : "Export: clipboard failed"); + }); + return; + } + if (key.raw === "X") { + setModal({ kind: "exportPicker" }); + return; + } + if (key.raw === "v") { + const output = getCursorOutput(state); + if (output && output.records.length > 0) { + const tmpPath = `/tmp/recs-${Date.now()}.jsonl`; + const jsonl = output.records.map((r) => JSON.stringify(r.toJSON())).join("\n") + "\n"; + void Bun.write(tmpPath, jsonl).then(() => { + void copyToClipboard(tmpPath).then(() => { + showStatus(`Records exported to ${tmpPath}`); + }); + }); + } else { + showStatus("No records to export"); + } + return; + } + + // Pipeline panel keys + if (state.focusedPanel === "pipeline") { + if (key.name === "up" || key.raw === "k") { + dispatch({ type: "MOVE_CURSOR", direction: "up" }); + return; + } + if (key.name === "down" || key.raw === "j") { + dispatch({ type: "MOVE_CURSOR", direction: "down" }); + return; + } + if (key.raw === "a") { + setModal({ kind: "addStage" }); + return; + } + if (key.raw === "d") { + if (state.cursorStageId) { + setModal({ kind: "confirmDelete", stageId: state.cursorStageId }); + } + return; + } + if (key.raw === "e") { + if (state.cursorStageId) { + setModal({ kind: "editStage" }); + } + return; + } + if (key.raw === " ") { + if (state.cursorStageId) { + dispatch({ type: "TOGGLE_STAGE", stageId: state.cursorStageId }); + } + return; + } + if (key.raw === "r") { + if (state.cursorStageId) { + dispatch({ type: "INVALIDATE_STAGE", stageId: state.cursorStageId }); + const downstream = getDownstreamStages(state, state.cursorStageId); + for (const s of downstream) { + dispatch({ type: "INVALIDATE_STAGE", stageId: s.id }); + } + showStatus("Re-running from cursor..."); + } + return; + } + if (key.raw === "J") { + if (state.cursorStageId) { + dispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "down" }); + } + return; + } + if (key.raw === "K") { + if (state.cursorStageId) { + dispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "up" }); + } + return; + } + if (key.name === "return" || key.name === "tab") { + dispatch({ type: "TOGGLE_FOCUS" }); + return; + } + } + + // Inspector panel keys + if (state.focusedPanel === "inspector") { + if (key.name === "escape") { + dispatch({ type: "TOGGLE_FOCUS" }); + return; + } + if (key.raw === "t") { + const modes = ["table", "prettyprint", "json"] as const; + const currentIdx = modes.indexOf(state.inspector.viewMode as typeof modes[number]); + const nextIdx = (currentIdx + 1) % modes.length; + dispatch({ type: "SET_VIEW_MODE", viewMode: modes[nextIdx]! }); + return; + } + } + }); + + const handleAddStageSelect = useCallback( + (operationName: string) => { + const config: StageConfig = { + operationName, + args: [], + enabled: true, + }; + dispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + } satisfies PipelineAction); + setModal({ kind: "none" }); + showStatus(`Added ${operationName} stage`); + }, + [state.cursorStageId, showStatus], + ); + + const handleEditStageSubmit = useCallback( + (args: string[]) => { + if (state.cursorStageId) { + dispatch({ + type: "UPDATE_STAGE_ARGS", + stageId: state.cursorStageId, + args, + }); + } + setModal({ kind: "none" }); + }, + [state.cursorStageId], + ); + + const handleConfirmDelete = useCallback(() => { + if (modal.kind === "confirmDelete") { + dispatch({ type: "DELETE_STAGE", stageId: modal.stageId }); + showStatus("Stage deleted"); + } + setModal({ kind: "none" }); + }, [modal, showStatus]); + + const handleExportFormat = useCallback( + (format: ExportFormat) => { + setModal({ kind: "none" }); + if (format === "pipe-script") { + const text = exportAsPipeScript(state); + void copyToClipboard(text).then((ok) => { + showStatus(ok ? "Copied pipe script!" : "Export: clipboard failed"); + }); + } else if (format === "chain-command") { + const text = exportAsChainCommand(state); + void copyToClipboard(text).then((ok) => { + showStatus(ok ? "Copied chain command!" : "Export: clipboard failed"); + }); + } else { + // save-file + const script = exportAsPipeScript(state); + const tmpPath = `/tmp/recs-pipeline-${Date.now()}.sh`; + void Bun.write(tmpPath, script).then(() => { + showStatus(`Saved to ${tmpPath}`); + }); + } + }, + [state, showStatus], + ); + + if (!hasInput) { + return ( + + Welcome to recs tui + + Open a file to start building a pipeline: + recs tui <file> + + Press q to quit + + ); + } + + const cursorStage = getCursorStage(state); + const cursorLabel = cursorStage?.config.operationName; + + return ( + + {/* Title bar */} + + + {/* Main content: pipeline list + inspector */} + + + + + + {/* Status bar */} + + + {/* Modals */} + {modal.kind === "addStage" && ( + setModal({ kind: "none" })} + afterLabel={cursorLabel} + /> + )} + {modal.kind === "editStage" && cursorStage && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "confirmDelete" && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "help" && ( + setModal({ kind: "none" })} /> + )} + {modal.kind === "exportPicker" && ( + setModal({ kind: "none" })} + /> + )} + + ); +} diff --git a/src/tui/components/InspectorHeader.tsx b/src/tui/components/InspectorHeader.tsx new file mode 100644 index 0000000..05dca75 --- /dev/null +++ b/src/tui/components/InspectorHeader.tsx @@ -0,0 +1,68 @@ +import type { PipelineState } from "../model/types.ts"; +import { getCursorStage, getCursorOutput, getActivePath, getStageOutput } from "../model/selectors.ts"; + +export interface InspectorHeaderProps { + state: PipelineState; +} + +function formatCacheAge(computedAt: number): string { + const elapsed = Date.now() - computedAt; + if (elapsed < 1000) return "just now"; + if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`; + if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`; + return `${Math.floor(elapsed / 3_600_000)}h ago`; +} + +export function InspectorHeader({ state }: InspectorHeaderProps) { + const stage = getCursorStage(state); + const output = getCursorOutput(state); + + if (!stage) { + return ( + + Inspector: (select a stage) + + ); + } + + if (state.executing) { + return ( + + Inspector: {stage.config.operationName} (computing...) + + ); + } + + if (state.lastError?.stageId === stage.id) { + return ( + + Inspector: {stage.config.operationName} — ERROR + + ); + } + + // Get total record count from first stage for ratio display + const activePath = getActivePath(state); + const firstStage = activePath[0]; + const firstOutput = firstStage ? getStageOutput(state, firstStage.id) : undefined; + const totalRecords = firstOutput?.recordCount; + + let countStr: string; + if (!output) { + countStr = "not cached"; + } else if (totalRecords && totalRecords > 0 && output.recordCount !== totalRecords) { + const pct = Math.round((output.recordCount / totalRecords) * 100); + countStr = `${output.recordCount} of ${totalRecords} records (${pct}%)`; + } else { + countStr = `${output.recordCount} records`; + } + const cacheAge = output ? `, cached ${formatCacheAge(output.computedAt)}` : ""; + + return ( + + + Inspector: {stage.config.operationName} ({countStr}{cacheAge}) + + + ); +} diff --git a/src/tui/components/InspectorPanel.tsx b/src/tui/components/InspectorPanel.tsx new file mode 100644 index 0000000..df080df --- /dev/null +++ b/src/tui/components/InspectorPanel.tsx @@ -0,0 +1,84 @@ +import type { PipelineState } from "../model/types.ts"; +import { getCursorStage, getCursorOutput } from "../model/selectors.ts"; +import { InspectorHeader } from "./InspectorHeader.tsx"; +import { RecordTable } from "./RecordTable.tsx"; + +export interface InspectorPanelProps { + state: PipelineState; +} + +function PrettyPrintView({ result }: { result: { records: import("../model/types.ts").CachedResult["records"]; recordCount: number }; scrollOffset: number }) { + if (result.records.length === 0) { + return (no records); + } + // Show first few records as pretty-printed JSON + const lines = result.records.slice(0, 20).map((r, i) => + `Record ${i + 1}: ${JSON.stringify(r.toJSON(), null, 2)}`, + ); + return ( + + {lines.map((line, i) => ( + {line} + ))} + + ); +} + +function JsonView({ result }: { result: { records: import("../model/types.ts").CachedResult["records"] } }) { + if (result.records.length === 0) { + return (no records); + } + const lines = result.records.slice(0, 50).map((r) => r.toString()); + return ( + + {lines.map((line, i) => ( + {line} + ))} + + ); +} + +export function InspectorPanel({ state }: InspectorPanelProps) { + const isFocused = state.focusedPanel === "inspector"; + const stage = getCursorStage(state); + const output = getCursorOutput(state); + + return ( + + + + + {!stage ? ( + Select a stage to inspect its output + ) : state.executing ? ( + Computing... + ) : state.lastError?.stageId === stage.id ? ( + + Error: {state.lastError.message} + + ) : !output ? ( + No cached output. Press r to execute. + ) : state.inspector.viewMode === "table" ? ( + + ) : state.inspector.viewMode === "prettyprint" ? ( + + ) : state.inspector.viewMode === "json" ? ( + + ) : ( + Schema view (coming soon) + )} + + + ); +} diff --git a/src/tui/components/RecordTable.tsx b/src/tui/components/RecordTable.tsx new file mode 100644 index 0000000..2fadcd1 --- /dev/null +++ b/src/tui/components/RecordTable.tsx @@ -0,0 +1,70 @@ +import type { CachedResult } from "../model/types.ts"; + +export interface RecordTableProps { + result: CachedResult; + scrollOffset: number; + maxRows?: number; +} + +export function RecordTable({ + result, + scrollOffset, + maxRows = 50, +}: RecordTableProps) { + if (result.records.length === 0) { + return (no records); + } + + // Compute column widths from field names + data + const fields = result.fieldNames; + if (fields.length === 0) { + return (no fields); + } + + const visibleRecords = result.records.slice( + scrollOffset, + scrollOffset + maxRows, + ); + + // Auto-calculate column widths from field names + data + const COL_MIN = 4; + const COL_MAX = 30; + const colWidths = fields.map((field) => { + let maxWidth = field.length; + for (const record of visibleRecords) { + const val = record.get(field); + const str = val === null || val === undefined ? "" : String(val); + maxWidth = Math.max(maxWidth, str.length); + } + return Math.min(Math.max(maxWidth, COL_MIN), COL_MAX); + }); + + // Build header + const header = "# " + fields.map((f, i) => f.padEnd(colWidths[i]!).slice(0, colWidths[i]!)).join(" "); + + // Build rows + const rows = visibleRecords.map((record, idx) => { + const rowNum = String(scrollOffset + idx + 1).padStart(3); + const cells = fields.map((field, i) => { + const val = record.get(field); + const str = val === null || val === undefined ? "" : String(val); + return str.padEnd(colWidths[i]!).slice(0, colWidths[i]!); + }); + return `${rowNum} ${cells.join(" ")}`; + }); + + const footer = + result.recordCount > scrollOffset + maxRows + ? `... (${result.recordCount} total)` + : ""; + + return ( + + {header} + {rows.map((row, i) => ( + {row} + ))} + {footer ? {footer} : null} + + ); +} diff --git a/src/tui/components/StageList.tsx b/src/tui/components/StageList.tsx new file mode 100644 index 0000000..223f752 --- /dev/null +++ b/src/tui/components/StageList.tsx @@ -0,0 +1,38 @@ +import type { PipelineState, PipelineAction } from "../model/types.ts"; +import { getActivePath } from "../model/selectors.ts"; +import { StageRow } from "./StageRow.tsx"; + +export interface StageListProps { + state: PipelineState; + dispatch: (action: PipelineAction) => void; +} + +export function StageList({ state }: StageListProps) { + const stages = getActivePath(state); + const isFocused = state.focusedPanel === "pipeline"; + + return ( + + Pipeline + {stages.length === 0 ? ( + (empty — press a to add) + ) : ( + + {stages.map((stage) => ( + + ))} + + )} + + ); +} diff --git a/src/tui/components/StageRow.tsx b/src/tui/components/StageRow.tsx new file mode 100644 index 0000000..51b5f1e --- /dev/null +++ b/src/tui/components/StageRow.tsx @@ -0,0 +1,68 @@ +import type { Stage, PipelineState, StageId } from "../model/types.ts"; +import { isDownstreamOfError, getStageOutput } from "../model/selectors.ts"; + +export interface StageRowProps { + stage: Stage; + state: PipelineState; + isCursor: boolean; +} + +function getCacheIndicator( + state: PipelineState, + stageId: StageId, +): string { + if (state.lastError?.stageId === stageId) return "\u2717"; // ✗ + if (state.executing && state.cursorStageId === stageId) return "\u27F3"; // ⟳ + const cached = getStageOutput(state, stageId); + if (cached) return "\u2713"; // ✓ + return "\u26A1"; // ⚡ +} + +function getRecordCount( + state: PipelineState, + stageId: StageId, +): string { + const cached = getStageOutput(state, stageId); + if (cached) return String(cached.recordCount); + return "----"; +} + +export function StageRow({ stage, state, isCursor }: StageRowProps) { + const isError = state.lastError?.stageId === stage.id; + const isDisabled = !stage.config.enabled; + const isDownstream = isDownstreamOfError(state, stage.id); + + const cacheIcon = getCacheIndicator(state, stage.id); + const count = getRecordCount(state, stage.id); + const cursor = isCursor ? ">" : " "; + + const argsStr = stage.config.args.join(" "); + const truncatedArgs = argsStr.length > 20 ? argsStr.slice(0, 17) + "..." : argsStr; + + // Determine fg color + let fg: string | undefined; + if (isError) { + fg = "#FF4444"; + } else if (isDisabled || isDownstream) { + fg = "#666666"; + } else if (isCursor) { + fg = "#FFFFFF"; + } + + const bg = isCursor ? "#333333" : undefined; + const posStr = String(stage.position + 1).padStart(3); + const disabledMarker = isDisabled ? " [off]" : ""; + + return ( + + + {cursor} {posStr} {stage.config.operationName} {cacheIcon} {count}{disabledMarker} + + {truncatedArgs ? ( + + {" "}{truncatedArgs} + + ) : null} + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx new file mode 100644 index 0000000..8f55682 --- /dev/null +++ b/src/tui/components/StatusBar.tsx @@ -0,0 +1,30 @@ +import type { PipelineState } from "../model/types.ts"; + +export interface StatusBarProps { + state: PipelineState; + statusMessage?: string | null; +} + +export function StatusBar({ state, statusMessage }: StatusBarProps) { + const undoCount = state.undoStack.length; + const errorMsg = state.lastError?.message; + + // Context-sensitive keybindings based on focused panel + const keys = + state.focusedPanel === "pipeline" + ? "a:add d:del e:edit J/K:move x:export v:vim u:undo ?:help q:quit" + : "↑↓:scroll t:view /:search Tab:back"; + + return ( + + + {statusMessage + ? statusMessage + : errorMsg + ? `Error: ${errorMsg}` + : keys} + + undo:{undoCount} + + ); +} diff --git a/src/tui/components/TitleBar.tsx b/src/tui/components/TitleBar.tsx new file mode 100644 index 0000000..d6c75f2 --- /dev/null +++ b/src/tui/components/TitleBar.tsx @@ -0,0 +1,29 @@ +import type { PipelineState } from "../model/types.ts"; + +export interface TitleBarProps { + state: PipelineState; +} + +export function TitleBar({ state }: TitleBarProps) { + const input = state.inputs.get(state.activeInputId); + const inputLabel = input?.label ?? "(no input)"; + + const fork = state.forks.get(state.activeForkId); + const forkLabel = fork?.name ?? "main"; + + const cachedResult = state.cursorStageId + ? state.cache.get(`${state.activeInputId}:${state.cursorStageId}`) + : undefined; + const recordCount = cachedResult?.recordCount; + const countStr = + recordCount !== undefined ? ` (${recordCount} rec)` : ""; + + return ( + + recs tui + + input: {inputLabel}{countStr} fork: {forkLabel} [?] + + + ); +} diff --git a/src/tui/components/modals/AddStageModal.tsx b/src/tui/components/modals/AddStageModal.tsx new file mode 100644 index 0000000..d71b502 --- /dev/null +++ b/src/tui/components/modals/AddStageModal.tsx @@ -0,0 +1,226 @@ +/** + * AddStageModal — categorized operation picker with fuzzy search + preview. + * + * Two-column layout: + * - Left: search input + categorized operation list (Transform, Input, Output) + * - Right: preview pane showing description, options, and examples + * + * Enter selects the operation. Esc cancels. + */ + +import { useState, useMemo } from "react"; +import { useKeyboard } from "@opentui/react"; +import { allDocs } from "../../../cli/operation-registry.ts"; +import type { CommandDoc } from "../../../types/CommandDoc.ts"; +import { fuzzyFilter } from "../../utils/fuzzy-match.ts"; + +export interface AddStageModalProps { + /** Called when user selects an operation */ + onSelect: (operationName: string) => void; + /** Called when user cancels (Esc) */ + onCancel: () => void; + /** Label shown in title (e.g., "after: grep") */ + afterLabel?: string; +} + +/** Operations hidden from the picker (internal-only). */ +const HIDDEN_OPS = new Set(["chain"]); + +interface CategoryGroup { + label: string; + docs: CommandDoc[]; +} + +function groupByCategory(docs: CommandDoc[]): CategoryGroup[] { + const transform: CommandDoc[] = []; + const input: CommandDoc[] = []; + const output: CommandDoc[] = []; + + for (const doc of docs) { + if (HIDDEN_OPS.has(doc.name)) continue; + switch (doc.category) { + case "transform": + transform.push(doc); + break; + case "input": + input.push(doc); + break; + case "output": + output.push(doc); + break; + } + } + + const groups: CategoryGroup[] = []; + if (transform.length > 0) groups.push({ label: "TRANSFORM", docs: transform }); + if (input.length > 0) groups.push({ label: "INPUT", docs: input }); + if (output.length > 0) groups.push({ label: "OUTPUT", docs: output }); + return groups; +} + +function formatPreview(doc: CommandDoc): string { + const lines: string[] = []; + + lines.push(doc.name); + lines.push(doc.description); + lines.push(""); + + if (doc.options.length > 0) { + lines.push("Options:"); + for (const opt of doc.options) { + const flags = opt.flags.join(", "); + const arg = opt.argument ? ` <${opt.argument}>` : ""; + lines.push(` ${flags}${arg}`); + lines.push(` ${opt.description}`); + } + lines.push(""); + } + + if (doc.examples.length > 0) { + lines.push("Example:"); + const ex = doc.examples[0]!; + lines.push(` ${ex.command}`); + if (ex.description) { + lines.push(` # ${ex.description}`); + } + } + + return lines.join("\n"); +} + +export function AddStageModal({ + onSelect, + onCancel, + afterLabel, +}: AddStageModalProps) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [focusSearch, setFocusSearch] = useState(true); + + // Filter docs by fuzzy search, then group by category + const filteredDocs = useMemo( + () => + fuzzyFilter( + allDocs.filter((d) => !HIDDEN_OPS.has(d.name)), + query, + (d) => `${d.name} ${d.description}`, + ), + [query], + ); + + const groups = useMemo(() => groupByCategory(filteredDocs), [filteredDocs]); + + // Flat list of visible docs (for index-based navigation) + const flatList = useMemo( + () => groups.flatMap((g) => g.docs), + [groups], + ); + + const selected = flatList[selectedIndex]; + + useKeyboard((key) => { + if (key.name === "escape") { + onCancel(); + return; + } + + if (key.name === "tab") { + setFocusSearch((f) => !f); + return; + } + + if (!focusSearch) { + if (key.name === "up" || key.raw === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.name === "down" || key.raw === "j") { + setSelectedIndex((i) => Math.min(flatList.length - 1, i + 1)); + } else if (key.name === "return") { + if (selected) { + onSelect(selected.name); + } + } + } + }); + + const titleText = afterLabel + ? `Add Stage (after: ${afterLabel})` + : "Add Stage"; + + return ( + + {/* Title bar */} + + {titleText} + [Esc] cancel + + + {/* Search input */} + + Search: + { + setQuery(v); + setSelectedIndex(0); + }} + placeholder="type to filter..." + focused={focusSearch} + width={30} + /> + + + {/* Two-column content */} + + {/* Left: categorized list */} + + {groups.map((group) => ( + + + {group.label} + + {group.docs.map((doc) => { + const idx = flatList.indexOf(doc); + const isSelected = idx === selectedIndex; + return ( + + {isSelected ? "> " : " "} + {doc.name} + + ); + })} + + ))} + {flatList.length === 0 && ( + No matching operations + )} + + + {/* Right: preview pane */} + + {selected ? ( + {formatPreview(selected)} + ) : ( + Select an operation to see details + )} + + + + {/* Footer hint */} + + + Tab:switch focus ↑↓:navigate Enter:select Esc:cancel + + + + ); +} diff --git a/src/tui/components/modals/ConfirmDialog.tsx b/src/tui/components/modals/ConfirmDialog.tsx new file mode 100644 index 0000000..d0f6735 --- /dev/null +++ b/src/tui/components/modals/ConfirmDialog.tsx @@ -0,0 +1,44 @@ +/** + * ConfirmDialog — generic yes/no confirmation modal. + * + * Used for stage deletion, fork deletion, etc. + */ + +import { useKeyboard } from "@opentui/react"; + +export interface ConfirmDialogProps { + /** The question to display */ + message: string; + /** Called when user confirms (y/Enter) */ + onConfirm: () => void; + /** Called when user cancels (n/Esc) */ + onCancel: () => void; +} + +export function ConfirmDialog({ + message, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + useKeyboard((key) => { + if (key.raw === "y" || key.name === "return") { + onConfirm(); + } else if (key.raw === "n" || key.name === "escape") { + onCancel(); + } + }); + + return ( + + {message} + + [y/Enter] confirm [n/Esc] cancel + + + ); +} diff --git a/src/tui/components/modals/EditStageModal.tsx b/src/tui/components/modals/EditStageModal.tsx new file mode 100644 index 0000000..723ffd3 --- /dev/null +++ b/src/tui/components/modals/EditStageModal.tsx @@ -0,0 +1,104 @@ +/** + * EditStageModal — raw args text input for editing stage arguments. + * + * Phase 1: Simple single-line input for operation arguments. + * Phase 2: Will add field autocomplete from upstream stage's fieldNames. + */ + +import { useState } from "react"; +import { useKeyboard } from "@opentui/react"; + +export interface EditStageModalProps { + /** The operation name being edited */ + operationName: string; + /** Current args as a single string (space-separated) */ + currentArgs: string; + /** Called when user confirms edit */ + onConfirm: (args: string[]) => void; + /** Called when user cancels */ + onCancel: () => void; +} + +export function EditStageModal({ + operationName, + currentArgs, + onConfirm, + onCancel, +}: EditStageModalProps) { + const [value, setValue] = useState(currentArgs); + + useKeyboard((key) => { + if (key.name === "escape") { + onCancel(); + } else if (key.name === "return") { + // Split on whitespace, respecting simple quoting + const args = parseArgs(value); + onConfirm(args); + } + }); + + return ( + + + + Edit: {operationName} + + [Esc] cancel + + + + Args: + + + + + Enter:confirm Esc:cancel + + + ); +} + +/** + * Parse a string of arguments, respecting single and double quotes. + * Returns an array of argument strings. + */ +function parseArgs(input: string): string[] { + const args: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } else if (ch === " " && !inSingle && !inDouble) { + if (current.length > 0) { + args.push(current); + current = ""; + } + } else { + current += ch; + } + } + + if (current.length > 0) { + args.push(current); + } + + return args; +} diff --git a/src/tui/components/modals/ExportPicker.tsx b/src/tui/components/modals/ExportPicker.tsx new file mode 100644 index 0000000..d1b5505 --- /dev/null +++ b/src/tui/components/modals/ExportPicker.tsx @@ -0,0 +1,98 @@ +/** + * ExportPicker — format picker for exporting the pipeline. + * + * Options: + * - Pipe script (multi-line shell script with | piping) + * - Chain command (single-line recs chain format) + * - Save to file (writes pipe script to a file) + * + * The selected format is copied to clipboard or written to file. + */ + +import { useState } from "react"; +import { useKeyboard } from "@opentui/react"; + +export type ExportFormat = "pipe-script" | "chain-command" | "save-file"; + +export interface ExportPickerProps { + onSelect: (format: ExportFormat) => void; + onCancel: () => void; +} + +const OPTIONS: { format: ExportFormat; label: string; description: string }[] = [ + { + format: "pipe-script", + label: "Pipe script", + description: "Multi-line shell script with | piping (copied to clipboard)", + }, + { + format: "chain-command", + label: "Chain command", + description: "Single-line recs chain command (copied to clipboard)", + }, + { + format: "save-file", + label: "Save to file", + description: "Write executable pipe script to a file", + }, +]; + +export function ExportPicker({ onSelect, onCancel }: ExportPickerProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + useKeyboard((key) => { + if (key.name === "escape") { + onCancel(); + return; + } + + if (key.name === "up" || key.raw === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.name === "down" || key.raw === "j") { + setSelectedIndex((i) => Math.min(OPTIONS.length - 1, i + 1)); + } else if (key.name === "return") { + const option = OPTIONS[selectedIndex]; + if (option) { + onSelect(option.format); + } + } + }); + + return ( + + + + Export Pipeline + + [Esc] cancel + + + + {OPTIONS.map((opt, idx) => { + const isSelected = idx === selectedIndex; + return ( + + + {isSelected ? "> " : " "} + {opt.label} + + {opt.description} + + ); + })} + + + + ↑↓:navigate Enter:select Esc:cancel + + + ); +} diff --git a/src/tui/components/modals/HelpPanel.tsx b/src/tui/components/modals/HelpPanel.tsx new file mode 100644 index 0000000..a820172 --- /dev/null +++ b/src/tui/components/modals/HelpPanel.tsx @@ -0,0 +1,72 @@ +/** + * HelpPanel — full keyboard reference overlay. + * + * Displayed when the user presses `?`. Shows all keyboard shortcuts + * organized by context (global, pipeline, inspector). + */ + +import { useKeyboard } from "@opentui/react"; + +export interface HelpPanelProps { + onClose: () => void; +} + +const HELP_TEXT = `Keyboard Reference + +PIPELINE (left panel) + ↑/k, ↓/j Move cursor between stages + a Add stage after cursor + A Add stage before cursor + d Delete stage (with confirm) + e Edit stage arguments + Space Toggle stage enabled/disabled + J/K Reorder stage down/up + r Re-run from cursor stage + v Export records to temp file + x Export pipeline → clipboard + X Export pipeline (choose format) + Enter/Tab Focus inspector panel + +INSPECTOR (right panel) + ↑/k, ↓/j Scroll records + PgUp/PgDn Page scroll + t Cycle view: table → prettyprint → json + / Search records + Esc/Tab Return to pipeline + +GLOBAL + Tab Toggle focus: pipeline ↔ inspector + u Undo last pipeline edit + Ctrl+R Redo last undone edit + Ctrl+C Quit + ? Toggle this help + q Quit`; + +export function HelpPanel({ onClose }: HelpPanelProps) { + useKeyboard((key) => { + if (key.name === "escape" || key.raw === "?" || key.raw === "q") { + onClose(); + } + }); + + return ( + + + + Help + + [Esc] or [?] to close + + + + {HELP_TEXT} + + + ); +} diff --git a/src/tui/executor/executor.ts b/src/tui/executor/executor.ts new file mode 100644 index 0000000..f1b2d4d --- /dev/null +++ b/src/tui/executor/executor.ts @@ -0,0 +1,269 @@ +/** + * Pipeline executor for the TUI. + * + * executeToStage() walks the stage path from the input to the target stage, + * finds the nearest cached result, and executes forward from there. Uses + * createOperation() from chain.ts to instantiate operations programmatically. + */ + +// Side-effect import: registers all operation factories so createOperation() works. +import "../../cli/dispatcher.ts"; + +import { createOperation } from "../../operations/transform/chain.ts"; +import { Operation } from "../../Operation.ts"; +import type { Record } from "../../Record.ts"; +import { InterceptReceiver } from "./intercept-receiver.ts"; +import { loadInputRecords, loadInputContent } from "./input-loader.ts"; +import type { + PipelineState, + StageId, + Stage, + CachedResult, +} from "../model/types.ts"; + +/** Operations that consume bulk stdin content via parseContent(). */ +const BULK_STDIN_OPS = new Set([ + "fromcsv", + "fromjsonarray", + "fromkv", + "fromxml", +]); + +/** Operations that are fully self-contained (no input needed). */ +const SELF_CONTAINED_OPS = new Set(["fromps", "fromdb", "frommongo"]); + +/** + * Check if an operation instance overrides acceptLine (line-oriented input op). + */ +function hasCustomAcceptLine(op: Operation): boolean { + const proto = Object.getPrototypeOf(op) as { [key: string]: unknown }; + return ( + typeof proto["acceptLine"] === "function" && + proto["acceptLine"] !== Operation.prototype.acceptLine + ); +} + +/** + * Get the ordered path of stages from the root to the target stage, + * following parentId links back to the beginning. + */ +export function getStagePath( + state: PipelineState, + targetStageId: StageId, +): Stage[] { + const path: Stage[] = []; + let currentId: StageId | null = targetStageId; + + while (currentId !== null) { + const stage = state.stages.get(currentId); + if (!stage) break; + path.unshift(stage); + currentId = stage.parentId; + } + + return path; +} + +/** + * Find the index of the nearest cached ancestor in the stage path. + * Returns -1 if no cached result exists (must start from input). + */ +function findNearestCache( + state: PipelineState, + path: Stage[], +): number { + for (let i = path.length - 1; i >= 0; i--) { + const stage = path[i]!; + const cacheKey = `${state.activeInputId}:${stage.id}`; + if (state.cache.has(cacheKey)) { + return i; + } + } + return -1; +} + +/** + * Execute the pipeline from the input (or nearest cache) to the target stage. + * + * Returns a CachedResult with the intercepted records, field names, and metadata. + * The result is also stored in state.cache. + */ +export async function executeToStage( + state: PipelineState, + targetStageId: StageId, +): Promise { + const startTime = performance.now(); + const path = getStagePath(state, targetStageId); + + if (path.length === 0) { + throw new Error(`Stage ${targetStageId} not found in pipeline`); + } + + const input = state.inputs.get(state.activeInputId); + if (!input) { + throw new Error(`Input ${state.activeInputId} not found`); + } + + // Find nearest cached ancestor + const cachedIndex = findNearestCache(state, path); + + // Determine starting records and which stages to execute + let currentRecords: Record[]; + let startIndex: number; + + if (cachedIndex >= 0) { + const cachedStage = path[cachedIndex]!; + const cacheKey = `${state.activeInputId}:${cachedStage.id}`; + const cached = state.cache.get(cacheKey)!; + currentRecords = cached.records; + startIndex = cachedIndex + 1; + } else { + // No cache — check if first enabled stage is a transform (needs input records) + const firstEnabled = path.find((s) => s.config.enabled); + if (firstEnabled && !isInputOperation(firstEnabled.config.operationName)) { + currentRecords = await loadInputRecords(input); + } else { + currentRecords = []; + } + startIndex = 0; + } + + // Execute stages from startIndex to end of path + for (let i = startIndex; i < path.length; i++) { + const stage = path[i]!; + + if (!stage.config.enabled) { + // Disabled stages pass through records unchanged + continue; + } + + const opName = stage.config.operationName; + const interceptor = new InterceptReceiver(); + + const op = createOperation(opName, [...stage.config.args], interceptor); + + if (isInputOperation(opName)) { + // Input operations: handle the 3 patterns + await executeInputOp(op, opName, input, state); + } else { + // Transform/output operations: feed records from previous stage + for (const record of currentRecords) { + if (!op.acceptRecord(record)) break; + } + } + + op.finish(); + currentRecords = interceptor.records; + + // Cache this stage's result + const elapsed = performance.now() - startTime; + const result: CachedResult = { + key: `${state.activeInputId}:${stage.id}`, + stageId: stage.id, + inputId: state.activeInputId, + records: interceptor.records, + spillFile: null, + recordCount: interceptor.recordCount, + fieldNames: [...interceptor.fieldNames], + computedAt: Date.now(), + sizeBytes: estimateSize(interceptor.records), + computeTimeMs: elapsed, + }; + + state.cache.set(result.key, result); + } + + // Return the final stage's cached result + const targetCacheKey = `${state.activeInputId}:${targetStageId}`; + const finalResult = state.cache.get(targetCacheKey); + if (!finalResult) { + // This can happen if the target stage was disabled — return empty result + const elapsed = performance.now() - startTime; + const emptyResult: CachedResult = { + key: targetCacheKey, + stageId: targetStageId, + inputId: state.activeInputId, + records: currentRecords, + spillFile: null, + recordCount: currentRecords.length, + fieldNames: [...new Set(currentRecords.flatMap((r) => r.keys()))], + computedAt: Date.now(), + sizeBytes: estimateSize(currentRecords), + computeTimeMs: elapsed, + }; + state.cache.set(targetCacheKey, emptyResult); + return emptyResult; + } + + return finalResult; +} + +/** + * Check if an operation name is an input operation (produces records + * from an external source rather than transforming piped records). + */ +function isInputOperation(opName: string): boolean { + return ( + opName.startsWith("from") || SELF_CONTAINED_OPS.has(opName) + ); +} + +/** + * Execute an input operation, handling the three input patterns: + * 1. Line-oriented (hasCustomAcceptLine): feed raw text lines + * 2. Bulk-content (BULK_STDIN_OPS): call parseContent() with file content + * 3. Self-contained (fromps, fromdb): just call finish() (handled by caller) + */ +async function executeInputOp( + op: Operation, + opName: string, + input: { source: { kind: "file"; path: string } | { kind: "stdin-capture"; records: Record[] }; label: string; id: string }, + _state: PipelineState, +): Promise { + if (SELF_CONTAINED_OPS.has(opName)) { + // Self-contained ops generate records on their own (finish triggers it) + return; + } + + if (BULK_STDIN_OPS.has(opName)) { + // Bulk content ops need the raw file content + const content = await loadInputContent(input); + const opAny = op as unknown as { [key: string]: unknown }; + if (opName === "fromxml" && typeof opAny["parseXml"] === "function") { + (opAny["parseXml"] as (xml: string) => void)(content); + } else if (typeof opAny["parseContent"] === "function") { + (opAny["parseContent"] as (content: string) => void)(content); + } + return; + } + + if (hasCustomAcceptLine(op)) { + // Line-oriented ops: feed raw text lines from the input + if (input.source.kind === "file") { + const content = await loadInputContent(input); + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed !== "") { + if (!op.acceptLine(trimmed)) break; + } + } + } else { + // stdin-capture: convert records back to lines + for (const record of input.source.records) { + if (!op.acceptLine(record.toString())) break; + } + } + } +} + +/** + * Rough estimate of memory size for an array of records. + */ +function estimateSize(records: Record[]): number { + let size = 0; + for (const r of records) { + size += r.toString().length * 2; // rough: 2 bytes per char + } + return size; +} diff --git a/src/tui/executor/input-loader.ts b/src/tui/executor/input-loader.ts new file mode 100644 index 0000000..9529e4b --- /dev/null +++ b/src/tui/executor/input-loader.ts @@ -0,0 +1,42 @@ +import { InputStream } from "../../InputStream.ts"; +import type { Record } from "../../Record.ts"; +import type { InputSource } from "../model/types.ts"; + +/** + * Load records from an InputSource. + * + * - file: reads a JSONL file via InputStream + * - stdin-capture: returns the stored records directly + */ +export async function loadInputRecords( + input: InputSource, +): Promise { + switch (input.source.kind) { + case "file": { + const stream = InputStream.fromFile(input.source.path); + return stream.toArray(); + } + case "stdin-capture": { + return input.source.records; + } + } +} + +/** + * Load raw file content as a string (for bulk-content ops like fromcsv). + * Only applicable to file-based inputs. + */ +export async function loadInputContent( + input: InputSource, +): Promise { + switch (input.source.kind) { + case "file": { + const file = Bun.file(input.source.path); + return file.text(); + } + case "stdin-capture": { + // Convert records to JSONL for bulk ops that expect raw content + return input.source.records.map((r) => r.toString()).join("\n") + "\n"; + } + } +} diff --git a/src/tui/executor/intercept-receiver.ts b/src/tui/executor/intercept-receiver.ts new file mode 100644 index 0000000..b499d06 --- /dev/null +++ b/src/tui/executor/intercept-receiver.ts @@ -0,0 +1,32 @@ +import type { RecordReceiver } from "../../Operation.ts"; +import type { Record } from "../../Record.ts"; + +/** + * A RecordReceiver that intercepts records flowing through a pipeline, + * collecting them for inspection in the TUI. Tracks field names and + * record counts for the inspector panel. + */ +export class InterceptReceiver implements RecordReceiver { + records: Record[] = []; + fieldNames = new Set(); + recordCount = 0; + lines: string[] = []; + + acceptRecord(record: Record): boolean { + this.recordCount++; + for (const key of record.keys()) { + this.fieldNames.add(key); + } + this.records.push(record.clone()); + return true; + } + + acceptLine(line: string): boolean { + this.lines.push(line); + return true; + } + + finish(): void { + // Nothing to do — results are read from records/fieldNames/recordCount + } +} diff --git a/src/tui/hooks/useAutoSave.ts b/src/tui/hooks/useAutoSave.ts new file mode 100644 index 0000000..1f98867 --- /dev/null +++ b/src/tui/hooks/useAutoSave.ts @@ -0,0 +1,16 @@ +/** + * useAutoSave — Stub for Phase 3 session persistence. + * + * In Phase 3, this will debounce auto-saves to ~/.config/recs-tui/sessions/ + * on structural changes and at regular intervals. For now, it's a no-op + * so components can import it without errors. + */ + +import type { PipelineState } from "../model/types.ts"; + +export function useAutoSave(_state: PipelineState): void { + // Phase 3: Implement debounced session persistence + // - Save every 30s and on structural change + // - Write session.json + cache JSONL files + // - Handle session directory creation +} diff --git a/src/tui/hooks/useExecution.ts b/src/tui/hooks/useExecution.ts new file mode 100644 index 0000000..ec395df --- /dev/null +++ b/src/tui/hooks/useExecution.ts @@ -0,0 +1,117 @@ +/** + * useExecution — Async pipeline execution hook. + * + * Watches the cursor stage and triggers execution when needed. + * On cache hit: returns cached result immediately. + * On cache miss: runs executeToStage and updates state with result. + * + * Returns execution status, the current result, and any error. + */ + +import { useState, useEffect, useRef } from "react"; +import { executeToStage } from "../executor/executor.ts"; +import type { + PipelineState, + PipelineAction, + CachedResult, +} from "../model/types.ts"; + +export interface UseExecutionResult { + /** Whether the executor is currently running */ + isExecuting: boolean; + /** The result for the current cursor stage (if available) */ + currentResult: CachedResult | null; + /** Error message if execution failed */ + error: string | null; +} + +export function useExecution( + state: PipelineState, + dispatch: (action: PipelineAction) => void, +): UseExecutionResult { + const [isExecuting, setIsExecuting] = useState(false); + const [currentResult, setCurrentResult] = useState(null); + const [error, setError] = useState(null); + + // Track the latest execution request to ignore stale completions + const executionIdRef = useRef(0); + + const { cursorStageId, activeInputId, cache, stages } = state; + + useEffect(() => { + if (!cursorStageId) { + setCurrentResult(null); + setError(null); + return; + } + + // Check if the cursor stage exists + const stage = stages.get(cursorStageId); + if (!stage) { + setCurrentResult(null); + setError(null); + return; + } + + // Check cache first + const cacheKey = `${activeInputId}:${cursorStageId}`; + const cached = cache.get(cacheKey); + if (cached) { + setCurrentResult(cached); + setError(null); + setIsExecuting(false); + return; + } + + // Check if there's an input source available + const input = state.inputs.get(activeInputId); + if (!input) { + setError("No input source selected"); + setCurrentResult(null); + return; + } + + // Cache miss — execute + const thisExecId = ++executionIdRef.current; + setIsExecuting(true); + setError(null); + + dispatch({ type: "SET_EXECUTING", executing: true }); + dispatch({ type: "CLEAR_ERROR" }); + + executeToStage(state, cursorStageId) + .then((result) => { + // Only apply if this is still the latest execution + if (executionIdRef.current !== thisExecId) return; + + setCurrentResult(result); + setIsExecuting(false); + + dispatch({ + type: "CACHE_RESULT", + inputId: activeInputId, + stageId: cursorStageId, + result, + }); + dispatch({ type: "SET_EXECUTING", executing: false }); + }) + .catch((err: unknown) => { + if (executionIdRef.current !== thisExecId) return; + + const message = + err instanceof Error ? err.message : String(err); + setError(message); + setIsExecuting(false); + setCurrentResult(null); + + dispatch({ + type: "SET_ERROR", + stageId: cursorStageId, + message, + }); + dispatch({ type: "SET_EXECUTING", executing: false }); + }); + }, [cursorStageId, activeInputId, cache, stages, state, dispatch]); + + return { isExecuting, currentResult, error }; +} diff --git a/src/tui/hooks/usePipeline.ts b/src/tui/hooks/usePipeline.ts new file mode 100644 index 0000000..ade7439 --- /dev/null +++ b/src/tui/hooks/usePipeline.ts @@ -0,0 +1,68 @@ +/** + * usePipeline — React hook wrapping the pipeline reducer. + * + * Provides state + dispatch for the pipeline data model. + * Initializes the state with the given input source if provided. + */ + +import { useReducer, useCallback } from "react"; +import { nanoid } from "nanoid"; +import { + pipelineReducer, + createInitialState, +} from "../model/reducer.ts"; +import type { + PipelineState, + PipelineAction, + InputSourceType, +} from "../model/types.ts"; + +export interface UsePipelineOptions { + /** Initial input source to load */ + initialInput?: { + source: InputSourceType; + label: string; + }; + /** Session ID for persistence */ + sessionId?: string; +} + +export interface UsePipelineResult { + state: PipelineState; + dispatch: (action: PipelineAction) => void; +} + +export function usePipeline(options?: UsePipelineOptions): UsePipelineResult { + const [state, rawDispatch] = useReducer( + pipelineReducer, + options, + (opts) => { + const initial = createInitialState({ + sessionId: opts?.sessionId ?? nanoid(), + }); + + // If an initial input is provided, add it to the state + if (opts?.initialInput) { + const inputId = initial.activeInputId; + const inputs = new Map(initial.inputs); + inputs.set(inputId, { + id: inputId, + source: opts.initialInput.source, + label: opts.initialInput.label, + }); + return { ...initial, inputs }; + } + + return initial; + }, + ); + + const dispatch = useCallback( + (action: PipelineAction) => { + rawDispatch(action); + }, + [rawDispatch], + ); + + return { state, dispatch }; +} diff --git a/src/tui/hooks/useVimIntegration.ts b/src/tui/hooks/useVimIntegration.ts new file mode 100644 index 0000000..7f8a04a --- /dev/null +++ b/src/tui/hooks/useVimIntegration.ts @@ -0,0 +1,64 @@ +/** + * useVimIntegration — Open inspector records in $EDITOR. + * + * When triggered, writes the current inspector records to a temp file + * as JSON lines, spawns $EDITOR (defaults to vim), suspends the TUI + * renderer during editing, then cleans up on exit. + */ + +import { useCallback } from "react"; +import { useRenderer } from "@opentui/react"; +import type { Record } from "../../Record.ts"; + +export interface UseVimIntegrationResult { + /** Open the given records in $EDITOR */ + openInEditor: (records: Record[]) => Promise; +} + +export function useVimIntegration(): UseVimIntegrationResult { + const renderer = useRenderer(); + + const openInEditor = useCallback( + async (records: Record[]) => { + if (records.length === 0) return; + + // Write records to temp file as JSONL + const tmpDir = + process.env["TMPDIR"] ?? process.env["TMP"] ?? "/tmp"; + const tmpFile = `${tmpDir}/recs-tui-${Date.now()}.jsonl`; + + const content = records.map((r) => r.toString()).join("\n") + "\n"; + await Bun.write(tmpFile, content); + + const editor = process.env["EDITOR"] ?? "vim"; + + try { + // Suspend the TUI renderer so the editor can take over + renderer.destroy(); + + const proc = Bun.spawn([editor, tmpFile], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + await proc.exited; + } finally { + // Clean up temp file + try { + const { unlinkSync } = await import("node:fs"); + unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + } + + // Note: After renderer.destroy(), the TUI process should exit. + // In a full implementation, we'd re-create the renderer and re-mount. + // For Phase 1, vim integration exits the TUI (user can resume via session). + }, + [renderer], + ); + + return { openInEditor }; +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx new file mode 100644 index 0000000..2b50eb7 --- /dev/null +++ b/src/tui/index.tsx @@ -0,0 +1,34 @@ +/** + * TUI Pipeline Builder for RecordStream. + * + * This is the main React application entry point. It creates an OpenTUI + * renderer and mounts the root App component. + */ + +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { App } from "./components/App.tsx"; + +export interface TuiOptions { + /** Input file path to open immediately */ + inputFile?: string; + /** Session ID to resume */ + sessionId?: string; + /** Initial pipeline command (e.g., "grep x=1 | sort --key y") */ + pipeline?: string; +} + +/** + * Launch the TUI pipeline builder. + * + * Creates an OpenTUI renderer, mounts the React app, and handles + * clean shutdown on exit. + */ +export async function launchTui(options: TuiOptions): Promise { + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + }); + + const root = createRoot(renderer); + root.render(); +} diff --git a/src/tui/model/reducer.ts b/src/tui/model/reducer.ts new file mode 100644 index 0000000..aa59969 --- /dev/null +++ b/src/tui/model/reducer.ts @@ -0,0 +1,653 @@ +import { nanoid } from "nanoid"; +import type { + PipelineState, + PipelineAction, + Stage, + StageId, + Fork, + CachedResult, + CacheConfig, + InspectorState, +} from "./types.ts"; +import { + UNDOABLE_ACTIONS, + MAX_UNDO_ENTRIES, + extractSnapshot, + describeAction, +} from "./undo.ts"; + +// ── Initial state factory ───────────────────────────────────────── + +export function createInitialState( + overrides?: Partial, +): PipelineState { + const mainForkId = nanoid(); + const mainInputId = nanoid(); + + const defaultCacheConfig: CacheConfig = { + maxMemoryBytes: 512 * 1024 * 1024, // 512 MB + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + + const defaultInspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + }; + + const mainFork: Fork = { + id: mainForkId, + name: "main", + forkPointStageId: null, + parentForkId: null, + stageIds: [], + createdAt: Date.now(), + }; + + return { + stages: new Map(), + forks: new Map([[mainForkId, mainFork]]), + inputs: new Map(), + activeInputId: mainInputId, + activeForkId: mainForkId, + cursorStageId: null, + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig: defaultCacheConfig, + inspector: defaultInspector, + executing: false, + lastError: null, + undoStack: [], + redoStack: [], + sessionId: nanoid(), + sessionDir: "", + ...overrides, + }; +} + +// ── Reducer ─────────────────────────────────────────────────────── + +export function pipelineReducer( + state: PipelineState, + action: PipelineAction, +): PipelineState { + // Early-return guard: skip undo checkpoint for actions targeting nonexistent entities + if (wouldBeNoop(state, action)) return state; + + // Undo checkpoint: push snapshot before structural actions + if (UNDOABLE_ACTIONS.has(action.type)) { + const snapshot = extractSnapshot(state); + const label = describeAction(action); + let undoStack = [...state.undoStack, { label, snapshot, timestamp: Date.now() }]; + if (undoStack.length > MAX_UNDO_ENTRIES) { + undoStack = undoStack.slice(undoStack.length - MAX_UNDO_ENTRIES); + } + state = { + ...state, + undoStack, + redoStack: [], + }; + } + + switch (action.type) { + // ── Undo / Redo ───────────────────────────────────────────── + case "UNDO": { + if (state.undoStack.length === 0) return state; + const entry = state.undoStack[state.undoStack.length - 1]!; + const currentSnapshot = extractSnapshot(state); + return { + ...state, + ...entry.snapshot, + undoStack: state.undoStack.slice(0, -1), + redoStack: [ + ...state.redoStack, + { label: entry.label, snapshot: currentSnapshot, timestamp: Date.now() }, + ], + }; + } + case "REDO": { + if (state.redoStack.length === 0) return state; + const entry = state.redoStack[state.redoStack.length - 1]!; + const currentSnapshot = extractSnapshot(state); + return { + ...state, + ...entry.snapshot, + undoStack: [ + ...state.undoStack, + { label: entry.label, snapshot: currentSnapshot, timestamp: Date.now() }, + ], + redoStack: state.redoStack.slice(0, -1), + }; + } + + // ── Add stage after cursor or at end ──────────────────────── + case "ADD_STAGE": { + const fork = state.forks.get(state.activeForkId); + if (!fork) return state; + + const stageId = nanoid(); + const stageIds = [...fork.stageIds]; + let insertIndex: number; + + if (action.afterStageId === null) { + // Append to end of fork + insertIndex = stageIds.length; + } else { + const idx = stageIds.indexOf(action.afterStageId); + insertIndex = idx === -1 ? stageIds.length : idx + 1; + } + + stageIds.splice(insertIndex, 0, stageId); + + // Determine parentId / childIds by position in fork + const parentId = insertIndex > 0 ? (stageIds[insertIndex - 1] ?? null) : null; + const childId = + insertIndex < stageIds.length - 1 + ? (stageIds[insertIndex + 1] ?? null) + : null; + + const newStage: Stage = { + id: stageId, + config: { ...action.config }, + parentId, + childIds: childId ? [childId] : [], + forkId: state.activeForkId, + position: insertIndex, + }; + + const stages = new Map(state.stages); + stages.set(stageId, newStage); + + // Update parent's childIds to point to new stage + if (parentId) { + const parent = stages.get(parentId); + if (parent) { + const newChildIds = parent.childIds.map((cid) => + cid === childId ? stageId : cid, + ); + if (!newChildIds.includes(stageId)) { + newChildIds.push(stageId); + } + stages.set(parentId, { ...parent, childIds: newChildIds }); + } + } + + // Update child's parentId to point to new stage + if (childId) { + const child = stages.get(childId); + if (child) { + stages.set(childId, { ...child, parentId: stageId }); + } + } + + // Recompute positions + recomputePositions(stages, stageIds); + + const forks = new Map(state.forks); + forks.set(state.activeForkId, { ...fork, stageIds }); + + return { + ...state, + stages, + forks, + cursorStageId: stageId, + }; + } + + // ── Insert stage before a specific stage ──────────────────── + case "INSERT_STAGE_BEFORE": { + const fork = state.forks.get(state.activeForkId); + if (!fork) return state; + + const stageIds = [...fork.stageIds]; + const idx = stageIds.indexOf(action.beforeStageId); + if (idx === -1) return state; + + const stageId = nanoid(); + stageIds.splice(idx, 0, stageId); + + const parentId = idx > 0 ? (stageIds[idx - 1] ?? null) : null; + const childId = action.beforeStageId; + + const newStage: Stage = { + id: stageId, + config: { ...action.config }, + parentId, + childIds: [childId], + forkId: state.activeForkId, + position: idx, + }; + + const stages = new Map(state.stages); + stages.set(stageId, newStage); + + // Update child's parentId + const child = stages.get(childId); + if (child) { + stages.set(childId, { ...child, parentId: stageId }); + } + + // Update parent's childIds + if (parentId) { + const parent = stages.get(parentId); + if (parent) { + stages.set(parentId, { + ...parent, + childIds: parent.childIds.map((cid) => + cid === childId ? stageId : cid, + ), + }); + } + } + + recomputePositions(stages, stageIds); + + const forks = new Map(state.forks); + forks.set(state.activeForkId, { ...fork, stageIds }); + + return { + ...state, + stages, + forks, + cursorStageId: stageId, + }; + } + + // ── Delete stage ──────────────────────────────────────────── + case "DELETE_STAGE": { + const stage = state.stages.get(action.stageId); + if (!stage) return state; + + const fork = state.forks.get(stage.forkId); + if (!fork) return state; + + const stageIds = fork.stageIds.filter((id) => id !== action.stageId); + const stages = new Map(state.stages); + + // Re-link parent ↔ child around the deleted stage + if (stage.parentId) { + const parent = stages.get(stage.parentId); + if (parent) { + const newChildIds = parent.childIds + .filter((cid) => cid !== action.stageId) + .concat(stage.childIds); + stages.set(stage.parentId, { ...parent, childIds: newChildIds }); + } + } + for (const childId of stage.childIds) { + const child = stages.get(childId); + if (child) { + stages.set(childId, { ...child, parentId: stage.parentId }); + } + } + + stages.delete(action.stageId); + recomputePositions(stages, stageIds); + + const forks = new Map(state.forks); + forks.set(stage.forkId, { ...fork, stageIds }); + + // Move cursor to neighbor + let cursorStageId: StageId | null = null; + if (stageIds.length > 0) { + const oldIdx = fork.stageIds.indexOf(action.stageId); + const newIdx = Math.min(oldIdx, stageIds.length - 1); + cursorStageId = stageIds[newIdx] ?? null; + } + + return { + ...state, + stages, + forks, + cursorStageId, + lastError: + state.lastError?.stageId === action.stageId ? null : state.lastError, + }; + } + + // ── Update stage args ─────────────────────────────────────── + case "UPDATE_STAGE_ARGS": { + const stage = state.stages.get(action.stageId); + if (!stage) return state; + + const stages = new Map(state.stages); + stages.set(action.stageId, { + ...stage, + config: { ...stage.config, args: [...action.args] }, + }); + + const cache = invalidateStageAndDownstream(state.cache, state.forks, stage); + + return { ...state, stages, cache }; + } + + // ── Toggle stage enabled ──────────────────────────────────── + case "TOGGLE_STAGE": { + const stage = state.stages.get(action.stageId); + if (!stage) return state; + + const stages = new Map(state.stages); + stages.set(action.stageId, { + ...stage, + config: { ...stage.config, enabled: !stage.config.enabled }, + }); + + const cache = invalidateStageAndDownstream(state.cache, state.forks, stage); + + return { ...state, stages, cache }; + } + + // ── Reorder stage ─────────────────────────────────────────── + case "REORDER_STAGE": { + const stage = state.stages.get(action.stageId); + if (!stage) return state; + + const fork = state.forks.get(stage.forkId); + if (!fork) return state; + + const stageIds = [...fork.stageIds]; + const idx = stageIds.indexOf(action.stageId); + if (idx === -1) return state; + + const newIdx = + action.direction === "up" ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= stageIds.length) return state; + + // Swap positions + [stageIds[idx], stageIds[newIdx]] = [stageIds[newIdx]!, stageIds[idx]!]; + + const stages = new Map(state.stages); + + // Rebuild parent/child links for the swapped stages + rebuildLinksForFork(stages, stageIds); + recomputePositions(stages, stageIds); + + const forks = new Map(state.forks); + forks.set(stage.forkId, { ...fork, stageIds }); + + return { ...state, stages, forks }; + } + + // ── Create fork ───────────────────────────────────────────── + case "CREATE_FORK": { + const parentFork = state.forks.get(state.activeForkId); + if (!parentFork) return state; + + const forkId = nanoid(); + const newFork: Fork = { + id: forkId, + name: action.name, + forkPointStageId: action.atStageId, + parentForkId: state.activeForkId, + stageIds: [], + createdAt: Date.now(), + }; + + const forks = new Map(state.forks); + forks.set(forkId, newFork); + + return { + ...state, + forks, + activeForkId: forkId, + }; + } + + // ── Delete fork ───────────────────────────────────────────── + case "DELETE_FORK": { + const fork = state.forks.get(action.forkId); + if (!fork || fork.parentForkId === null) return state; // can't delete main fork + + const forks = new Map(state.forks); + forks.delete(action.forkId); + + // Remove all stages belonging to this fork + const stages = new Map(state.stages); + for (const stageId of fork.stageIds) { + stages.delete(stageId); + } + + const activeForkId = fork.parentForkId; + + return { + ...state, + stages, + forks, + activeForkId, + cursorStageId: null, + }; + } + + // ── Add input ─────────────────────────────────────────────── + case "ADD_INPUT": { + const inputId = nanoid(); + const inputs = new Map(state.inputs); + inputs.set(inputId, { + id: inputId, + source: action.source, + label: action.label, + }); + + return { ...state, inputs, activeInputId: inputId }; + } + + // ── Remove input ──────────────────────────────────────────── + case "REMOVE_INPUT": { + if (state.inputs.size <= 1) return state; // keep at least one + + const inputs = new Map(state.inputs); + inputs.delete(action.inputId); + + const activeInputId = + state.activeInputId === action.inputId + ? inputs.keys().next().value! + : state.activeInputId; + + return { ...state, inputs, activeInputId }; + } + + // ── Cursor movement ───────────────────────────────────────── + case "MOVE_CURSOR": { + const fork = state.forks.get(state.activeForkId); + if (!fork || fork.stageIds.length === 0) return state; + + const currentIdx = state.cursorStageId + ? fork.stageIds.indexOf(state.cursorStageId) + : -1; + + let newIdx: number; + if (action.direction === "up") { + newIdx = currentIdx <= 0 ? 0 : currentIdx - 1; + } else { + newIdx = + currentIdx >= fork.stageIds.length - 1 + ? fork.stageIds.length - 1 + : currentIdx + 1; + } + + return { + ...state, + cursorStageId: fork.stageIds[newIdx] ?? null, + }; + } + + case "SET_CURSOR": + return { ...state, cursorStageId: action.stageId }; + + // ── Switch input / fork ───────────────────────────────────── + case "SWITCH_INPUT": + return { ...state, activeInputId: action.inputId }; + + case "SWITCH_FORK": + return { ...state, activeForkId: action.forkId, cursorStageId: null }; + + // ── Cache ─────────────────────────────────────────────────── + case "CACHE_RESULT": { + const cache = new Map(state.cache); + cache.set(`${action.inputId}:${action.stageId}`, action.result); + return { ...state, cache }; + } + + case "INVALIDATE_STAGE": { + const cache = new Map(state.cache); + // Remove all cache entries for this stageId (any input) + for (const [key] of cache) { + if (key.endsWith(`:${action.stageId}`)) { + cache.delete(key); + } + } + return { ...state, cache }; + } + + case "PIN_STAGE": { + const pinnedStageIds = new Set(state.cacheConfig.pinnedStageIds); + if (pinnedStageIds.has(action.stageId)) { + pinnedStageIds.delete(action.stageId); + } else { + pinnedStageIds.add(action.stageId); + } + return { + ...state, + cacheConfig: { ...state.cacheConfig, pinnedStageIds }, + }; + } + + case "SET_CACHE_POLICY": + return { + ...state, + cacheConfig: { ...state.cacheConfig, cachePolicy: action.policy }, + }; + + // ── Error state ───────────────────────────────────────────── + case "SET_ERROR": + return { + ...state, + lastError: { stageId: action.stageId, message: action.message }, + }; + + case "CLEAR_ERROR": + return { ...state, lastError: null }; + + // ── Execution state ───────────────────────────────────────── + case "SET_EXECUTING": + return { ...state, executing: action.executing }; + + // ── Focus toggle ──────────────────────────────────────────── + case "TOGGLE_FOCUS": + return { + ...state, + focusedPanel: + state.focusedPanel === "pipeline" ? "inspector" : "pipeline", + }; + + // ── Inspector view mode ───────────────────────────────────── + case "SET_VIEW_MODE": + return { + ...state, + inspector: { ...state.inspector, viewMode: action.viewMode }, + }; + + default: + return state; + } +} + +// ── Helpers ───────────────────────────────────────────────────── + +function recomputePositions( + stages: Map, + stageIds: StageId[], +): void { + for (let i = 0; i < stageIds.length; i++) { + const id = stageIds[i]!; + const stage = stages.get(id); + if (stage && stage.position !== i) { + stages.set(id, { ...stage, position: i }); + } + } +} + +function rebuildLinksForFork( + stages: Map, + stageIds: StageId[], +): void { + for (let i = 0; i < stageIds.length; i++) { + const id = stageIds[i]!; + const stage = stages.get(id); + if (!stage) continue; + + const parentId = i > 0 ? (stageIds[i - 1] ?? null) : null; + const childId = + i < stageIds.length - 1 ? (stageIds[i + 1] ?? null) : null; + + stages.set(id, { + ...stage, + parentId, + childIds: childId ? [childId] : [], + }); + } +} + +/** + * Remove cache entries for a stage and all downstream stages in the same fork. + * Called when a stage's config changes (args update, toggle enabled) to ensure + * the inspector doesn't show stale cached results. + */ +function invalidateStageAndDownstream( + cache: Map, + forks: Map, + stage: Stage, +): Map { + const fork = forks.get(stage.forkId); + if (!fork) return cache; + + const idx = fork.stageIds.indexOf(stage.id); + if (idx === -1) return cache; + + // This stage + all stages after it in the fork + const toInvalidate = new Set(fork.stageIds.slice(idx)); + + const newCache = new Map(cache); + for (const [key] of newCache) { + for (const sid of toInvalidate) { + if (key.endsWith(`:${sid}`)) { + newCache.delete(key); + break; + } + } + } + return newCache; +} + +/** + * Pre-check whether an action would be a no-op so we can skip the undo checkpoint. + * Returns true if the action should be short-circuited (return state unchanged). + */ +function wouldBeNoop(state: PipelineState, action: PipelineAction): boolean { + switch (action.type) { + case "DELETE_STAGE": + case "UPDATE_STAGE_ARGS": + case "TOGGLE_STAGE": + return !state.stages.has(action.stageId); + case "INSERT_STAGE_BEFORE": + return !state.stages.has(action.beforeStageId); + case "REORDER_STAGE": { + const stage = state.stages.get(action.stageId); + if (!stage) return true; + const fork = state.forks.get(stage.forkId); + if (!fork) return true; + const idx = fork.stageIds.indexOf(action.stageId); + const newIdx = action.direction === "up" ? idx - 1 : idx + 1; + return newIdx < 0 || newIdx >= fork.stageIds.length; + } + case "DELETE_FORK": { + const fork = state.forks.get(action.forkId); + return !fork || fork.parentForkId === null; + } + case "REMOVE_INPUT": + return state.inputs.size <= 1 || !state.inputs.has(action.inputId); + default: + return false; + } +} diff --git a/src/tui/model/selectors.ts b/src/tui/model/selectors.ts new file mode 100644 index 0000000..753c421 --- /dev/null +++ b/src/tui/model/selectors.ts @@ -0,0 +1,108 @@ +import type { + PipelineState, + Stage, + StageId, + CachedResult, +} from "./types.ts"; + +/** + * Get the ordered list of stages in the active fork, walking from root to tip. + */ +export function getActivePath(state: PipelineState): Stage[] { + const fork = state.forks.get(state.activeForkId); + if (!fork) return []; + + const stages: Stage[] = []; + for (const stageId of fork.stageIds) { + const stage = state.stages.get(stageId); + if (stage) stages.push(stage); + } + return stages; +} + +/** + * Check whether a stage is downstream of the current error stage. + * A stage is "downstream" if it comes after the error stage in the + * active fork's stage order. + */ +export function isDownstreamOfError( + state: PipelineState, + stageId: StageId, +): boolean { + if (!state.lastError) return false; + + const fork = state.forks.get(state.activeForkId); + if (!fork) return false; + + const errorIdx = fork.stageIds.indexOf(state.lastError.stageId); + const stageIdx = fork.stageIds.indexOf(stageId); + + if (errorIdx === -1 || stageIdx === -1) return false; + return stageIdx > errorIdx; +} + +/** + * Get cached output for a specific stage + active input combination. + */ +export function getStageOutput( + state: PipelineState, + stageId: StageId, +): CachedResult | undefined { + const key = `${state.activeInputId}:${stageId}`; + return state.cache.get(key); +} + +/** + * Get the currently-cursored stage, if any. + */ +export function getCursorStage(state: PipelineState): Stage | undefined { + if (!state.cursorStageId) return undefined; + return state.stages.get(state.cursorStageId); +} + +/** + * Get the cached output for the currently-cursored stage. + */ +export function getCursorOutput( + state: PipelineState, +): CachedResult | undefined { + if (!state.cursorStageId) return undefined; + return getStageOutput(state, state.cursorStageId); +} + +/** + * Get all stages downstream of a given stage (exclusive) in the active fork. + */ +export function getDownstreamStages( + state: PipelineState, + stageId: StageId, +): Stage[] { + const fork = state.forks.get(state.activeForkId); + if (!fork) return []; + + const idx = fork.stageIds.indexOf(stageId); + if (idx === -1) return []; + + return fork.stageIds + .slice(idx + 1) + .map((id) => state.stages.get(id)) + .filter((s): s is Stage => s !== undefined); +} + +/** + * Get the total cache size in bytes for the current session. + */ +export function getTotalCacheSize(state: PipelineState): number { + let total = 0; + for (const entry of state.cache.values()) { + total += entry.sizeBytes; + } + return total; +} + +/** + * Get enabled stages in the active path (for export). + */ +export function getEnabledStages(state: PipelineState): Stage[] { + return getActivePath(state).filter((s) => s.config.enabled); +} diff --git a/src/tui/model/serialization.ts b/src/tui/model/serialization.ts new file mode 100644 index 0000000..ec19518 --- /dev/null +++ b/src/tui/model/serialization.ts @@ -0,0 +1,121 @@ +/** + * Pipeline export: serialize the active pipeline as a shell pipe script + * or a recs chain command. Also provides clipboard integration. + */ + +import type { PipelineState, InputSource, Stage } from "./types.ts"; +import { getEnabledStages } from "./selectors.ts"; + +/** + * Characters that need shell escaping (in addition to single quotes). + */ +const SHELL_SPECIAL = /[^a-zA-Z0-9_\-.,/:=@+%^~]/; + +/** + * Shell-escape a single argument. Uses single quotes for most strings, + * falls back to $'...' for strings containing single quotes. + */ +export function shellEscape(arg: string): string { + if (arg === "") return "''"; + if (!SHELL_SPECIAL.test(arg)) return arg; + // If no single quotes, wrap in single quotes + if (!arg.includes("'")) return `'${arg}'`; + // Contains single quotes: use $'...' syntax with escaped single quotes + return "$'" + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; +} + +/** + * Export the pipeline as a multi-line shell pipe script. + * + * Example output: + * ``` + * #!/usr/bin/env bash + * recs fromcsv --header data.csv \ + * | recs grep 'r.age > 25' \ + * | recs sort --key age=n \ + * | recs totable + * ``` + */ +export function exportAsPipeScript( + state: PipelineState, + inputSource?: InputSource, +): string { + const stages = getEnabledStages(state); + if (stages.length === 0) return "#!/usr/bin/env bash\n"; + + const input = inputSource ?? state.inputs.get(state.activeInputId); + + const lines = stages.map((stage) => formatStageCommand(stage)); + + // Append input file path to the first stage if applicable + if (input?.source.kind === "file") { + lines[0] = `${lines[0]} ${shellEscape(input.source.path)}`; + } + + const shebang = "#!/usr/bin/env bash"; + const body = lines.join(" \\\n | "); + return `${shebang}\n${body}\n`; +} + +/** + * Export the pipeline as a single-line `recs chain` command. + * + * Example output: + * ``` + * recs chain fromcsv --header \| grep 'r.age > 25' \| sort --key age=n \| totable + * ``` + */ +export function exportAsChainCommand( + state: PipelineState, +): string { + const stages = getEnabledStages(state); + if (stages.length === 0) return "recs chain"; + + const parts = stages.map((stage) => formatChainPart(stage)); + return `recs chain ${parts.join(" \\| ")}`; +} + +/** + * Format a stage as a `recs ` shell command fragment. + */ +function formatStageCommand(stage: Stage): string { + const parts = ["recs", stage.config.operationName]; + for (const arg of stage.config.args) { + parts.push(shellEscape(arg)); + } + return parts.join(" "); +} + +/** + * Format a stage as ` ` for chain command (no `recs` prefix). + */ +function formatChainPart(stage: Stage): string { + const parts = [stage.config.operationName]; + for (const arg of stage.config.args) { + parts.push(shellEscape(arg)); + } + return parts.join(" "); +} + +/** + * Copy text to the system clipboard. + * + * Strategy 1: Platform-specific CLI tool (pbcopy on macOS, xclip on Linux). + * Strategy 2: Write to a temp file as fallback. + */ +export async function copyToClipboard(text: string): Promise { + try { + const cmd = + process.platform === "darwin" + ? ["pbcopy"] + : ["xclip", "-selection", "clipboard"]; + + const proc = Bun.spawn(cmd, { stdin: "pipe" }); + proc.stdin.write(text); + proc.stdin.end(); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} diff --git a/src/tui/model/types.ts b/src/tui/model/types.ts new file mode 100644 index 0000000..a6e8886 --- /dev/null +++ b/src/tui/model/types.ts @@ -0,0 +1,148 @@ +import type { Record } from "../../Record.ts"; + +export type StageId = string; +export type ForkId = string; +export type InputId = string; +export type CacheKey = string; + +export interface StageConfig { + operationName: string; + args: string[]; + enabled: boolean; +} + +export interface Stage { + id: StageId; + config: StageConfig; + parentId: StageId | null; + childIds: StageId[]; + forkId: ForkId; + position: number; +} + +export interface Fork { + id: ForkId; + name: string; + forkPointStageId: StageId | null; + parentForkId: ForkId | null; + stageIds: StageId[]; + createdAt: number; +} + +export interface InputSource { + id: InputId; + source: + | { kind: "file"; path: string } + | { kind: "stdin-capture"; records: Record[] }; + label: string; +} + +export interface CachedResult { + key: CacheKey; + stageId: StageId; + inputId: InputId; + records: Record[]; + spillFile: string | null; + recordCount: number; + fieldNames: string[]; + computedAt: number; + sizeBytes: number; + computeTimeMs: number; +} + +export interface CacheConfig { + maxMemoryBytes: number; + cachePolicy: "all" | "selective" | "none"; + pinnedStageIds: Set; +} + +export interface InspectorState { + viewMode: "table" | "prettyprint" | "json" | "schema"; + scrollOffset: number; + searchQuery: string | null; +} + +export interface UndoEntry { + label: string; + snapshot: PipelineSnapshot; + timestamp: number; +} + +export interface PipelineSnapshot { + stages: Map; + forks: Map; + inputs: Map; + activeInputId: InputId; + activeForkId: ForkId; + cursorStageId: StageId | null; +} + +export interface PipelineState { + stages: Map; + forks: Map; + inputs: Map; + activeInputId: InputId; + activeForkId: ForkId; + cursorStageId: StageId | null; + focusedPanel: "pipeline" | "inspector"; + cache: Map; + cacheConfig: CacheConfig; + inspector: InspectorState; + executing: boolean; + lastError: { stageId: StageId; message: string } | null; + undoStack: UndoEntry[]; + redoStack: UndoEntry[]; + sessionId: string; + sessionDir: string; +} + +// ── Actions ─────────────────────────────────────────────────────── + +export type InputSourceType = + | { kind: "file"; path: string } + | { kind: "stdin-capture"; records: Record[] }; + +export type PipelineAction = + // Structural (undoable) + | { type: "ADD_STAGE"; afterStageId: StageId | null; config: StageConfig } + | { type: "DELETE_STAGE"; stageId: StageId } + | { type: "UPDATE_STAGE_ARGS"; stageId: StageId; args: string[] } + | { type: "TOGGLE_STAGE"; stageId: StageId } + | { type: "INSERT_STAGE_BEFORE"; beforeStageId: StageId; config: StageConfig } + | { type: "CREATE_FORK"; name: string; atStageId: StageId } + | { type: "DELETE_FORK"; forkId: ForkId } + | { type: "ADD_INPUT"; source: InputSourceType; label: string } + | { type: "REMOVE_INPUT"; inputId: InputId } + | { type: "REORDER_STAGE"; stageId: StageId; direction: "up" | "down" } + // Undo/Redo + | { type: "UNDO" } + | { type: "REDO" } + // Non-undoable (UI/cache state) + | { type: "MOVE_CURSOR"; direction: "up" | "down" } + | { type: "SET_CURSOR"; stageId: StageId } + | { type: "SWITCH_INPUT"; inputId: InputId } + | { type: "SWITCH_FORK"; forkId: ForkId } + | { type: "CACHE_RESULT"; inputId: InputId; stageId: StageId; result: CachedResult } + | { type: "INVALIDATE_STAGE"; stageId: StageId } + | { type: "PIN_STAGE"; stageId: StageId } + | { type: "SET_CACHE_POLICY"; policy: "all" | "selective" | "none" } + | { type: "SET_ERROR"; stageId: StageId; message: string } + | { type: "CLEAR_ERROR" } + | { type: "SET_EXECUTING"; executing: boolean } + | { type: "TOGGLE_FOCUS" } + | { type: "SET_VIEW_MODE"; viewMode: InspectorState["viewMode"] }; + +// ── File Size Warning ───────────────────────────────────────────── + +export interface FileSizeWarning { + path: string; + fileBytes: number; + estimatedRecords: number; + projectedCacheBytes: number; + acknowledged: boolean; +} + +export const FILE_SIZE_THRESHOLDS = { + warn: 100 * 1024 * 1024, // 100 MB + danger: 1024 * 1024 * 1024, // 1 GB +} as const; diff --git a/src/tui/model/undo.ts b/src/tui/model/undo.ts new file mode 100644 index 0000000..0468712 --- /dev/null +++ b/src/tui/model/undo.ts @@ -0,0 +1,68 @@ +import type { + PipelineState, + PipelineSnapshot, + PipelineAction, +} from "./types.ts"; + +export const MAX_UNDO_ENTRIES = 200; + +export const UNDOABLE_ACTIONS = new Set([ + "ADD_STAGE", + "DELETE_STAGE", + "UPDATE_STAGE_ARGS", + "TOGGLE_STAGE", + "INSERT_STAGE_BEFORE", + "CREATE_FORK", + "DELETE_FORK", + "ADD_INPUT", + "REMOVE_INPUT", + "REORDER_STAGE", +]); + +export function extractSnapshot(state: PipelineState): PipelineSnapshot { + return { + stages: new Map( + Array.from(state.stages.entries()).map(([id, stage]) => [ + id, + { ...stage, childIds: [...stage.childIds] }, + ]), + ), + forks: new Map( + Array.from(state.forks.entries()).map(([id, fork]) => [ + id, + { ...fork, stageIds: [...fork.stageIds] }, + ]), + ), + inputs: new Map(state.inputs), + activeInputId: state.activeInputId, + activeForkId: state.activeForkId, + cursorStageId: state.cursorStageId, + }; +} + +export function describeAction(action: PipelineAction): string { + switch (action.type) { + case "ADD_STAGE": + return `Add ${action.config.operationName} stage`; + case "DELETE_STAGE": + return "Delete stage"; + case "UPDATE_STAGE_ARGS": + return "Update stage arguments"; + case "TOGGLE_STAGE": + return "Toggle stage enabled"; + case "INSERT_STAGE_BEFORE": + return `Insert ${action.config.operationName} stage`; + case "CREATE_FORK": + return `Create fork "${action.name}"`; + case "DELETE_FORK": + return "Delete fork"; + case "ADD_INPUT": + return `Add input "${action.label}"`; + case "REMOVE_INPUT": + return "Remove input"; + case "REORDER_STAGE": + return `Move stage ${action.direction}`; + default: + return action.type; + } +} diff --git a/src/tui/utils/fuzzy-match.ts b/src/tui/utils/fuzzy-match.ts new file mode 100644 index 0000000..b80b956 --- /dev/null +++ b/src/tui/utils/fuzzy-match.ts @@ -0,0 +1,77 @@ +/** + * Simple fuzzy string matching for the AddStageModal search. + * + * Checks if all characters in the query appear in order within the target + * string (case-insensitive). Returns a score based on match quality: + * consecutive matches and early matches score higher. + */ + +export interface FuzzyResult { + matches: boolean; + score: number; +} + +export function fuzzyMatch(query: string, target: string): FuzzyResult { + if (query.length === 0) return { matches: true, score: 1 }; + + const q = query.toLowerCase(); + const t = target.toLowerCase(); + + // Quick check: exact substring match scores highest + const substringIdx = t.indexOf(q); + if (substringIdx !== -1) { + // Prefer matches at word boundaries (start of string or after space/-) + const atBoundary = + substringIdx === 0 || + t[substringIdx - 1] === " " || + t[substringIdx - 1] === "-"; + return { matches: true, score: atBoundary ? 100 : 80 }; + } + + // Fuzzy: check all query chars appear in order + let qi = 0; + let score = 0; + let prevMatchIdx = -2; // for detecting consecutive matches + + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + // Consecutive match bonus + if (ti === prevMatchIdx + 1) { + score += 5; + } else { + score += 1; + } + // Early match bonus + if (ti < 5) score += 2; + prevMatchIdx = ti; + qi++; + } + } + + if (qi < q.length) { + return { matches: false, score: 0 }; + } + + return { matches: true, score }; +} + +/** + * Filter and sort items by fuzzy match quality. + */ +export function fuzzyFilter( + items: T[], + query: string, + getText: (item: T) => string, +): T[] { + if (query.length === 0) return items; + + const scored = items + .map((item) => { + const result = fuzzyMatch(query, getText(item)); + return { item, ...result }; + }) + .filter((r) => r.matches) + .sort((a, b) => b.score - a.score); + + return scored.map((r) => r.item); +} diff --git a/tests/tui/executor/executor.test.ts b/tests/tui/executor/executor.test.ts new file mode 100644 index 0000000..b39afc2 --- /dev/null +++ b/tests/tui/executor/executor.test.ts @@ -0,0 +1,491 @@ +import { describe, test, expect } from "bun:test"; +import { Record } from "../../../src/Record.ts"; +import { InterceptReceiver } from "../../../src/tui/executor/intercept-receiver.ts"; +import { + executeToStage, + getStagePath, +} from "../../../src/tui/executor/executor.ts"; +import { loadInputRecords } from "../../../src/tui/executor/input-loader.ts"; +import { createOperation } from "../../../src/operations/transform/chain.ts"; +import type { + PipelineState, + Stage, + InputSource, + CacheConfig, + InspectorState, +} from "../../../src/tui/model/types.ts"; + +// ── Helpers ────────────────────────────────────────────────────────── + +function makeStage( + id: string, + opName: string, + args: string[], + parentId: string | null, + position: number, +): Stage { + return { + id, + config: { operationName: opName, args, enabled: true }, + parentId, + childIds: [], + forkId: "main", + position, + }; +} + +function makePipelineState( + stages: Stage[], + input: InputSource, +): PipelineState { + const stageMap = new Map(); + for (const s of stages) { + stageMap.set(s.id, s); + } + + // Wire up childIds + for (const s of stages) { + if (s.parentId) { + const parent = stageMap.get(s.parentId); + if (parent && !parent.childIds.includes(s.id)) { + parent.childIds.push(s.id); + } + } + } + + const cacheConfig: CacheConfig = { + maxMemoryBytes: 100 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + + const inspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + }; + + return { + stages: stageMap, + forks: new Map([ + [ + "main", + { + id: "main", + name: "main", + forkPointStageId: null, + parentForkId: null, + stageIds: stages.map((s) => s.id), + createdAt: Date.now(), + }, + ], + ]), + inputs: new Map([[input.id, input]]), + activeInputId: input.id, + activeForkId: "main", + cursorStageId: stages[stages.length - 1]?.id ?? null, + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig, + inspector, + executing: false, + lastError: null, + undoStack: [], + redoStack: [], + sessionId: "test-session", + sessionDir: "/tmp/recs-tui-test", + }; +} + +// ── InterceptReceiver tests ────────────────────────────────────────── + +describe("InterceptReceiver", () => { + test("collects records and tracks field names", () => { + const receiver = new InterceptReceiver(); + const r1 = new Record({ name: "Alice", age: 30 }); + const r2 = new Record({ name: "Bob", city: "NYC" }); + + receiver.acceptRecord(r1); + receiver.acceptRecord(r2); + receiver.finish(); + + expect(receiver.recordCount).toBe(2); + expect(receiver.records.length).toBe(2); + expect(receiver.fieldNames).toEqual(new Set(["name", "age", "city"])); + }); + + test("clones records so originals are not mutated", () => { + const receiver = new InterceptReceiver(); + const original = new Record({ x: 1 }); + + receiver.acceptRecord(original); + original.set("x", 999); + + expect(receiver.records[0]!.get("x")).toBe(1); + }); + + test("acceptLine collects lines", () => { + const receiver = new InterceptReceiver(); + receiver.acceptLine("hello"); + receiver.acceptLine("world"); + + expect(receiver.lines).toEqual(["hello", "world"]); + }); +}); + +// ── getStagePath tests ─────────────────────────────────────────────── + +describe("getStagePath", () => { + test("returns ordered path from root to target", () => { + const stages = [ + makeStage("s1", "grep", ["{{x}} > 1"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + makeStage("s3", "eval", ["{{y}} = {{x}} * 2"], "s2", 2), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }; + const state = makePipelineState(stages, input); + const path = getStagePath(state, "s3"); + + expect(path.length).toBe(3); + expect(path[0]!.id).toBe("s1"); + expect(path[1]!.id).toBe("s2"); + expect(path[2]!.id).toBe("s3"); + }); + + test("returns single stage for root stage", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 1"], null, 0)]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }; + const state = makePipelineState(stages, input); + const path = getStagePath(state, "s1"); + + expect(path.length).toBe(1); + expect(path[0]!.id).toBe("s1"); + }); + + test("returns empty array for unknown stage", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 1"], null, 0)]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }; + const state = makePipelineState(stages, input); + const path = getStagePath(state, "nonexistent"); + + expect(path.length).toBe(0); + }); +}); + +// ── input-loader tests ─────────────────────────────────────────────── + +describe("loadInputRecords", () => { + test("loads stdin-capture records directly", async () => { + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records }, + label: "test", + }; + + const loaded = await loadInputRecords(input); + expect(loaded.length).toBe(2); + expect(loaded[0]!.get("x")).toBe(1); + expect(loaded[1]!.get("x")).toBe(2); + }); +}); + +// ── executeToStage integration tests ───────────────────────────────── + +describe("executeToStage", () => { + test("executes a single grep stage", async () => { + const inputRecords = [ + new Record({ x: 1 }), + new Record({ x: 3 }), + new Record({ x: 5 }), + new Record({ x: 2 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [makeStage("s1", "grep", ["{{x}} > 2"], null, 0)]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s1"); + + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("x")).toBe(3); + expect(result.records[1]!.get("x")).toBe(5); + expect(result.fieldNames).toContain("x"); + }); + + test("executes a pipeline: grep → sort", async () => { + const inputRecords = [ + new Record({ x: 5, name: "eve" }), + new Record({ x: 1, name: "alice" }), + new Record({ x: 3, name: "charlie" }), + new Record({ x: 2, name: "bob" }), + new Record({ x: 4, name: "dave" }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s2"); + + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(3); + expect(result.records[1]!.get("x")).toBe(4); + expect(result.records[2]!.get("x")).toBe(5); + expect(result.fieldNames).toContain("x"); + expect(result.fieldNames).toContain("name"); + }); + + test("uses cached results when available", async () => { + const inputRecords = [ + new Record({ x: 5 }), + new Record({ x: 1 }), + new Record({ x: 3 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + // Pre-populate cache for s1 + state.cache.set("in1:s1", { + key: "in1:s1", + stageId: "s1", + inputId: "in1", + records: [new Record({ x: 5 }), new Record({ x: 3 })], + spillFile: null, + recordCount: 2, + fieldNames: ["x"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 10, + }); + + const result = await executeToStage(state, "s2"); + + // Sort should only see the cached records (5, 3) → sorted: (3, 5) + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("x")).toBe(3); + expect(result.records[1]!.get("x")).toBe(5); + }); + + test("handles disabled stages (pass-through)", async () => { + const inputRecords = [ + new Record({ x: 3 }), + new Record({ x: 1 }), + new Record({ x: 2 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + + const disabledStage = makeStage("s1", "grep", ["{{x}} > 100"], null, 0); + disabledStage.config.enabled = false; + + const stages = [ + disabledStage, + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s2"); + + // Disabled grep should be skipped, so sort gets all 3 records + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(1); + expect(result.records[1]!.get("x")).toBe(2); + expect(result.records[2]!.get("x")).toBe(3); + }); + + test("handles input op: fromcsv with parseContent", async () => { + const csvContent = "name,age\nAlice,30\nBob,25\nCharlie,35\n"; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: [] }, + label: "test.csv", + }; + // Override loadInputContent to return CSV + const stages = [ + makeStage("s1", "fromcsv", ["--header"], null, 0), + ]; + const state = makePipelineState(stages, input); + + // For stdin-capture with no records, loadInputContent returns "\n" + // Instead, let's use a file-based approach with a temp file + const tmpFile = `/tmp/recs-tui-test-${Date.now()}.csv`; + await Bun.write(tmpFile, csvContent); + + const fileInput: InputSource = { + id: "in1", + source: { kind: "file", path: tmpFile }, + label: "test.csv", + }; + state.inputs.set("in1", fileInput); + + const result = await executeToStage(state, "s1"); + + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("name")).toBe("Alice"); + expect(result.records[0]!.get("age")).toBe("30"); + expect(result.records[1]!.get("name")).toBe("Bob"); + expect(result.records[2]!.get("name")).toBe("Charlie"); + + // Clean up + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); + + test("handles error: bad grep expression", async () => { + const inputRecords = [new Record({ x: 1 })]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [ + makeStage("s1", "grep", ["this is not valid {{{{"], null, 0), + ]; + const state = makePipelineState(stages, input); + + await expect(executeToStage(state, "s1")).rejects.toThrow(); + }); + + test("caches intermediate results during multi-stage execution", async () => { + const inputRecords = [ + new Record({ x: 5, y: "b" }), + new Record({ x: 1, y: "a" }), + new Record({ x: 3, y: "c" }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [ + makeStage("s1", "sort", ["--key", "x=n"], null, 0), + makeStage("s2", "grep", ["{{x}} > 1"], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + await executeToStage(state, "s2"); + + // Both stages should be cached + expect(state.cache.has("in1:s1")).toBe(true); + expect(state.cache.has("in1:s2")).toBe(true); + + const s1Cache = state.cache.get("in1:s1")!; + expect(s1Cache.recordCount).toBe(3); + expect(s1Cache.records[0]!.get("x")).toBe(1); + + const s2Cache = state.cache.get("in1:s2")!; + expect(s2Cache.recordCount).toBe(2); + }); + + test("executes xform stage with field transformation", async () => { + const inputRecords = [ + new Record({ x: 10 }), + new Record({ x: 20 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test", + }; + const stages = [ + makeStage("s1", "xform", ["{{doubled}} = {{x}} * 2"], null, 0), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s1"); + + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("doubled")).toBe(20); + expect(result.records[1]!.get("doubled")).toBe(40); + expect(result.fieldNames).toContain("x"); + expect(result.fieldNames).toContain("doubled"); + }); +}); + +// ── Direct operation tests (using InterceptReceiver with createOperation) ─ + +describe("createOperation + InterceptReceiver", () => { + test("grep filters records through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("grep", ["{{x}} > 2"], receiver); + + op.acceptRecord(new Record({ x: 1 })); + op.acceptRecord(new Record({ x: 3 })); + op.acceptRecord(new Record({ x: 5 })); + op.finish(); + + expect(receiver.recordCount).toBe(2); + expect(receiver.records[0]!.get("x")).toBe(3); + expect(receiver.records[1]!.get("x")).toBe(5); + }); + + test("sort reorders records through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("sort", ["--key", "x=n"], receiver); + + op.acceptRecord(new Record({ x: 3 })); + op.acceptRecord(new Record({ x: 1 })); + op.acceptRecord(new Record({ x: 2 })); + op.finish(); + + expect(receiver.recordCount).toBe(3); + expect(receiver.records[0]!.get("x")).toBe(1); + expect(receiver.records[1]!.get("x")).toBe(2); + expect(receiver.records[2]!.get("x")).toBe(3); + }); + + test("collate aggregates records through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation( + "collate", + ["--key", "group", "-a", "count"], + receiver, + ); + + op.acceptRecord(new Record({ group: "a", val: 1 })); + op.acceptRecord(new Record({ group: "b", val: 2 })); + op.acceptRecord(new Record({ group: "a", val: 3 })); + op.finish(); + + expect(receiver.recordCount).toBe(2); + // collate produces one record per group + const groups = receiver.records.map((r) => r.get("group")).sort(); + expect(groups).toEqual(["a", "b"]); + }); +}); diff --git a/tests/tui/integration/pipeline-flow.test.ts b/tests/tui/integration/pipeline-flow.test.ts new file mode 100644 index 0000000..efd26db --- /dev/null +++ b/tests/tui/integration/pipeline-flow.test.ts @@ -0,0 +1,459 @@ +/** + * Integration tests for the TUI pipeline flow. + * + * These tests exercise the real reducer, real executor, and real operations + * together to verify the full lifecycle works correctly. + */ + +import { describe, test, expect } from "bun:test"; +import { Record } from "../../../src/Record.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/tui/model/reducer.ts"; +import { executeToStage } from "../../../src/tui/executor/executor.ts"; +import { + getActivePath, + getEnabledStages, + isDownstreamOfError, +} from "../../../src/tui/model/selectors.ts"; +import { + exportAsPipeScript, + exportAsChainCommand, +} from "../../../src/tui/model/serialization.ts"; +import type { + PipelineState, + PipelineAction, + StageConfig, +} from "../../../src/tui/model/types.ts"; + +// ── Helpers ────────────────────────────────────────────────────────── + +function dispatch(state: PipelineState, action: PipelineAction): PipelineState { + return pipelineReducer(state, action); +} + +function addStage( + state: PipelineState, + opName: string, + args: string[], +): PipelineState { + const config: StageConfig = { + operationName: opName, + args, + enabled: true, + }; + return dispatch(state, { + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); +} + +function addInput( + state: PipelineState, + records: Record[], + label = "test-input", +): PipelineState { + return dispatch(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records }, + label, + }); +} + +function getLastStageId(state: PipelineState): string { + const path = getActivePath(state); + return path[path.length - 1]!.id; +} + +function getStageIds(state: PipelineState): string[] { + return getActivePath(state).map((s) => s.id); +} + +// ── 1. Full pipeline lifecycle ─────────────────────────────────────── + +describe("Full pipeline lifecycle", () => { + test("create state → add stages → execute → verify records at each stage", async () => { + let state = createInitialState(); + + // Add input records + const records = [ + new Record({ name: "Alice", age: 30 }), + new Record({ name: "Bob", age: 20 }), + new Record({ name: "Charlie", age: 35 }), + new Record({ name: "Dave", age: 25 }), + ]; + state = addInput(state, records); + + // Add stages: grep (age > 25) → sort (age ascending) + state = addStage(state, "grep", ["{{age}} > 25"]); + const grepId = getLastStageId(state); + + state = addStage(state, "sort", ["--key", "age=n"]); + const sortId = getLastStageId(state); + + // Execute to grep stage + const grepResult = await executeToStage(state, grepId); + expect(grepResult.recordCount).toBe(2); + expect(grepResult.records.map((r) => r.get("name")).sort()).toEqual([ + "Alice", + "Charlie", + ]); + + // Execute to sort stage + const sortResult = await executeToStage(state, sortId); + expect(sortResult.recordCount).toBe(2); + expect(sortResult.records[0]!.get("name")).toBe("Alice"); + expect(sortResult.records[0]!.get("age")).toBe(30); + expect(sortResult.records[1]!.get("name")).toBe("Charlie"); + expect(sortResult.records[1]!.get("age")).toBe(35); + }); + + test("3-stage pipeline: grep → sort → collate", async () => { + let state = createInitialState(); + const records = [ + new Record({ group: "a", val: 10 }), + new Record({ group: "b", val: 20 }), + new Record({ group: "a", val: 30 }), + new Record({ group: "b", val: 5 }), + new Record({ group: "a", val: 15 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{val}} > 10"]); + state = addStage(state, "sort", ["--key", "group"]); + state = addStage(state, "collate", ["--key", "group", "-a", "count"]); + const collateId = getLastStageId(state); + + const result = await executeToStage(state, collateId); + expect(result.recordCount).toBe(2); + + const groups = result.records.map((r) => ({ + group: r.get("group"), + count: r.get("count"), + })); + const sortedGroups = groups.sort((a, b) => + String(a.group).localeCompare(String(b.group)), + ); + expect(sortedGroups[0]).toEqual({ group: "a", count: 2 }); + expect(sortedGroups[1]).toEqual({ group: "b", count: 1 }); + }); +}); + +// ── 2. Undo/redo + execution ───────────────────────────────────────── + +describe("Undo/redo + execution", () => { + test("undo removes stage, execution uses updated path", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 3 }), + new Record({ x: 1 }), + new Record({ x: 2 }), + ]; + state = addInput(state, records); + + // Add grep → sort + state = addStage(state, "grep", ["{{x}} > 1"]); + state = addStage(state, "sort", ["--key", "x=n"]); + + // Verify 2-stage pipeline works + const stageIds = getStageIds(state); + expect(stageIds.length).toBe(2); + + // Undo the sort addition + state = dispatch(state, { type: "UNDO" }); + const afterUndo = getStageIds(state); + expect(afterUndo.length).toBe(1); + + // Execute — should only run grep + const result = await executeToStage(state, afterUndo[0]!); + expect(result.recordCount).toBe(2); + + // Redo the sort + state = dispatch(state, { type: "REDO" }); + const afterRedo = getStageIds(state); + expect(afterRedo.length).toBe(2); + + // Execute the full pipeline again + // Clear cache to force re-execution + state = { ...state, cache: new Map() }; + const finalResult = await executeToStage(state, afterRedo[1]!); + expect(finalResult.recordCount).toBe(2); + expect(finalResult.records[0]!.get("x")).toBe(2); + expect(finalResult.records[1]!.get("x")).toBe(3); + }); +}); + +// ── 3. Cache invalidation ──────────────────────────────────────────── + +describe("Cache invalidation", () => { + test("modify middle stage args → downstream caches invalidated → re-execute", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 5 }), + new Record({ x: 1 }), + new Record({ x: 3 }), + new Record({ x: 2 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 1"]); + const grepId = getStageIds(state)[0]!; + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[1]!; + + // Execute full pipeline + await executeToStage(state, sortId); + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(true); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(true); + + // Update grep args to be more restrictive + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: grepId, + args: ["{{x}} > 3"], + }); + + // Invalidate downstream caches + state = dispatch(state, { type: "INVALIDATE_STAGE", stageId: grepId }); + state = dispatch(state, { type: "INVALIDATE_STAGE", stageId: sortId }); + + // Re-execute + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(1); + expect(result.records[0]!.get("x")).toBe(5); + }); +}); + +// ── 4. Error propagation ───────────────────────────────────────────── + +describe("Error propagation", () => { + test("bad expression in stage 2 → error captured → downstream marked", async () => { + let state = createInitialState(); + const records = [new Record({ x: 1 })]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getStageIds(state)[0]!; + state = addStage(state, "grep", ["this is not valid {{{{"]); // bad expression + const badId = getStageIds(state)[1]!; + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[2]!; + + // Execute should throw on the bad stage + let caughtError = false; + try { + await executeToStage(state, sortId); + } catch (e) { + caughtError = true; + // Set error in state + state = dispatch(state, { + type: "SET_ERROR", + stageId: badId, + message: String(e), + }); + } + + expect(caughtError).toBe(true); + expect(state.lastError).not.toBeNull(); + expect(state.lastError!.stageId).toBe(badId); + + // Stage 1 (grep) is not downstream of error + expect(isDownstreamOfError(state, grepId)).toBe(false); + // Bad stage itself is not "downstream" (it IS the error) + expect(isDownstreamOfError(state, badId)).toBe(false); + // Stage 3 (sort) IS downstream of error + expect(isDownstreamOfError(state, sortId)).toBe(true); + }); +}); + +// ── 5. Export round-trip ───────────────────────────────────────────── + +describe("Export round-trip", () => { + test("build pipeline → export as pipe script → verify valid shell", () => { + let state = createInitialState(); + const records = [new Record({ x: 1 })]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 2"]); + state = addStage(state, "sort", ["--key", "x=n"]); + state = addStage(state, "totable", []); + + const pipeScript = exportAsPipeScript(state); + expect(pipeScript).toContain("#!/usr/bin/env bash"); + expect(pipeScript).toContain("recs grep"); + expect(pipeScript).toContain("| recs sort --key x=n"); + expect(pipeScript).toContain("| recs totable"); + // Should have line continuations + expect(pipeScript).toContain("\\\n"); + + const chainCmd = exportAsChainCommand(state); + expect(chainCmd).toContain("recs chain"); + expect(chainCmd).toContain("grep"); + expect(chainCmd).toContain("\\| sort --key x=n"); + expect(chainCmd).toContain("\\| totable"); + }); + + test("export preserves stage order from reducer", () => { + let state = createInitialState(); + state = addInput(state, []); + + state = addStage(state, "grep", ["{{x}} > 1"]); + state = addStage(state, "sort", ["--key", "x=n"]); + state = addStage(state, "xform", ["{{y}} = {{x}} * 2"]); + + const chain = exportAsChainCommand(state); + const parts = chain.replace("recs chain ", "").split(" \\| "); + expect(parts[0]).toContain("grep"); + expect(parts[1]).toContain("sort"); + expect(parts[2]).toContain("xform"); + }); +}); + +// ── 6. Toggle stage ────────────────────────────────────────────────── + +describe("Toggle stage", () => { + test("disable a stage → execute → disabled stage is skipped", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 3 }), + new Record({ x: 1 }), + new Record({ x: 2 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 1"]); + const grepId = getStageIds(state)[0]!; + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[1]!; + + // Disable grep + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + + const stage = state.stages.get(grepId)!; + expect(stage.config.enabled).toBe(false); + + // Execute — grep is skipped, sort gets all 3 records + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(1); + expect(result.records[1]!.get("x")).toBe(2); + expect(result.records[2]!.get("x")).toBe(3); + + // Verify disabled stage is excluded from export + const enabledStages = getEnabledStages(state); + expect(enabledStages.length).toBe(1); + expect(enabledStages[0]!.config.operationName).toBe("sort"); + }); + + test("re-enable a stage → execute → stage processes again", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 3 }), + new Record({ x: 1 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 2"]); + const grepId = getStageIds(state)[0]!; + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[1]!; + + // Disable then re-enable grep + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + + expect(state.stages.get(grepId)!.config.enabled).toBe(true); + + // Clear cache and re-execute + state = { ...state, cache: new Map() }; + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(1); + expect(result.records[0]!.get("x")).toBe(3); + }); +}); + +// ── 7. Large pipeline ──────────────────────────────────────────────── + +describe("Large pipeline", () => { + test("10+ stages execute correctly through full path", async () => { + let state = createInitialState(); + + // Create 20 records with x: 1..20 + const records = Array.from({ length: 20 }, (_, i) => + new Record({ x: i + 1, group: i % 3 === 0 ? "a" : "b" }), + ); + state = addInput(state, records); + + // Build a 10-stage pipeline: + // 1. grep x > 5 + // 2. sort by x ascending + // 3. xform: add doubled field + // 4. grep doubled > 20 + // 5. sort by doubled descending + // 6-10: five more grep stages that each pass everything through + state = addStage(state, "grep", ["{{x}} > 5"]); + state = addStage(state, "sort", ["--key", "x=n"]); + state = addStage(state, "xform", ["{{doubled}} = {{x}} * 2"]); + state = addStage(state, "grep", ["{{doubled}} > 20"]); + state = addStage(state, "sort", ["--key", "doubled=-n"]); + + // Add 5 pass-through grep stages (always true) + for (let i = 0; i < 5; i++) { + state = addStage(state, "grep", ["true"]); + } + + const stageIds = getStageIds(state); + expect(stageIds.length).toBe(10); + + // Execute to final stage + const lastId = stageIds[stageIds.length - 1]!; + const result = await executeToStage(state, lastId); + + // x > 5 gives 15 records (6..20) + // doubled > 20 means x > 10, so records with x: 11..20 (10 records) + // sorted by doubled descending → x: 20, 19, 18, ..., 11 + expect(result.recordCount).toBe(10); + expect(result.records[0]!.get("x")).toBe(20); + expect(result.records[0]!.get("doubled")).toBe(40); + expect(result.records[9]!.get("x")).toBe(11); + expect(result.records[9]!.get("doubled")).toBe(22); + + // All intermediate stages should be cached + for (const id of stageIds) { + expect(state.cache.has(`${state.activeInputId}:${id}`)).toBe(true); + } + }); +}); + +// ── 8. Input op integration (fromcsv) ──────────────────────────────── + +describe("Input op integration", () => { + test("fromcsv as first stage with file input", async () => { + const csvContent = "name,score\nAlice,90\nBob,85\nCharlie,95\n"; + const tmpFile = `/tmp/recs-tui-integration-${Date.now()}.csv`; + await Bun.write(tmpFile, csvContent); + + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: tmpFile }, + label: "test.csv", + }); + + state = addStage(state, "fromcsv", ["--header"]); + state = addStage(state, "sort", ["--key", "score=-n"]); + const sortId = getLastStageId(state); + + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("name")).toBe("Charlie"); + expect(result.records[1]!.get("name")).toBe("Alice"); + expect(result.records[2]!.get("name")).toBe("Bob"); + + // Clean up + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); +}); diff --git a/tests/tui/model/reducer.test.ts b/tests/tui/model/reducer.test.ts new file mode 100644 index 0000000..a699632 --- /dev/null +++ b/tests/tui/model/reducer.test.ts @@ -0,0 +1,759 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { + pipelineReducer, + createInitialState, +} from "../../../src/tui/model/reducer.ts"; +import type { + PipelineState, + StageConfig, +} from "../../../src/tui/model/types.ts"; +import { + getActivePath, + isDownstreamOfError, + getStageOutput, + getDownstreamStages, +} from "../../../src/tui/model/selectors.ts"; +import { extractSnapshot, describeAction } from "../../../src/tui/model/undo.ts"; + +function makeConfig(name: string, args: string[] = []): StageConfig { + return { operationName: name, args, enabled: true }; +} + +function addStage( + state: PipelineState, + name: string, + args: string[] = [], +): PipelineState { + return pipelineReducer(state, { + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config: makeConfig(name, args), + }); +} + +function getStageIds(state: PipelineState): string[] { + const fork = state.forks.get(state.activeForkId)!; + return fork.stageIds; +} + +describe("pipelineReducer", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + // ── ADD_STAGE ─────────────────────────────────────────────── + + describe("ADD_STAGE", () => { + test("adds first stage to empty pipeline", () => { + state = addStage(state, "grep", ["status=200"]); + const path = getActivePath(state); + expect(path).toHaveLength(1); + expect(path[0]!.config.operationName).toBe("grep"); + expect(path[0]!.config.args).toEqual(["status=200"]); + expect(state.cursorStageId).toBe(path[0]!.id); + }); + + test("adds multiple stages in order", () => { + state = addStage(state, "fromre"); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + const path = getActivePath(state); + expect(path).toHaveLength(3); + expect(path.map((s) => s.config.operationName)).toEqual([ + "fromre", + "grep", + "sort", + ]); + }); + + test("adds stage after specific stage (not at end)", () => { + state = addStage(state, "fromre"); + const firstId = state.cursorStageId!; + state = addStage(state, "sort"); // cursor is now on sort + + // Insert between fromre and sort + state = pipelineReducer(state, { + type: "ADD_STAGE", + afterStageId: firstId, + config: makeConfig("grep"), + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["fromre", "grep", "sort"]); + }); + + test("maintains parent/child links", () => { + state = addStage(state, "fromre"); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + const path = getActivePath(state); + const [first, second, third] = path; + + expect(first!.parentId).toBeNull(); + expect(first!.childIds).toContain(second!.id); + + expect(second!.parentId).toBe(first!.id); + expect(second!.childIds).toContain(third!.id); + + expect(third!.parentId).toBe(second!.id); + expect(third!.childIds).toHaveLength(0); + }); + + test("positions are sequential", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const positions = getActivePath(state).map((s) => s.position); + expect(positions).toEqual([0, 1, 2]); + }); + }); + + // ── DELETE_STAGE ──────────────────────────────────────────── + + describe("DELETE_STAGE", () => { + test("removes stage from pipeline", () => { + state = addStage(state, "fromre"); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + const grepId = getActivePath(state)[1]!.id; + state = pipelineReducer(state, { + type: "DELETE_STAGE", + stageId: grepId, + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["fromre", "sort"]); + }); + + test("re-links parent and child on deletion", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const bId = getActivePath(state)[1]!.id; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: bId }); + + const path = getActivePath(state); + expect(path[0]!.childIds).toContain(path[1]!.id); + expect(path[1]!.parentId).toBe(path[0]!.id); + }); + + test("moves cursor to neighbor on deletion", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const cId = getActivePath(state)[2]!.id; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: cId }); + + // Cursor should move to the last remaining stage + const lastStage = getActivePath(state).at(-1)!; + expect(state.cursorStageId).toBe(lastStage.id); + }); + + test("sets cursor to null when last stage is deleted", () => { + state = addStage(state, "a"); + const id = state.cursorStageId!; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: id }); + + expect(state.cursorStageId).toBeNull(); + expect(getActivePath(state)).toHaveLength(0); + }); + + test("clears error if error stage is deleted", () => { + state = addStage(state, "a"); + const id = state.cursorStageId!; + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: id, + message: "bad", + }); + expect(state.lastError).not.toBeNull(); + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: id }); + expect(state.lastError).toBeNull(); + }); + + test("no-op for nonexistent stageId", () => { + state = addStage(state, "a"); + const before = state; + state = pipelineReducer(state, { + type: "DELETE_STAGE", + stageId: "nonexistent", + }); + expect(state).toBe(before); + }); + }); + + // ── REORDER_STAGE (move) ──────────────────────────────────── + + describe("REORDER_STAGE", () => { + test("moves stage up", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const cId = getActivePath(state)[2]!.id; + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: cId, + direction: "up", + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["a", "c", "b"]); + }); + + test("moves stage down", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const aId = getActivePath(state)[0]!.id; + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: aId, + direction: "down", + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["b", "a", "c"]); + }); + + test("no-op when moving first stage up", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + + const aId = getActivePath(state)[0]!.id; + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: aId, + direction: "up", + }); + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["a", "b"]); + }); + + test("no-op when moving last stage down", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + + const bId = getActivePath(state)[1]!.id; + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: bId, + direction: "down", + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["a", "b"]); + }); + + test("positions and links are correct after move", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const bId = getActivePath(state)[1]!.id; + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: bId, + direction: "up", + }); + + const path = getActivePath(state); + expect(path.map((s) => s.position)).toEqual([0, 1, 2]); + expect(path[0]!.parentId).toBeNull(); + expect(path[0]!.childIds).toContain(path[1]!.id); + expect(path[2]!.childIds).toHaveLength(0); + }); + }); + + // ── TOGGLE_STAGE ──────────────────────────────────────────── + + describe("TOGGLE_STAGE", () => { + test("disables an enabled stage", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + expect(state.stages.get(id)!.config.enabled).toBe(true); + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: id }); + expect(state.stages.get(id)!.config.enabled).toBe(false); + }); + + test("re-enables a disabled stage", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: id }); + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: id }); + expect(state.stages.get(id)!.config.enabled).toBe(true); + }); + + test("invalidates cache for toggled stage and downstream stages", () => { + state = addStage(state, "fromre"); + const fromreId = state.cursorStageId!; + state = addStage(state, "grep"); + const grepId = state.cursorStageId!; + state = addStage(state, "sort"); + const sortId = state.cursorStageId!; + + // Populate cache + const makeCacheResult = (stageId: string) => ({ + key: `key-${stageId}`, + stageId, + inputId: state.activeInputId, + records: [], + spillFile: null, + recordCount: 10, + fieldNames: ["a"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }); + + for (const id of [fromreId, grepId, sortId]) { + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: id, + result: makeCacheResult(id), + }); + } + + expect(state.cache.size).toBe(3); + + // Toggle grep — should invalidate grep + sort, but NOT fromre + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: grepId }); + + expect(getStageOutput(state, fromreId)).toBeDefined(); + expect(getStageOutput(state, grepId)).toBeUndefined(); + expect(getStageOutput(state, sortId)).toBeUndefined(); + }); + }); + + // ── UPDATE_STAGE_ARGS ─────────────────────────────────────── + + describe("UPDATE_STAGE_ARGS", () => { + test("updates args on existing stage", () => { + state = addStage(state, "grep", ["status=200"]); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "UPDATE_STAGE_ARGS", + stageId: id, + args: ["status=404"], + }); + + expect(state.stages.get(id)!.config.args).toEqual(["status=404"]); + }); + + test("invalidates cache for modified stage and downstream stages", () => { + state = addStage(state, "fromre"); + const fromreId = state.cursorStageId!; + state = addStage(state, "grep", ["status=200"]); + const grepId = state.cursorStageId!; + state = addStage(state, "sort"); + const sortId = state.cursorStageId!; + + // Populate cache for all stages + const makeCacheResult = (stageId: string) => ({ + key: `key-${stageId}`, + stageId, + inputId: state.activeInputId, + records: [], + spillFile: null, + recordCount: 10, + fieldNames: ["a"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }); + + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: fromreId, + result: makeCacheResult(fromreId), + }); + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: grepId, + result: makeCacheResult(grepId), + }); + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: sortId, + result: makeCacheResult(sortId), + }); + + expect(state.cache.size).toBe(3); + + // Update grep args — should invalidate grep + sort (downstream), but NOT fromre (upstream) + state = pipelineReducer(state, { + type: "UPDATE_STAGE_ARGS", + stageId: grepId, + args: ["status=404"], + }); + + expect(getStageOutput(state, fromreId)).toBeDefined(); + expect(getStageOutput(state, grepId)).toBeUndefined(); + expect(getStageOutput(state, sortId)).toBeUndefined(); + }); + }); + + // ── UNDO / REDO ───────────────────────────────────────────── + + describe("Undo/Redo", () => { + test("undo restores previous state after ADD_STAGE", () => { + state = addStage(state, "grep"); + expect(getActivePath(state)).toHaveLength(1); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(0); + }); + + test("redo restores undone state", () => { + state = addStage(state, "grep"); + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(0); + + state = pipelineReducer(state, { type: "REDO" }); + expect(getActivePath(state)).toHaveLength(1); + expect(getActivePath(state)[0]!.config.operationName).toBe("grep"); + }); + + test("redo stack clears on new structural action", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + + // Undo "add b" -> redo stack has 1 entry + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.redoStack).toHaveLength(1); + + // New action clears redo + state = addStage(state, "c"); + expect(state.redoStack).toHaveLength(0); + }); + + test("undo is no-op when stack is empty", () => { + const before = state; + const after = pipelineReducer(state, { type: "UNDO" }); + expect(after).toBe(before); + }); + + test("redo is no-op when stack is empty", () => { + const before = state; + const after = pipelineReducer(state, { type: "REDO" }); + expect(after).toBe(before); + }); + + test("multiple undo/redo cycle works correctly", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + expect(getActivePath(state)).toHaveLength(3); + + // Undo all three additions + state = pipelineReducer(state, { type: "UNDO" }); // remove c + expect(getActivePath(state)).toHaveLength(2); + + state = pipelineReducer(state, { type: "UNDO" }); // remove b + expect(getActivePath(state)).toHaveLength(1); + + state = pipelineReducer(state, { type: "UNDO" }); // remove a + expect(getActivePath(state)).toHaveLength(0); + + // Redo all three + state = pipelineReducer(state, { type: "REDO" }); + expect(getActivePath(state)).toHaveLength(1); + + state = pipelineReducer(state, { type: "REDO" }); + expect(getActivePath(state)).toHaveLength(2); + + state = pipelineReducer(state, { type: "REDO" }); + expect(getActivePath(state)).toHaveLength(3); + }); + + test("undo stack is capped at 200 entries", () => { + for (let i = 0; i < 210; i++) { + state = addStage(state, `op-${i}`); + } + expect(state.undoStack.length).toBeLessThanOrEqual(200); + // Should still have all 210 stages (cap only limits undo history, not stages) + expect(getActivePath(state)).toHaveLength(210); + }); + + test("undo restores cursor position", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + + state = addStage(state, "b"); + expect(state.cursorStageId).not.toBe(aId); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.cursorStageId).toBe(aId); + }); + + test("undo restores toggle state", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: id }); + expect(state.stages.get(id)!.config.enabled).toBe(false); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.stages.get(id)!.config.enabled).toBe(true); + }); + + test("non-undoable actions do not push to undo stack", () => { + state = addStage(state, "a"); + const stackLen = state.undoStack.length; + + state = pipelineReducer(state, { type: "TOGGLE_FOCUS" }); + expect(state.undoStack.length).toBe(stackLen); + + state = pipelineReducer(state, { + type: "SET_EXECUTING", + executing: true, + }); + expect(state.undoStack.length).toBe(stackLen); + }); + }); + + // ── Error propagation ─────────────────────────────────────── + + describe("Error propagation", () => { + test("SET_ERROR sets lastError", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: id, + message: "Invalid expression", + }); + + expect(state.lastError).toEqual({ + stageId: id, + message: "Invalid expression", + }); + }); + + test("CLEAR_ERROR clears lastError", () => { + state = addStage(state, "grep"); + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: state.cursorStageId!, + message: "bad", + }); + state = pipelineReducer(state, { type: "CLEAR_ERROR" }); + expect(state.lastError).toBeNull(); + }); + + test("isDownstreamOfError identifies downstream stages", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: aId, + message: "fail", + }); + + expect(isDownstreamOfError(state, aId)).toBe(false); + expect(isDownstreamOfError(state, bId)).toBe(true); + expect(isDownstreamOfError(state, cId)).toBe(true); + }); + + test("isDownstreamOfError returns false when no error", () => { + state = addStage(state, "a"); + const id = state.cursorStageId!; + expect(isDownstreamOfError(state, id)).toBe(false); + }); + }); + + // ── Selectors ─────────────────────────────────────────────── + + describe("Selectors", () => { + test("getActivePath returns stages in order", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const path = getActivePath(state); + expect(path.map((s) => s.config.operationName)).toEqual(["a", "b", "c"]); + }); + + test("getStageOutput returns cached result", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + expect(getStageOutput(state, id)).toBeUndefined(); + + const result = { + key: "abc", + stageId: id, + inputId: state.activeInputId, + records: [], + spillFile: null, + recordCount: 5, + fieldNames: ["ip", "status"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 10, + }; + + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: id, + result, + }); + + expect(getStageOutput(state, id)).toEqual(result); + }); + + test("getDownstreamStages returns correct stages", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + state = addStage(state, "c"); + + const downstream = getDownstreamStages(state, aId); + expect(downstream.map((s) => s.config.operationName)).toEqual([ + "b", + "c", + ]); + }); + }); + + // ── Cursor movement ───────────────────────────────────────── + + describe("MOVE_CURSOR", () => { + test("moves cursor down through stages", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + state = addStage(state, "c"); + + // Set cursor to start + state = pipelineReducer(state, { type: "SET_CURSOR", stageId: aId }); + expect(state.cursorStageId).toBe(aId); + + state = pipelineReducer(state, { + type: "MOVE_CURSOR", + direction: "down", + }); + expect(state.cursorStageId).toBe(bId); + }); + + test("clamps cursor at boundaries", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + + const stageIds = getStageIds(state); + + // Set to first, move up — stays at first + state = pipelineReducer(state, { + type: "SET_CURSOR", + stageId: stageIds[0]!, + }); + state = pipelineReducer(state, { + type: "MOVE_CURSOR", + direction: "up", + }); + expect(state.cursorStageId).toBe(stageIds[0]!); + + // Set to last, move down — stays at last + state = pipelineReducer(state, { + type: "SET_CURSOR", + stageId: stageIds[1]!, + }); + state = pipelineReducer(state, { + type: "MOVE_CURSOR", + direction: "down", + }); + expect(state.cursorStageId).toBe(stageIds[1]!); + }); + }); + + // ── Undo helper tests ────────────────────────────────────── + + describe("extractSnapshot / describeAction", () => { + test("extractSnapshot creates deep copy of maps", () => { + state = addStage(state, "grep"); + const snapshot = extractSnapshot(state); + + // Mutating state's maps should not affect snapshot + state.stages.clear(); + expect(snapshot.stages.size).toBe(1); + }); + + test("describeAction returns human-readable labels", () => { + expect( + describeAction({ + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }), + ).toBe("Add grep stage"); + + expect( + describeAction({ type: "DELETE_STAGE", stageId: "x" }), + ).toBe("Delete stage"); + + expect( + describeAction({ + type: "REORDER_STAGE", + stageId: "x", + direction: "up", + }), + ).toBe("Move stage up"); + }); + }); + + // ── INSERT_STAGE_BEFORE ───────────────────────────────────── + + describe("INSERT_STAGE_BEFORE", () => { + test("inserts stage before the specified stage", () => { + state = addStage(state, "a"); + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "INSERT_STAGE_BEFORE", + beforeStageId: cId, + config: makeConfig("b"), + }); + + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["a", "b", "c"]); + }); + }); + + // ── TOGGLE_FOCUS ──────────────────────────────────────────── + + describe("TOGGLE_FOCUS", () => { + test("toggles between pipeline and inspector", () => { + expect(state.focusedPanel).toBe("pipeline"); + state = pipelineReducer(state, { type: "TOGGLE_FOCUS" }); + expect(state.focusedPanel).toBe("inspector"); + state = pipelineReducer(state, { type: "TOGGLE_FOCUS" }); + expect(state.focusedPanel).toBe("pipeline"); + }); + }); +}); diff --git a/tests/tui/model/serialization.test.ts b/tests/tui/model/serialization.test.ts new file mode 100644 index 0000000..b087a53 --- /dev/null +++ b/tests/tui/model/serialization.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect } from "bun:test"; +import { + exportAsPipeScript, + exportAsChainCommand, + shellEscape, +} from "../../../src/tui/model/serialization.ts"; +import type { + PipelineState, + Stage, + InputSource, + CacheConfig, + InspectorState, +} from "../../../src/tui/model/types.ts"; + +// ── Helpers ────────────────────────────────────────────────────────── + +function makeStage( + id: string, + opName: string, + args: string[], + parentId: string | null, + position: number, + enabled = true, +): Stage { + return { + id, + config: { operationName: opName, args, enabled }, + parentId, + childIds: [], + forkId: "main", + position, + }; +} + +function makePipelineState( + stages: Stage[], + input?: InputSource, +): PipelineState { + const stageMap = new Map(); + for (const s of stages) { + stageMap.set(s.id, s); + } + + for (const s of stages) { + if (s.parentId) { + const parent = stageMap.get(s.parentId); + if (parent && !parent.childIds.includes(s.id)) { + parent.childIds.push(s.id); + } + } + } + + const defaultInput: InputSource = input ?? { + id: "in1", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }; + + const cacheConfig: CacheConfig = { + maxMemoryBytes: 100 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + + const inspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + }; + + return { + stages: stageMap, + forks: new Map([ + [ + "main", + { + id: "main", + name: "main", + forkPointStageId: null, + parentForkId: null, + stageIds: stages.map((s) => s.id), + createdAt: Date.now(), + }, + ], + ]), + inputs: new Map([[defaultInput.id, defaultInput]]), + activeInputId: defaultInput.id, + activeForkId: "main", + cursorStageId: stages[stages.length - 1]?.id ?? null, + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig, + inspector, + executing: false, + lastError: null, + undoStack: [], + redoStack: [], + sessionId: "test-session", + sessionDir: "/tmp/recs-tui-test", + }; +} + +// ── shellEscape tests ──────────────────────────────────────────────── + +describe("shellEscape", () => { + test("passes through simple strings", () => { + expect(shellEscape("hello")).toBe("hello"); + expect(shellEscape("--key")).toBe("--key"); + expect(shellEscape("x=n")).toBe("x=n"); + expect(shellEscape("foo/bar")).toBe("foo/bar"); + }); + + test("wraps strings with spaces in single quotes", () => { + expect(shellEscape("hello world")).toBe("'hello world'"); + }); + + test("wraps strings with special chars in single quotes", () => { + expect(shellEscape("{{x}} > 2")).toBe("'{{x}} > 2'"); + expect(shellEscape("r.name === 'Alice'")).toBe("$'r.name === \\'Alice\\''"); + }); + + test("handles empty string", () => { + expect(shellEscape("")).toBe("''"); + }); + + test("handles strings with single quotes using $'...' syntax", () => { + const result = shellEscape("it's a test"); + expect(result).toBe("$'it\\'s a test'"); + }); +}); + +// ── exportAsPipeScript tests ───────────────────────────────────────── + +describe("exportAsPipeScript", () => { + test("exports empty pipeline", () => { + const state = makePipelineState([]); + const result = exportAsPipeScript(state); + expect(result).toBe("#!/usr/bin/env bash\n"); + }); + + test("exports single stage", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 2"], null, 0)]; + const state = makePipelineState(stages); + const result = exportAsPipeScript(state); + + expect(result).toContain("#!/usr/bin/env bash"); + expect(result).toContain("recs grep '{{x}} > 2'"); + }); + + test("exports multi-stage pipeline with pipe operators", () => { + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + makeStage("s3", "totable", [], "s2", 2), + ]; + const state = makePipelineState(stages); + const result = exportAsPipeScript(state); + + expect(result).toContain("#!/usr/bin/env bash"); + expect(result).toContain("recs grep '{{x}} > 2'"); + expect(result).toContain("| recs sort --key x=n"); + expect(result).toContain("| recs totable"); + // Multi-line pipe format + expect(result).toContain("\\\n"); + }); + + test("includes input file path", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 2"], null, 0)]; + const input: InputSource = { + id: "in1", + source: { kind: "file", path: "/tmp/data.jsonl" }, + label: "data.jsonl", + }; + const state = makePipelineState(stages, input); + const result = exportAsPipeScript(state); + + expect(result).toContain("recs grep '{{x}} > 2' /tmp/data.jsonl"); + }); + + test("skips disabled stages", () => { + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1, false), // disabled + makeStage("s3", "totable", [], "s2", 2), + ]; + const state = makePipelineState(stages); + const result = exportAsPipeScript(state); + + expect(result).toContain("recs grep"); + expect(result).not.toContain("recs sort"); + expect(result).toContain("recs totable"); + }); + + test("escapes special characters in args", () => { + const stages = [ + makeStage("s1", "xform", ["{{name}} = 'Alice & Bob'"], null, 0), + ]; + const state = makePipelineState(stages); + const result = exportAsPipeScript(state); + + // The arg should be escaped using $'...' syntax (single quotes inside) + expect(result).toContain("$'"); + expect(result).toContain("recs xform"); + }); + + test("escapes file paths with spaces", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 2"], null, 0)]; + const input: InputSource = { + id: "in1", + source: { kind: "file", path: "/tmp/my data/file name.jsonl" }, + label: "file name.jsonl", + }; + const state = makePipelineState(stages, input); + const result = exportAsPipeScript(state); + + expect(result).toContain("'/tmp/my data/file name.jsonl'"); + }); +}); + +// ── exportAsChainCommand tests ─────────────────────────────────────── + +describe("exportAsChainCommand", () => { + test("exports empty pipeline", () => { + const state = makePipelineState([]); + expect(exportAsChainCommand(state)).toBe("recs chain"); + }); + + test("exports single stage", () => { + const stages = [makeStage("s1", "grep", ["{{x}} > 2"], null, 0)]; + const state = makePipelineState(stages); + const result = exportAsChainCommand(state); + + expect(result).toBe("recs chain grep '{{x}} > 2'"); + }); + + test("exports multi-stage pipeline with \\| separator", () => { + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1), + makeStage("s3", "totable", [], "s2", 2), + ]; + const state = makePipelineState(stages); + const result = exportAsChainCommand(state); + + expect(result).toBe( + "recs chain grep '{{x}} > 2' \\| sort --key x=n \\| totable", + ); + }); + + test("skips disabled stages", () => { + const stages = [ + makeStage("s1", "grep", ["{{x}} > 2"], null, 0), + makeStage("s2", "sort", ["--key", "x=n"], "s1", 1, false), // disabled + makeStage("s3", "totable", [], "s2", 2), + ]; + const state = makePipelineState(stages); + const result = exportAsChainCommand(state); + + expect(result).toBe("recs chain grep '{{x}} > 2' \\| totable"); + }); + + test("handles args with multiple flags", () => { + const stages = [ + makeStage( + "s1", + "collate", + ["--key", "group", "-a", "count", "-a", "sum,value"], + null, + 0, + ), + ]; + const state = makePipelineState(stages); + const result = exportAsChainCommand(state); + + expect(result).toBe("recs chain collate --key group -a count -a sum,value"); + }); +}); diff --git a/tests/tui/utils/fuzzy-match.test.ts b/tests/tui/utils/fuzzy-match.test.ts new file mode 100644 index 0000000..d2d4b99 --- /dev/null +++ b/tests/tui/utils/fuzzy-match.test.ts @@ -0,0 +1,88 @@ +import { describe, test, expect } from "bun:test"; +import { fuzzyMatch, fuzzyFilter } from "../../../src/tui/utils/fuzzy-match.ts"; + +describe("fuzzyMatch", () => { + test("empty query matches everything", () => { + const result = fuzzyMatch("", "anything"); + expect(result.matches).toBe(true); + expect(result.score).toBe(1); + }); + + test("exact substring match scores high", () => { + const result = fuzzyMatch("grep", "grep - Filter records"); + expect(result.matches).toBe(true); + expect(result.score).toBeGreaterThanOrEqual(80); + }); + + test("word-boundary substring scores highest", () => { + const atBoundary = fuzzyMatch("sort", "sort records by key"); + const midWord = fuzzyMatch("sort", "resorting items"); + expect(atBoundary.score).toBeGreaterThan(midWord.score); + }); + + test("fuzzy match works for scattered chars", () => { + const result = fuzzyMatch("gp", "grep"); + expect(result.matches).toBe(true); + expect(result.score).toBeGreaterThan(0); + }); + + test("non-matching query returns false", () => { + const result = fuzzyMatch("xyz", "grep"); + expect(result.matches).toBe(false); + expect(result.score).toBe(0); + }); + + test("case-insensitive matching", () => { + const result = fuzzyMatch("GREP", "grep filter"); + expect(result.matches).toBe(true); + }); + + test("consecutive match bonus increases score", () => { + const consecutive = fuzzyMatch("gre", "grep"); + const scattered = fuzzyMatch("gre", "g.r.e.p"); + expect(consecutive.score).toBeGreaterThan(scattered.score); + }); + + test("query longer than target does not match", () => { + const result = fuzzyMatch("longquery", "short"); + expect(result.matches).toBe(false); + }); +}); + +describe("fuzzyFilter", () => { + const items = [ + { name: "grep", desc: "Filter records" }, + { name: "sort", desc: "Sort records by key" }, + { name: "collate", desc: "Group and aggregate" }, + { name: "fromcsv", desc: "Read CSV input" }, + { name: "totable", desc: "Format as table" }, + ]; + + test("empty query returns all items", () => { + const result = fuzzyFilter(items, "", (i) => `${i.name} ${i.desc}`); + expect(result).toHaveLength(items.length); + }); + + test("filters to matching items only", () => { + const result = fuzzyFilter(items, "sort", (i) => `${i.name} ${i.desc}`); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]!.name).toBe("sort"); + }); + + test("sorts by score — exact name match first", () => { + const result = fuzzyFilter(items, "table", (i) => `${i.name} ${i.desc}`); + // "totable" has "table" as a substring, should rank high + expect(result.some((r) => r.name === "totable")).toBe(true); + }); + + test("no matches returns empty array", () => { + const result = fuzzyFilter(items, "zzzzz", (i) => `${i.name} ${i.desc}`); + expect(result).toHaveLength(0); + }); + + test("matches against combined name + description", () => { + const result = fuzzyFilter(items, "CSV", (i) => `${i.name} ${i.desc}`); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]!.name).toBe("fromcsv"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e975b2a..433b6c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,9 @@ "resolveJsonModule": true, "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", + "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, @@ -23,5 +26,5 @@ "exactOptionalPropertyTypes": false, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "bin/**/*.ts", "scripts/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "bin/**/*.ts", "scripts/**/*.ts"] }